Compare commits
5 Commits
47b3b9f93a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d6b8b509f | |||
| a31d273bfe | |||
| 6e4de611e6 | |||
| 5feacb4799 | |||
| 137b83d8b6 |
@@ -3,15 +3,15 @@
|
||||
<!-- ASTRA:AUTO-START -->
|
||||
|
||||
## Snapshot
|
||||
- **Workspace**: `connectai` `v2.2.248` _(absolute path varies by environment; resolved from the active VS Code workspace)_
|
||||
- **Workspace**: `connectai` `v2.2.312` _(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**: 560 source files, ~83,596 lines across 5 top-level modules.
|
||||
- **Stats**: 617 source files, ~90,403 lines across 5 top-level modules.
|
||||
|
||||
## Last Refresh
|
||||
- **Time**: 2026-06-15T02:56:35.947Z
|
||||
- **Time**: 2026-07-20T04:20:03.384Z
|
||||
- **Files newly analysed**: 1
|
||||
- **Files reused from cache**: 559
|
||||
- **Files reused from cache**: 616
|
||||
|
||||
## Directory Map
|
||||
```mermaid
|
||||
@@ -19,11 +19,11 @@ mindmap
|
||||
root((connectai))
|
||||
src/
|
||||
features/
|
||||
sidebar/
|
||||
lib/
|
||||
agent/
|
||||
intelligence/
|
||||
sidebar/
|
||||
retrieval/
|
||||
intelligence/
|
||||
media/
|
||||
tests/
|
||||
helpers/
|
||||
@@ -41,11 +41,11 @@ mindmap
|
||||
> Arrows: which top-level module imports from which.
|
||||
```mermaid
|
||||
flowchart LR
|
||||
src["src/<br/>317 files"]
|
||||
src["src/<br/>342 files"]
|
||||
media["media/<br/>6 files"]
|
||||
tests["tests/<br/>58 files"]
|
||||
tests["tests/<br/>86 files"]
|
||||
core_py["core_py/<br/>6 files"]
|
||||
docs["docs/<br/>173 files"]
|
||||
docs["docs/<br/>177 files"]
|
||||
tests --> src
|
||||
```
|
||||
|
||||
@@ -57,71 +57,71 @@ flowchart LR
|
||||
|
||||
## Hub Files
|
||||
> Imported by many other files — touching these has wide blast radius.
|
||||
- `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 **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/utils.ts` — referenced by **116** files
|
||||
- `src/config.ts` — referenced by **44** files
|
||||
- `src/agent.ts` — referenced by **37** files
|
||||
- `src/core/services.ts` — referenced by **23** files
|
||||
- `src/retrieval/brainIndex.ts` — referenced by **14** files · Brain Index — persistent, mtime-keyed tokenized cache of the Second Brain RAG 검색은 매 질의마다 브레인의 모든 .md 파일을 읽고 토크나이즈해서 TF-IDF 점수를 계산했습니다 — 파일 수가 많아지면 그게 병목입니다. 이 모듈은 <brainPath>/.astra/brain-index.json 에
|
||||
- `src/agent/actions/types.ts` — referenced by **13** files
|
||||
- `src/integrations/telegram/telegramClient.ts` — referenced by **13** files
|
||||
- `src/features/company/types.ts` — referenced by **13** files · Type definitions for the 1인 기업 (One-Person Company) mode. The mode turns the user into a virtual CEO that dispatches work to a roster of specialist agents. Each turn produces a session directory conta
|
||||
|
||||
## Modules
|
||||
|
||||
### `src/` — 317 files, ~61,246 lines
|
||||
### `src/` — 342 files, ~65,381 lines
|
||||
|
||||
**Sub-directories**
|
||||
- `src/features/` (112) — Astra Office — public API. 다음 세션에서 추가될 OfficeSnapshot presenter / schema 도 같은 entry 로 노출 예정. 현재 노출: full webview panel H
|
||||
- `src/sidebar/` (35) — Brain profile lifecycle 의 pure helpers — sidebarProvider 의 add/edit/delete 흐름에서 modal UI 와 config 쓰기를 제외한 데이터 변환 만 격리. 현
|
||||
- `src/lib/` (33) — Astra Mode Architecture Context Builder. 의도: 사용자가 Astra 자체의 mode 디자인 (Guard vs Multi-Agent 가 별도 모드여야 하는지) 을 묻는 메타 질문에 답할
|
||||
- `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/features/` (126) — 데일리 브리핑 워처 — 평일(월~금) KST 09:30 에 "오늘의 할 일"을 텔레그램으로 발송. 소스: 1. Google Calendar — iCal 캐시 새로고침 후 오늘 일정 (readCalendarEvents
|
||||
- `src/lib/` (37) — Astra Mode Architecture Context Builder. 의도: 사용자가 Astra 자체의 mode 디자인 (Guard vs Multi-Agent 가 별도 모드여야 하는지) 을 묻는 메타 질문에 답할
|
||||
- `src/agent/` (36) — <investigatefiles path="..." focus="..."/> — 폴더(또는 단일 파일) 강제 정독 조사. listfiles 가 "이름 목록"을 주는 것과 달리, 이 액션은 대상 파일들을 코드가 직접
|
||||
- `src/sidebar/` (32) — Brain profile lifecycle 의 pure helpers — sidebarProvider 의 add/edit/delete 흐름에서 modal UI 와 config 쓰기를 제외한 데이터 변환 만 격리. 현
|
||||
- `src/retrieval/` (20) — Actionability Scoring — 검색 결과를 "현재 작업 상태" 신호로 재가중. 기존 TF-IDF (단어 매칭) + recency (시간) 만으로는 "지금 이 사용자가 하고 있는 작업과 직접 연결 된 문서
|
||||
- `src/intelligence/` (19) — Confidence Engine — 답변 확신도 0~100 결정론적 산출. Self-Evolving OS 마스터 플랜 Phase 2 / Track 1-1. 신뢰 조건 T4 "확신이 없으면 사람에게 묻는다" 의 측정
|
||||
- `src/core/` (15) — Astra Path Resolver (경로 해결기) Astra의 모든 데이터 파일(.astra 디렉토리)의 경로를 중앙에서 관리합니다. 확장 프로그램의 설치 경로(extensionUri) 기반으로 .astra 디렉토
|
||||
- `src/extension/` (13) — 두뇌(Second Brain) 기본 위치 부트스트랩 — 첫 실행 온보딩. 문제: 두뇌 미설정 시 config 가 ~/.g1nation-brain(숨김 점폴더)로 조용히 폴백했다. - 폴더가 실제로 생성되지 않고, 설
|
||||
- `src/memory/` (9) — Distillation Loop — stale Episodic Memory → Long-Term "episode-digest" 승급. 배경: Episodic Memory 가 무한히 누적되면 검색 노이즈. 30일+ 지
|
||||
- `src/integrations/` (7) — Per-chat conversation history for the Telegram bot. Why this exists: the previous bot was stateless — every inbound mess
|
||||
- `src/docs/` (6) — Bug: Edited agent.ts Edited agent.ts Edited agent.ts Edited agent.ts Edited agent.ts ...
|
||||
- `src/integrations/` (6) — Per-chat conversation history for the Telegram bot. Why this exists: the previous bot was stateless — every inbound mess
|
||||
- `src/lmstudio/` (4) — 4 files (.ts)
|
||||
- `src/lmstudio/` (4) — [메모리·기동 개선] SDK 를 타입 전용으로만 import — 값 import 는 번들 로드(=확장 활성화) 시점에 SDK 모듈 초기화를 실행시킨다. 실제 모듈 로드는 getSdk() 첫 호출 때 require 로
|
||||
|
||||
**Key files**
|
||||
- `src/utils.ts` (485 lines)
|
||||
- `src/config.ts` (661 lines)
|
||||
- `src/agent.ts` (1677 lines)
|
||||
- `src/utils.ts` (538 lines)
|
||||
- `src/config.ts` (727 lines)
|
||||
- `src/agent.ts` (1979 lines)
|
||||
- `src/core/services.ts` (218 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` (3468 lines)
|
||||
- `src/integrations/telegram/telegramClient.ts` (154 lines)
|
||||
- `src/sidebarProvider.ts` (2992 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/integrations/telegram/telegramClient.ts` (154 lines)
|
||||
- `src/lib/contextManager.ts` (296 lines) — Context Manager (컨텍스트 한계 관리) "context length = 132k" 는 "답변을 132k 토큰까지 생성해도 된다" 가 아닙니다. 시스템 프롬프트 + 대화 기록 + 입력 문서 + 생성될 답변 + 여유분 ≤ context length 이 모듈은 요청을 보내기 전에 입력 토큰을 추정하고, - 동적으로 출력 상한(maxTokens)을 계
|
||||
- `src/retrieval/brainIndex.ts` (645 lines) — Brain Index — persistent, mtime-keyed tokenized cache of the Second Brain RAG 검색은 매 질의마다 브레인의 모든 .md 파일을 읽고 토크나이즈해서 TF-IDF 점수를 계산했습니다 — 파일 수가 많아지면 그게 병목입니다. 이 모듈은 <brainPath>/.astra/brain-index.json 에
|
||||
- `src/features/datacollect/slashRouter.ts` (211 lines)
|
||||
- `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/features/stocks/types.ts` (61 lines) — Stocks 모듈 공유 타입. investresults/targetstocks.json 스키마를 그대로 받아서, ConnectAI 의 <workspace>/.astra/stocks.json 으로 옮긴 뒤 같은 필드명을 유지. 한글 필드명은 사용자의 도메인 데이터라 변경하지 않는다 — 마이그레이션 충돌 회피 + 사용자가 직접 JSON 편집할 때 frictio
|
||||
- `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/intelligence/requirementGraph.ts` (312 lines) — Requirement Graph — 업무 유형별 필수 요소 정의 + 감지 + 커버리지 검사. Self-Evolving Digital Employee OS 마스터 플랜(docs/SELFEVOLVINGOSMASTERPLAN.md) Phase 1 / Track 2-1. 신뢰 조건 T3 "품질이 일관적이다 — 필수 요소 누락 없음" 담당. 동작 2단계: 1. In
|
||||
- `src/security.ts` (159 lines)
|
||||
- `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/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)
|
||||
- `src/lib/execUtil.ts` (104 lines)
|
||||
- `src/retrieval/lessonHelpers.ts` (325 lines) — Lesson / Experience Memory — pure helpers (no vscode dependency) "Lesson" = a markdown file in the active brain that captures a past mistake/risk and how to avoid repeating it. Identified by a lessons
|
||||
- `src/features/calendar/calendarCache.ts` (271 lines) — Google Calendar (iCal) 캐시 — fetch + parse + 회사 shared/calendarcache.md 에 저장. Connectorigin 의 googlecalendar.py 를 TypeScript / native fetch 로 옮김. OAuth 없음. 사용자가 Google Calendar 설정 → "비공개 주소(iCal 형식)" 복
|
||||
|
||||
### `media/` — 6 files, ~7,836 lines
|
||||
### `media/` — 6 files, ~7,582 lines
|
||||
|
||||
**Key files**
|
||||
- `media/sidebar.css` (2132 lines) — Stylesheet
|
||||
- `media/sidebar.js` (3966 lines)
|
||||
- `media/sidebar.html` (539 lines) — Astra
|
||||
- `media/settings-panel.html` (440 lines) — Astra Settings
|
||||
- `media/settings-panel.js` (505 lines)
|
||||
- `media/sidebar.css` (1957 lines) — Stylesheet
|
||||
- `media/sidebar.js` (3767 lines)
|
||||
- `media/settings-panel.html` (541 lines) — Astra Settings
|
||||
- `media/sidebar.html` (486 lines) — Astra
|
||||
- `media/settings-panel.js` (577 lines)
|
||||
- `media/settings-panel.css` (254 lines) — Stylesheet
|
||||
|
||||
### `tests/` — 58 files, ~8,276 lines
|
||||
### `tests/` — 86 files, ~11,085 lines
|
||||
*Depends on*: `src/`
|
||||
|
||||
**Sub-directories**
|
||||
@@ -132,29 +132,29 @@ flowchart LR
|
||||
**Key files**
|
||||
- `tests/helpers/mockLLMClient.ts` (112 lines) — MockLLMClient — IAIService 의 Mock 구현체. 의도: 회사 모드 dispatcher / ChunkedWriter / ceoPlanner 등 LLM 을 호출하는 코드 경로를 CI 환경에서도 테스트 가능하게. 실제 Ollama / LM Studio 없이도 응답을 미리 정의하거나 동적으로 생성 가능. 사용 예: const ai = new
|
||||
- `tests/agentEngine.test.ts` (413 lines) — AgentEngine Tests — Chunked Writer Architecture 예전 buildup(planner → researcher → reflector → writer → synthesizer)을 단일 ChunkedWriter 의 outline → section[N] → polish 로 교체한 뒤의 회귀 테스트. 다루는 범위: 1. ErrorC
|
||||
- `tests/lmStudioLifecycle.test.ts` (330 lines) — Unit tests for ModelLifecycleManager. Strategy: inject mock ILMStudioClient and a simple in-memory IActivityTracker. No real LM Studio or SDK is touched — the manager file does not import the SDK dire
|
||||
- `tests/lmStudioLifecycle.test.ts` (334 lines) — Unit tests for ModelLifecycleManager. Strategy: inject mock ILMStudioClient and a simple in-memory IActivityTracker. No real LM Studio or SDK is touched — the manager file does not import the SDK dire
|
||||
- `tests/localPathPreflight.test.ts` (520 lines)
|
||||
- `tests/telegramBot.test.ts` (363 lines) — Unit tests for TelegramBot + truncateForTelegram. Strategy: - TelegramBot is driven by an injected ITelegramClient stub. We script getUpdates to return queued batches and assert that: - the offset cur
|
||||
- `tests/criticReflectionEval.test.ts` (200 lines) — Critic Agent / Reflection Store / Task Eval Harness (Self-Evolving OS P1 잔여 + P3) 테스트.
|
||||
- `tests/lmStudioStreamer.test.ts` (222 lines) — Unit tests for LMStudioStreamer. Strategy: inject a fake ILMStudioClient that returns a fake model handle whose respond() yields a controllable async iterable. No real SDK or WebSocket touched.
|
||||
- `tests/emailCommand.test.ts` (282 lines) — v2.2.269 — /email (Gmail + Outlook) 순수 로직 테스트. COM/네트워크는 통합 영역이라 제외 — 참조 파싱·프롬프트·트랜스크립트 컷만 고정.
|
||||
- `tests/lmStudioStreamer.test.ts` (295 lines) — Unit tests for LMStudioStreamer. Strategy: inject a fake ILMStudioClient that returns a fake model handle whose respond() yields a controllable async iterable. No real SDK or WebSocket touched.
|
||||
- `tests/meetRegistration.test.ts` (232 lines) — /meet 확신 게이트 — 분류·confirm 파싱·날짜 정규화 테스트. 정책: 확정+기한만 자동, 진행미정/기한미정/조건부는 보류, 반복은 첫 1회, 과거 날짜는 등록하되 완료확인 표기, 기한 해석 불가 확정건은 보류(추측 등록 금지).
|
||||
- `tests/secondBrainTrace.test.ts` (407 lines)
|
||||
- `tests/approvalQueue.test.ts` (164 lines) — Unit tests for ApprovalQueue. Strategy: drive enqueue → approve / reject / clear / pre-empt directly, confirm the onChange event fires at the right moments and callbacks fire exactly once.
|
||||
- `tests/compositeScore.test.ts` (131 lines) — 멀티팩터 합성점수 (v2.2.266) — 백분위 랭크·축 합성·value trap 가드·결측 처리.
|
||||
- `tests/computeBudgetedRequest.test.ts` (147 lines) — Phase 1 — context-window alignment. The budgeter must clamp to the model's ACTUALLY-loaded window when it's smaller than the user's contextLength setting, so a model loaded with a smaller window than
|
||||
- `tests/confidenceEngine.test.ts` (175 lines) — Confidence Engine + Escalation Engine (Self-Evolving OS Phase 2) 단위 테스트. 순수 함수만 검증 — vscode 의존 없음.
|
||||
- `tests/correctionLoop.test.ts` (158 lines) — Correction Loop 단위 테스트 — 순수 로직 (감지·프로필·레슨·큐 등록·영속화). LLM 의존 부분(classifyCorrection)은 엔드포인트 실패 → 휴리스틱 fallback 경로만 검증.
|
||||
- `tests/emailState.test.ts` (121 lines) — v2.2.271 — 이메일 영속 상태 머신 (syncThreads / detectOverdue / setThreadState). "명령 안 쳐도 f/u 되는" 핵심 로직: 창 밖 미회신 보존, done 자동 재개, 알림 중복 차단.
|
||||
- `tests/investigationPipeline.test.ts` (193 lines) — Investigation Pipeline — 파일명만 보고 상상하는 헛조사 차단 3중 방어. (v2.2.309) ① 조사형 요청 감지 + 증거 게이트 ② 강제 정독 Map + 해시 캐시 ③ Hollow 감지
|
||||
- `tests/knowledgeOps.test.ts` (174 lines) — Knowledge Validation / Belief Revision / Decay / Debt (Self-Evolving OS Phase 4 — 지식 운영) 테스트.
|
||||
- `tests/meetRegistration.test.ts` (110 lines) — /meet 확신 게이트 — 분류·confirm 파싱·날짜 정규화 테스트. 정책: 확정+기한만 자동, 진행미정/기한미정/조건부는 보류, 반복은 첫 1회, 과거 날짜는 등록하되 완료확인 표기, 기한 해석 불가 확정건은 보류(추측 등록 금지).
|
||||
- `tests/largeInputMapReduce.test.ts` (159 lines) — Phase 2 — large-input map-reduce core. Pure orchestration with an injected callLLM, so no network / SDK is touched.
|
||||
- `tests/needEngineQueue.test.ts` (159 lines) — Gap Detector / Need Engine / Knowledge Inventory / Learning Queue (Self-Evolving OS Phase 3 — 성장 루프 코어) 테스트.
|
||||
- `tests/projectScaffolder.test.ts` (135 lines) — Unit tests for FileSystemProjectScaffolder. Drives against a real temp directory so end-to-end file IO + path-traversal defenses are exercised.
|
||||
- `tests/requirementGraph.test.ts` (126 lines) — Requirement Graph (Self-Evolving OS Phase 1 / Track 2-1) 단위 테스트. 순수 함수만 검증 — vscode 의존 없음.
|
||||
- `tests/requirementGraph.test.ts` (146 lines) — Requirement Graph (Self-Evolving OS Phase 1 / Track 2-1) 단위 테스트. 순수 함수만 검증 — vscode 의존 없음.
|
||||
- `tests/researchSkill.test.ts` (122 lines) — Research Agent / Skill Score / Success Pattern DB (Self-Evolving OS Phase 6) 테스트.
|
||||
- `tests/resilience_stress.test.ts` (197 lines) — Resilience & Boundary Stress Test Suite (v2.77.3) 이 테스트는 ConnectAI 엔진이 극한의 환경(인증 실패, 네트워크 차단, 타임아웃 등)에서 얼마나 안정적으로 복구되고, 신뢰성 지표(Resilience Metrics)를 정확히 기록하는지 검증합니다.
|
||||
- `tests/retrievalEvalEmbedding.test.ts` (106 lines) — 하이브리드(sparse+dense) 검색 측정 — 청크 TF-IDF vs 청크+임베딩 (alpha sweep). 평소 테스트 런에서는 skip (실제 두뇌 + 로컬 임베딩 서버 필요). 수동 실행: ASTRAEVALBRAIN="E:/Wiki/2nd/10Wiki/Topics" \ ASTRAEVALEMBEDMODEL="text-embedding-nomic-em
|
||||
- `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" />
|
||||
|
||||
### `core_py/` — 6 files, ~409 lines
|
||||
|
||||
@@ -166,16 +166,16 @@ flowchart LR
|
||||
- `core_py/optimizer.py` (55 lines)
|
||||
- `core_py/queue_worker.py` (82 lines)
|
||||
|
||||
### `docs/` — 173 files, ~5,829 lines
|
||||
### `docs/` — 177 files, ~5,946 lines
|
||||
|
||||
**Sub-directories**
|
||||
- `docs/records/` (157) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 프로젝트 코드 리뷰 해줄 수 있어? 개선할 부분이 있는지, 그러고...
|
||||
- `docs/records/` (160) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 프로젝트 코드 리뷰 해줄 수 있어? 개선할 부분이 있는지, 그러고...
|
||||
- `docs/docs/` (5) — Bug: Viewed integrationretrieval.test.ts:1-59 integrationretrieval.test.ts를 통해 ...
|
||||
- `docs/plans/` (2) — Alignment Self-Learning 개선 계획 (v2 — 적대적 리뷰 반영)
|
||||
- `docs/Meeting/` (0)
|
||||
|
||||
**Key files**
|
||||
- `docs/records/ConnectAI/timeline.md` (422 lines) — Project Timeline
|
||||
- `docs/records/ConnectAI/timeline.md` (431 lines) — Project Timeline
|
||||
- `docs/TELEGRAM_REMOTE_EXECUTION_PLAN.md` (452 lines) — Telegram Remote Execution 기획서
|
||||
- `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
|
||||
@@ -209,6 +209,7 @@ flowchart LR
|
||||
- `g1nation.eval.retrieval` — Astra: 검색 평가 실행 (recall@k / MRR)
|
||||
- `g1nation.eval.tasks` — Astra: 업무 평가 실행 (회의록 골든셋)
|
||||
- `g1nation.growth.report` — Astra: 성장 리포트 (Reflection 추이)
|
||||
- `g1nation.growth.standingRules` — Astra: 상시 행동 규칙 (Standing Rules)
|
||||
- `g1nation.growth.learningQueue` — Astra: 학습 큐 갱신 (Need Engine)
|
||||
- `g1nation.knowledge.decayAudit` — Astra: 지식 노후 점검 (Knowledge Decay)
|
||||
- `g1nation.research.runQueue` — Astra: 학습 실행 (Research Agent — 승인된 큐 항목)
|
||||
@@ -239,17 +240,21 @@ flowchart LR
|
||||
- `g1nation.company.toggle` — Astra: Toggle 1인 기업 Mode
|
||||
- `g1nation.company.manage` — Astra: Manage 1인 기업 Agents
|
||||
- `g1nation.company.openSessions` — Astra: Open 1인 기업 Sessions Folder
|
||||
- `g1nation.company.pixelOffice.open` — Astra: Open Pixel Office (Full Screen)
|
||||
- `g1nation.calendar.connect` — Astra: Google Calendar (iCal) 연결 📅
|
||||
- `g1nation.calendar.refresh` — Astra: Google Calendar 새로고침 📅
|
||||
- `g1nation.calendar.connectOAuth` — Astra: Google Calendar OAuth 연결 (쓰기) 🔐
|
||||
- `g1nation.devilAgent.toggle` — Astra: Toggle Devil Agent 🎭
|
||||
- **Configuration** (149 settings):
|
||||
- **Configuration** (183 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).
|
||||
- `g1nation.datacollectBridgeNasUrl` *(string)* _(default: `""`)_
|
||||
- `g1nation.datacollectBridgeTarget` *(string)* _(default: `"nas"`)_
|
||||
- `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). 백엔드를 NAS로 옮긴 경우 보통 사
|
||||
- `g1nation.datacollectBridgeNasUrl` *(string)* _(default: `"https://dc.koritips.com"`)_
|
||||
- `g1nation.datacollectBridgeNasToken` *(string)* _(default: `""`)_
|
||||
- `g1nation.domainKnowledge.generalFolder` *(string)* _(default: `""`)_
|
||||
- `g1nation.domainKnowledge.mathFolder` *(string)* _(default: `""`)_
|
||||
- `g1nation.domainKnowledge.codingFolder` *(string)* _(default: `""`)_
|
||||
- `g1nation.domainKnowledge.factsFolder` *(string)* _(default: `""`)_
|
||||
- `g1nation.datacollectLocalProjectPath` *(string)* _(default: `""`)_
|
||||
- `g1nation.datacollectSavePath` *(string)* _(default: `""`)_
|
||||
- `g1nation.datacollectCrawlDepth` *(number)* _(default: `1`)_
|
||||
- `g1nation.datacollectMaxPages` *(number)* _(default: `8`)_
|
||||
@@ -257,7 +262,24 @@ flowchart LR
|
||||
- `g1nation.chatTemperature` *(number)* _(default: `0.3`)_
|
||||
- `g1nation.meetUsesTasks` *(boolean)* _(default: `true`)_
|
||||
- `g1nation.meetUsesCalendar` *(boolean)* _(default: `false`)_
|
||||
- `g1nation.meetVerifyPass` *(boolean)* _(default: `false`)_
|
||||
- `g1nation.meetVerifyPass` *(boolean)* _(default: `true`)_
|
||||
- `g1nation.meetVerifyMaxRounds` *(number)* _(default: `2`)_
|
||||
- `g1nation.meetEditorPass` *(boolean)* _(default: `true`)_
|
||||
- `g1nation.meetTaskDateFallback` *(string)* _(default: `"today"`)_
|
||||
- `g1nation.meetTaskDetailExpand` *(boolean)* _(default: `true`)_
|
||||
- `g1nation.meetTeamRoster` *(string)* _(default: `""`)_
|
||||
- `g1nation.meetMaxOutputTokens` *(number)* _(default: `8000`)_
|
||||
- `g1nation.email.watcherEnabled` *(boolean)* _(default: `true`)_
|
||||
- `g1nation.email.watchIntervalMinutes` *(number)* _(default: `60`)_
|
||||
- `g1nation.email.overdueAlertHours` *(number)* _(default: `0`)_
|
||||
- `g1nation.email.outlookEnabled` *(boolean)* _(default: `true`)_
|
||||
- `g1nation.email.gmailEnabled` *(boolean)* _(default: `true`)_
|
||||
- `g1nation.email.includeSubfolders` *(boolean)* _(default: `true`)_
|
||||
- `g1nation.email.wikifySavePath` *(string)* _(default: `""`)_
|
||||
- `g1nation.email.syncDays` *(number)* _(default: `7`)_
|
||||
- `g1nation.messenger.stocksReportTimes` *(string)* _(default: `"09:00,15:00"`)_
|
||||
- `g1nation.messenger.buySignalAlert` *(boolean)* _(default: `true`)_
|
||||
- `g1nation.messenger.discoverTopFive` *(boolean)* _(default: `true`)_
|
||||
- `g1nation.dailyBriefing.enabled` *(boolean)* _(default: `true`)_
|
||||
- `g1nation.dailyBriefing.time` *(string)* _(default: `"09:30"`)_
|
||||
- `g1nation.sleepDigest.enabled` *(boolean)* _(default: `true`)_
|
||||
@@ -280,32 +302,10 @@ flowchart LR
|
||||
- `g1nation.contextOverflowPolicy` *(string)* _(default: `"stopAtLimit"`)_ — Fallback behavior (LM Studio) if the prompt still exceeds the context window after Astra's own budgeting. 'stopAtLimit' fails clearly so you notice; 'truncateMiddle'/'rollingWindow' drop content silen
|
||||
- `g1nation.autoCompactHistory` *(boolean)* _(default: `true`)_ — Automatically drop the oldest conversation messages from the request when the prompt would exceed the context budget (the on-screen chat history is unaffected). Default: true
|
||||
- `g1nation.smallModelContextCap` *(number)* _(default: `0`)_ — Optional safety knob, OFF by default (0). Some very small models (≤3B) emit an empty/EOS response when given a prompt near their context window even though it nominally fits. If you observe that with
|
||||
- `g1nation.autoContinueOnOutputLimit` *(boolean)* _(default: `true`)_ — When a reply is cut off because it hit the output-token limit, Astra continues it internally (compressed request — original question + the answer so far, not the whole context again) and shows one mer
|
||||
- `g1nation.maxAutoContinuations` *(number)* _(default: `4`)_ — Maximum number of automatic continuation rounds per reply (prevents runaway loops). Raise it (e.g. 5–6) for long-form answers on slow local models; set 0 to disable auto-continuation. Default: 4
|
||||
- `g1nation.finalOnlyRetryOnThoughtLeak` *(boolean)* _(default: `true`)_ — If the model emits only hidden reasoning (<think>, <|channel|>thought, "Thinking Process:" …) and no user-visible answer, Astra silently re-asks it for the final answer only. Hidden reasoning is never
|
||||
- `g1nation.lmStudio.idleTimeoutMs` *(number)* _(default: `300000`)_ — Auto-eject the loaded LM Studio model after this many milliseconds of inactivity. Set to 0 to disable. Default: 300000 (5 minutes).
|
||||
- `g1nation.lmStudio.autoLoadOnSelect` *(boolean)* _(default: `true`)_ — Automatically load LM Studio models into memory when selected from the Astra sidebar.
|
||||
- `g1nation.lmStudio.sampling.topP` *(number)* _(default: `0.9`)_ — Nucleus sampling cutoff. Small / quantized models often spew wrong-neighbour tokens (한글 깨짐: 붕괴→붕점) when the tail is wide. Lower (0.8–0.9) tightens; 1.0 disables. Applied to both SDK and REST paths.
|
||||
- `g1nation.lmStudio.sampling.topK` *(number)* _(default: `20`)_ — Top-K sampling cutoff. 0 disables. Default 20 — tighter for small models, raise to 40–80 for large models that already sample well.
|
||||
- `g1nation.lmStudio.sampling.minP` *(number)* _(default: `0.05`)_ — Min-P floor — discards tokens with probability below this fraction of the top token. Good defence against rare-token glitches. 0 disables.
|
||||
- `g1nation.lmStudio.sampling.repeatPenalty` *(number)* _(default: `1.1`)_ — Repeat / frequency penalty to curb stutter (것입니다서입니다…). 1.0 disables. Values 1.05–1.2 are typical.
|
||||
- `g1nation.lmStudio.statsInBudget` *(boolean)* _(default: `true`)_ — Show token/s and time-to-first-token from LM Studio prediction stats in the context-budget badge after each turn (SDK path only).
|
||||
- `g1nation.lmStudio.draftModel` *(string)* _(default: `""`)_ — [Speculative decoding] LM Studio model key of a small draft model (e.g. 'gemma-2b-it') used to accelerate the main model. Empty disables. 1.5–3x throughput on large models. The draft must be downloade
|
||||
- `g1nation.lmStudio.load.flashAttention` *(boolean)* _(default: `true`)_ — [Load option] Enable Flash Attention when loading models. Faster generation + lower memory on compatible hardware, especially helpful for long contexts. Default: true.
|
||||
- `g1nation.lmStudio.load.gpuOffloadRatio` *(string)* _(default: `"max"`)_ — [Load option] How much of the model to offload to GPU. 'max' = all (default), 'off' = CPU only, or a number 0–1 (e.g. '0.5' = half). Numeric strings are parsed.
|
||||
- `g1nation.lmStudio.load.offloadKVCacheToGpu` *(boolean)* _(default: `true`)_ — [Load option] Keep KV cache on GPU memory. Faster but requires VRAM headroom. Default: true.
|
||||
- `g1nation.lmStudio.load.keepModelInMemory` *(boolean)* _(default: `true`)_ — [Load option] Prevent the model from being swapped out of system memory. Improves interactive responsiveness; raises RAM use. Default: true.
|
||||
- `g1nation.lmStudio.load.useFp16ForKVCache` *(boolean)* _(default: `false`)_ — [Load option] Store KV cache in FP16 (halves cache memory). Tiny quality impact for most models — try if you run out of VRAM at long contexts. Default: false.
|
||||
- `g1nation.lmStudio.load.evalBatchSize` *(number)* _(default: `0`)_ — [Load option] Token batch size during evaluation. 0 = engine default. Higher (512–1024) improves prefill speed on GPU at the cost of memory.
|
||||
- `g1nation.localBrainPath` *(string)* _(default: `""`)_ — Folder path for your local Second Brain knowledge base. Leave empty to use the default folder.
|
||||
- `g1nation.brainProfiles` *(array)* _(default: `[]`)_ — Multiple brain profiles. Each item supports id, name, localBrainPath, secondBrainRepo, and description.
|
||||
- `g1nation.activeBrainId` *(string)* _(default: `""`)_ — Active brain profile id used for the current chat context.
|
||||
- `g1nation.secondBrainRepo` *(string)* _(default: `""`)_ — Optional GitHub repository URL used for Second Brain sync.
|
||||
- `g1nation.autoPushBrain` *(boolean)* _(default: `false`)_ — Automatically commit and push Second Brain changes after updates.
|
||||
- `g1nation.maxContextSize` *(number)* _(default: `32000`)_ — Maximum character count for active file context. Default: 32000
|
||||
- `g1nation.maxAutoSteps` *(number)* _(default: `50`)_ — Maximum autonomous steps the agent can take per request. Default: 50
|
||||
- `g1nation.dryRun` *(boolean)* _(default: `false`)_ — If enabled, the agent will ask for approval before committing any file changes.
|
||||
- _…and 89 more_
|
||||
- `g1nation.largeInputMapReduce` *(boolean)* _(default: `true`)_ — When a single message is too large to fit the model's context window, split it into chunks, extract only the request-relevant facts from each (no hallucination/summary), integrate them, and answer fro
|
||||
- `g1nation.mapReduceTriggerRatio` *(number)* _(default: `0.6`)_ — Map-reduce kicks in when a single message exceeds (effective context window × this ratio). Lower = engages sooner (safer for big inputs, more LLM calls). Default: 0.6
|
||||
- `g1nation.mapReduceConcurrency` *(number)* _(default: `2`)_ — How many chunk extractions run in parallel. Keep low on a single local GPU (one model serves them sequentially anyway). Default: 2
|
||||
- _…and 123 more_
|
||||
|
||||
## Dependencies
|
||||
- **Runtime** (2): `@lmstudio/sdk`, `pdf-parse`
|
||||
@@ -353,7 +353,7 @@ Astra는 대표님의 명시적인 승인 하에 로컬 시스템의 강력한
|
||||
**Designed for High-Performance Decision Making.**
|
||||
Copyright (C) **g1nation**. All rights reserved.
|
||||
|
||||
_Last auto-scan: 2026-06-15T02:56:35.947Z · signature `c66695ef`_
|
||||
_Last auto-scan: 2026-07-20T04:20:03.384Z · signature `ff87bb88`_
|
||||
<!-- ASTRA:AUTO-END -->
|
||||
|
||||
## Purpose
|
||||
|
||||
+1007
-405
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "직답 결과 — single-pass mock 응답입니다.",
|
||||
"createdAt": 1783757602271,
|
||||
"createdAt": 1784521057661,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "---\nid: wiki_on\ndate: 2026-07-11T08:13:22.272Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\n직답 결과 — single-pass mock 응답입니다.\n\n직답 결과 — single-pass mock 응답입니다.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `0/100` | ✅ Low |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[DIRECT]** 답변 작성 중... (단일 호출 fast-path) (12ms)\n",
|
||||
"createdAt": 1783757602272,
|
||||
"result": "---\nid: wiki_on\ndate: 2026-07-20T04:17:37.663Z\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": 1784521057663,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"missionId": "wiki_on",
|
||||
"status": "completed",
|
||||
"startTime": "2026-07-11T08:13:22.259Z",
|
||||
"totalElapsedMs": 13,
|
||||
"startTime": "2026-07-20T04:17:37.641Z",
|
||||
"totalElapsedMs": 24,
|
||||
"results": {
|
||||
"direct": "직답 결과 — single-pass mock 응답입니다."
|
||||
},
|
||||
@@ -12,16 +12,16 @@
|
||||
{
|
||||
"from": "idle",
|
||||
"to": "direct",
|
||||
"durationMs": 12,
|
||||
"durationMs": 18,
|
||||
"message": "답변 작성 중... (단일 호출 fast-path)",
|
||||
"ts": "2026-07-11T08:13:22.271Z"
|
||||
"ts": "2026-07-20T04:17:37.659Z"
|
||||
},
|
||||
{
|
||||
"from": "direct",
|
||||
"to": "completed",
|
||||
"durationMs": 1,
|
||||
"durationMs": 5,
|
||||
"message": "미션 완료",
|
||||
"ts": "2026-07-11T08:13:22.272Z"
|
||||
"ts": "2026-07-20T04:17:37.664Z"
|
||||
}
|
||||
],
|
||||
"resilienceMetrics": {
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
||||
"createdAt": 1783757609132,
|
||||
"createdAt": 1784521065426,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
||||
"createdAt": 1783757609132,
|
||||
"createdAt": 1784521065425,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
|
||||
"createdAt": 1783757609131,
|
||||
"createdAt": 1784521065420,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
||||
"createdAt": 1783757609131,
|
||||
"createdAt": 1784521065423,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+11
-11
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"missionId": "stress_conflict_1783757609119",
|
||||
"missionId": "stress_conflict_1784521065403",
|
||||
"status": "completed",
|
||||
"startTime": "2026-07-11T08:13:29.119Z",
|
||||
"totalElapsedMs": 13,
|
||||
"startTime": "2026-07-20T04:17:45.404Z",
|
||||
"totalElapsedMs": 23,
|
||||
"results": {
|
||||
"outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
|
||||
"section_0": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
||||
@@ -14,30 +14,30 @@
|
||||
{
|
||||
"from": "idle",
|
||||
"to": "outline",
|
||||
"durationMs": 11,
|
||||
"durationMs": 15,
|
||||
"message": "답변 구조 잡는 중...",
|
||||
"ts": "2026-07-11T08:13:29.130Z"
|
||||
"ts": "2026-07-20T04:17:45.419Z"
|
||||
},
|
||||
{
|
||||
"from": "outline",
|
||||
"to": "section",
|
||||
"durationMs": 1,
|
||||
"durationMs": 3,
|
||||
"message": "본문 작성 중...",
|
||||
"ts": "2026-07-11T08:13:29.131Z"
|
||||
"ts": "2026-07-20T04:17:45.422Z"
|
||||
},
|
||||
{
|
||||
"from": "section",
|
||||
"to": "polish",
|
||||
"durationMs": 0,
|
||||
"durationMs": 2,
|
||||
"message": "최종 다듬기 중...",
|
||||
"ts": "2026-07-11T08:13:29.131Z"
|
||||
"ts": "2026-07-20T04:17:45.424Z"
|
||||
},
|
||||
{
|
||||
"from": "polish",
|
||||
"to": "completed",
|
||||
"durationMs": 1,
|
||||
"durationMs": 3,
|
||||
"message": "미션 완료",
|
||||
"ts": "2026-07-11T08:13:29.132Z"
|
||||
"ts": "2026-07-20T04:17:45.427Z"
|
||||
}
|
||||
],
|
||||
"resilienceMetrics": {
|
||||
@@ -1,5 +1,23 @@
|
||||
# Astra Patch Notes
|
||||
|
||||
## v2.2.312 (2026-07-20)
|
||||
### 📋 보고 품질 — "## 4.부터 시작하는 보고서" 버그 + 일반론 총평 차단
|
||||
실사례: "connectai 프로젝트를 분석하고 보고해줘" → 답변이 "## 4. 종합 분석 결과"부터 시작(1~3 증발), 내용은 프로젝트명을 바꿔도 성립하는 7줄 일반론.
|
||||
- **중간 라운드 본문 표시 버그 수정 (근본 원인)**: 액션 루프에서 액션과 *함께* 본문 섹션을 쓴 라운드는 화면에 한 번도 표시되지 않고(라이브 스트리밍은 depth 0 전용) 최종 라운드만 append 됐다. 모델은 히스토리에서 자기 이전 섹션(1~3)을 보고 "## 4."부터 이어 써서 사용자에겐 4번부터 도착. 이제 중간 라운드의 가시 본문을 액션 태그만 걷어내고(신규 stripForDisplay.ts) 버블에 순서대로 표시한다.
|
||||
- **분석 보고 업무 유형 신설 (requirementGraph)**: "분석하고 보고해줘/현황 보고/검토 보고"가 어떤 유형에도 안 걸리던 갭을 막음. 필수 요소: 보고 개요(첫 줄에 대상—근거—결론 자기선언), **파일 근거**(주장마다 실제 읽은 파일 인용, 일반론 금지), 구조·현황, 핵심 발견, 다음 단계. 유형 감지 시 "최대 3섹션" 일반 규칙보다 필수 요소 커버 우선 명시(보고서가 얕아지는 상충 제거).
|
||||
- **근거 없는 분석 감지 (Hollow 의 반대 방향)**: 파일을 읽고도(read/investigate ≥1) 답변에 파일 인용이 2개 미만인 장문 분석이면 "근거 인용 없음" footer 로 경고 — 그럴듯한 총평이 검증된 분석처럼 통과하는 것을 차단.
|
||||
검증: tsc 무오류 + jest 954 통과(신규 13: analysis-report 3 + ungrounded 5 + stripForDisplay 5) + esbuild 정상.
|
||||
|
||||
## v2.2.311 (2026-07-20)
|
||||
### ⚡ 응답 지연 근본 개선 — KV 캐시 친화 프롬프트 + 제2뇌 상주 캐시
|
||||
실측(usage.jsonl): 출력 22토큰 답변에 94.7초 — 지연의 90%+ 가 생성이 아니라 **매 턴 13k+ 토큰 전체 재프리필**이었다. 원인은 (a) 시스템 프롬프트 상단의 분 단위 시각이 llama.cpp prompt cache 를 첫 토큰부터 무효화, (b) 턴마다 바뀌는 RAG/동적 블록이 전부 message[0] 에 있어 히스토리 캐시까지 연쇄 무효화, (c) 32K 창에서 검색 결과에만 8k 토큰 예산, (d) continuation 마다 빈 쿼리로 재검색(3~8초), (e) 매 질의 6,500 파일 디렉터리 워크 + statSync.
|
||||
- **KV 캐시 분리 (`kvCachePromptSplit`, 기본 ON)**: message[0] 을 불변 정적 본문(페르소나·규칙·환경)으로 고정하고, 날짜/RAG/[CONTEXT]/동적 블록 전부를 *마지막 user 메시지 직전*의 internal system 메시지로 이동. 프리픽스(정적 프롬프트+과거 히스토리)가 캐시에서 재사용되어 재프리필이 "직전 교환 + 동적 컨텍스트"로 줄어든다. [CONTEXT] truncation 도 tail 에 적용. 커스텀 systemPrompt 호출자(멀티에이전트 등)는 종전 경로 유지.
|
||||
- **검색 토큰 예산 현실화 (`retrievalTokenBudget`, 0=자동)**: 자동값을 창의 25%(8k~80k) → 12%(2.5k~6k 클램프)로. 매 턴 재프리필되는 tail 을 직접 얇게 만든다.
|
||||
- **continuation 은 depth-0 memoryCtx 재사용**: 액션 라운드마다의 재검색 제거 — 라운드당 3~8초 절감 + 빈 쿼리 재검색으로 청크가 갈리던 문제 제거 + 턴 내 프롬프트 안정화.
|
||||
- **제2뇌 상주 캐시 (신규 brainWatch.ts)**: 브레인 루트에 재귀 fs.watch 를 걸고 세대 카운터로 무효화 — 변경 없으면 디렉터리 워크와 파일별 statSync(수천 회)를 통째로 생략하고 메모리의 파일 목록·토큰 인덱스를 신뢰한다. "제2뇌를 항상 메모리에" 아이디어의 안전한 구현: 원본 대신 검색 파생물을 상주시키고 워처가 정합성을 맡는다. 수정 직후 3초 창은 신뢰 명부에서 제외(이벤트 지연 레이스 가드), 워처 불가 환경은 자동으로 종전(매번 stat) 폴백. 활성화 시 백그라운드 워밍으로 첫 질의의 인덱스 파싱 비용도 선불 처리, 유휴 해제 30분→2시간.
|
||||
- 기대 효과: 동일 세션 반복 턴의 프리필이 13k → 수백~2k 토큰 수준. LM Studio 쪽 GPU 오프로드/Flash Attention 설정과 병행 권장.
|
||||
검증: tsc 무오류 + jest 941 통과(신규 15: split 6 + brainCache 4 + promptSplit 5) + esbuild 정상.
|
||||
|
||||
## v2.2.307 (2026-07-11)
|
||||
### 🏗️ /benchmark 레이아웃 골격 분석 + 합성 안정화
|
||||
koritips 실측 피드백 반영: ① 합성이 파트 2 에서 타임아웃("This operation was aborted")으로 죽으면서 105초 걸린 파트 1까지 폐기 ② 남은 원시 요약이 색·폰트뿐이라 "배너가 롤링인지, 각 페이지가 어떤 블록 순서인지" 알 길이 없었음.
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"corePurpose": "",
|
||||
"detailLevel": "standard",
|
||||
"createdAt": "2026-05-20T09:42:40.003Z",
|
||||
"updatedAt": "2026-06-15T03:00:02.383Z"
|
||||
"updatedAt": "2026-07-20T04:19:49.700Z"
|
||||
}
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
# Discussion: E:\Wiki\connectai 프로젝트를 분석하고 구조에 대해서 설명해줘
|
||||
|
||||
## User Request Summary
|
||||
E:\Wiki\connectai 프로젝트를 분석하고 구조에 대해서 설명해줘
|
||||
|
||||
## Interpreted Intent
|
||||
Capture a meaningful project discussion automatically instead of requiring manual record selection.
|
||||
|
||||
## Questions
|
||||
No explicit question was captured.
|
||||
|
||||
## Main Discussion
|
||||
- E:\Wiki\connectai 프로젝트를 분석하고 구조에 대해서 설명해줘
|
||||
|
||||
## Decisions
|
||||
No decisions captured yet.
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
# Discussion: E:\Wiki\connectai 프로젝트를 분석하고 구조에 대해서 설명해줘
|
||||
|
||||
## User Request Summary
|
||||
E:\Wiki\connectai 프로젝트를 분석하고 구조에 대해서 설명해줘
|
||||
|
||||
## Interpreted Intent
|
||||
Capture a meaningful project discussion automatically instead of requiring manual record selection.
|
||||
|
||||
## Questions
|
||||
No explicit question was captured.
|
||||
|
||||
## Main Discussion
|
||||
- 반가워요. 어떻게 도와드릴까요?
|
||||
|
||||
## Decisions
|
||||
No decisions captured yet.
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
# Discussion: E:\Wiki\connectai 프로젝트를 분석하고 어떻게 되어 있는지 보고해줘.
|
||||
|
||||
## User Request Summary
|
||||
E:\Wiki\connectai 프로젝트를 분석하고 어떻게 되어 있는지 보고해줘.
|
||||
|
||||
## Interpreted Intent
|
||||
Capture a meaningful project discussion automatically instead of requiring manual record selection.
|
||||
|
||||
## Questions
|
||||
No explicit question was captured.
|
||||
|
||||
## Main Discussion
|
||||
- ## 4. 종합 분석 결과 현재 ConnectAI 프로젝트는 로컬 LLM과 RAG 기술을 VS Code라는 개발 환경에 밀접하게 결합하려는 시도가 매우 인상적입니다. 구조적으로 에이전트 기반의 워크플로우와 계층적 메모리 시스템이 설계되어 있어, 단순한 챗봇 이상의 '지능형 코딩 어시스턴트'로 발전할 가능성이 높습니다. **핵심 요약:** - **강점**: 로컬 LLM 사용을 통한 보안성 및 비용 효율성 확보, RAG를 활용한 개인화된 지식 검색 기능의 구조적 완성도. - **기회**: 에이전트 구조(Planner, Researcher, Writer)를 통한 복잡한 개발 태스크 자동화 가능성. - **과제**: 대규모 코드베이스 인덱싱 시의 성능 최적화 및 사용자 경험(UX)의 직관성 강화. 결론적으로, 이 프로젝트는 단순한 도구 모음을 넘어 사용자의 코딩 맥락을 이해하는 '지능형 레이어'를 구축하는 데 초점이 맞춰져 있습니다.
|
||||
|
||||
## Decisions
|
||||
No decisions captured yet.
|
||||
@@ -420,3 +420,12 @@
|
||||
|
||||
## 2026-06-12
|
||||
- Auto development record created: development\2026-06-12_지금까지-너는-나에게-아이디어-핵심-내용-기대-효과-내용으로-써줬는데-법정-의무-교육은-다른-포멧으로-줬어-_implementation.md
|
||||
|
||||
## 2026-07-20
|
||||
- Auto discussion record created: discussions\2026-07-20_e-wiki-connectai-프로젝트를-분석하고-구조에-대해서-설명해줘.md
|
||||
|
||||
## 2026-07-20
|
||||
- Auto discussion record created: discussions\2026-07-20_e-wiki-connectai-프로젝트를-분석하고-구조에-대해서-설명해줘-2.md
|
||||
|
||||
## 2026-07-20
|
||||
- Auto discussion record created: discussions\2026-07-20_e-wiki-connectai-프로젝트를-분석하고-어떻게-되어-있는지-보고해줘.md
|
||||
|
||||
+24
-2
@@ -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.307",
|
||||
"version": "2.2.312",
|
||||
"publisher": "g1nation",
|
||||
"license": "MIT",
|
||||
"icon": "assets/icon.png",
|
||||
@@ -54,6 +54,10 @@
|
||||
"command": "g1nation.growth.report",
|
||||
"title": "Astra: 성장 리포트 (Reflection 추이)"
|
||||
},
|
||||
{
|
||||
"command": "g1nation.growth.standingRules",
|
||||
"title": "Astra: 상시 행동 규칙 (Standing Rules)"
|
||||
},
|
||||
{
|
||||
"command": "g1nation.growth.learningQueue",
|
||||
"title": "Astra: 학습 큐 갱신 (Need Engine)"
|
||||
@@ -352,7 +356,10 @@
|
||||
"g1nation.meetTaskDateFallback": {
|
||||
"type": "string",
|
||||
"default": "today",
|
||||
"enum": ["today", "hold"],
|
||||
"enum": [
|
||||
"today",
|
||||
"hold"
|
||||
],
|
||||
"markdownDescription": "`/meet` 액션 아이템 중 **기한이 없는(파싱 불가) 확정·기한미정** 항목의 처리. `today`(기본): 회의록 **작성일(오늘)** 을 기한으로 잡아 자동 등록 — 매번 '자동 등록 0건'이 되던 문제를 해소. `hold`: 이전처럼 등록하지 않고 보류→`/meet confirm` 으로 수동 등록. (진행미정·조건부 항목은 이 설정과 무관하게 항상 보류.)"
|
||||
},
|
||||
"g1nation.meetTaskDetailExpand": {
|
||||
@@ -1282,6 +1289,21 @@
|
||||
"type": "number",
|
||||
"default": 0,
|
||||
"markdownDescription": "**Stocks 보고서 전용 텔레그램 chatId** — fallback. `g1nation.telegram.allowedChatIds` 가 비어있을 때만 사용. 0 = 미설정."
|
||||
},
|
||||
"g1nation.investigationModel": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"markdownDescription": "조사형 요청('~폴더 조사해줘') 전용 모델 오버라이드. 예: 'claude-code:sonnet'. 소형 로컬 모델이 파일명만 보고 내용을 상상하는 헛조사 방지책의 하나 — 비우면 현재 선택 모델 사용."
|
||||
},
|
||||
"g1nation.kvCachePromptSplit": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"markdownDescription": "**KV 캐시 친화 프롬프트 분리** — 시스템 프롬프트를 '불변 본문(message[0])'과 '턴별 동적 컨텍스트(마지막 질문 직전 주입)'로 분리해 LM Studio/llama.cpp 의 prompt cache 프리픽스 재사용을 극대화. 매 턴 전체 재프리필(1만+ 토큰, 수십 초)을 직전 교환 분량으로 줄인다. 특정 모델 템플릿에서 응답 품질 문제가 보이면 끄세요."
|
||||
},
|
||||
"g1nation.retrievalTokenBudget": {
|
||||
"type": "number",
|
||||
"default": 0,
|
||||
"markdownDescription": "**RAG 검색 토큰 예산 상한** — 두뇌/메모리 검색 결과가 프롬프트에서 차지할 최대 토큰. `0 = 자동`(컨텍스트 창의 12%, 2,500~6,000 클램프). 값이 클수록 근거는 풍부해지지만 로컬 모델 프리필이 느려진다."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+135
-18
@@ -5,6 +5,8 @@ import * as fs from 'fs';
|
||||
import {
|
||||
findBrainFiles,
|
||||
getSystemPrompt,
|
||||
getStaticSystemPrompt,
|
||||
getDateTimeContextBlock,
|
||||
shouldAutoPushBrain,
|
||||
buildApiUrl,
|
||||
getActiveBrainProfile,
|
||||
@@ -24,7 +26,8 @@ import { isSelfAssessRequest, isAboutSelf, buildSelfAssessContext } from './lib/
|
||||
import { ensureFeatureInventory } from './extension/featureInventory';
|
||||
import { buildUrlContext } from './lib/contextBuilders/urlContext';
|
||||
import { extractUrls } from './features/web/webFetch';
|
||||
import { looksLikeCorrection, captureCorrection } from './intelligence/correctionLoop';
|
||||
import { looksLikeCorrection, looksLikeBehaviorComplaint, captureCorrection } from './intelligence/correctionLoop';
|
||||
import { isLocalInvestigationPrompt, detectHollowInvestigation, formatHollowInvestigationFooter } from './intelligence/investigationPipeline';
|
||||
import { shouldUseMultiAgentWorkflow } from './lib/contextBuilders/multiAgentRouting';
|
||||
import { buildThinkingPartnerResponseContract } from './lib/contextBuilders/thinkingPartnerContract';
|
||||
import { buildDroppedHistorySummary } from './lib/contextBuilders/droppedHistorySummary';
|
||||
@@ -163,6 +166,7 @@ import { applyRunCommandActions } from './agent/actions/runCommand';
|
||||
import { applyCalculateActions } from './agent/actions/calculate';
|
||||
import { applyRunCodeActions } from './agent/actions/runCode';
|
||||
import { applyListFilesActions } from './agent/actions/listFiles';
|
||||
import { applyInvestigateFilesActions } from './agent/actions/investigateFiles';
|
||||
import { applyWebFetchActions } from './agent/actions/webFetch';
|
||||
import { applyBrainOpsActions } from './agent/actions/brainOps';
|
||||
import { applyCalendarActions } from './agent/actions/calendar';
|
||||
@@ -316,6 +320,18 @@ export class AgentExecutor {
|
||||
selfCheckSources: Array<{ title: string; excerpt: string }>;
|
||||
/** Confidence Engine 검색 신호 (Phase 2) — memoryContext 가 채움. */
|
||||
confidenceSignals: import('./intelligence/confidenceEngine').RetrievalConfidenceSignals | null;
|
||||
/** [v2.2.309] 이번 turn 의 액션 실행 통계 — Hollow Investigation 감지용 (loop depth 누적). */
|
||||
actionStats: { reads: number; lists: number; investigates: number };
|
||||
/** [v2.2.309] 조사 턴 모델 오버라이드 — depth 0 에서 결정, continuation 에도 유지. */
|
||||
investigationModelOverride: string | null;
|
||||
/**
|
||||
* [v2.2.311] depth 0 에서 빌드한 memoryCtx 문자열 캐시 — continuation depth 는
|
||||
* 재검색하지 않고 이걸 재사용한다. 종전엔 depth 마다 buildMemoryContext 를 다시
|
||||
* 돌렸는데, (a) 검색 3~8초가 라운드마다 추가되고 (b) continuation 의 prompt 는
|
||||
* null 이라 *빈 쿼리로* 재검색해 엉뚱한 청크로 갈아끼우고 (c) 프롬프트가 흔들려
|
||||
* KV 캐시도 깨졌다. actionStats 와 같은 이유로 depth 0 진입부에서만 초기화.
|
||||
*/
|
||||
memoryCtxCache: string | null;
|
||||
} = {
|
||||
retrieval: null,
|
||||
lessons: [],
|
||||
@@ -323,6 +339,9 @@ export class AgentExecutor {
|
||||
dynamicBlocks: new Map(),
|
||||
selfCheckSources: [],
|
||||
confidenceSignals: null,
|
||||
actionStats: { reads: 0, lists: 0, investigates: 0 },
|
||||
investigationModelOverride: null,
|
||||
memoryCtxCache: null,
|
||||
};
|
||||
|
||||
/** Per-turn state 일괄 정리. turn 시작/abort/load session 시 호출. */
|
||||
@@ -333,6 +352,10 @@ export class AgentExecutor {
|
||||
this._turnCtx.dynamicBlocks.clear();
|
||||
this._turnCtx.selfCheckSources = [];
|
||||
this._turnCtx.confidenceSignals = null;
|
||||
// actionStats / investigationModelOverride 는 여기서 리셋하지 않는다 —
|
||||
// resetTurnContext 는 continuation depth 에서도 호출되는데(메모리 컨텍스트 재구축),
|
||||
// 이 둘은 사용자 turn 전체(모든 depth)에 걸쳐 누적/유지되어야 한다.
|
||||
// 리셋은 loopDepth === 0 진입부에서만 (handlePrompt 초입).
|
||||
}
|
||||
|
||||
private readonly options: AgentExecutorOptions;
|
||||
@@ -545,6 +568,10 @@ export class AgentExecutor {
|
||||
// buildMemoryContext, the previous turn's value would otherwise leak into this turn's
|
||||
// "참조 범위" footer (the exact "안녕 → 🔎 참조: 에피소드기억" bug).
|
||||
this.resetTurnContext();
|
||||
// [v2.2.309] turn 전체(모든 depth) 누적 상태 — depth 0 에서만 초기화.
|
||||
this._turnCtx.actionStats = { reads: 0, lists: 0, investigates: 0 };
|
||||
this._turnCtx.investigationModelOverride = null;
|
||||
this._turnCtx.memoryCtxCache = null;
|
||||
}
|
||||
|
||||
// 1. Prepare Context
|
||||
@@ -618,6 +645,7 @@ export class AgentExecutor {
|
||||
- 이 워크스페이스의 코드·문서·기능에 대한 주장은 *이 대화에서 실제로 읽은 파일*에만 근거하라.
|
||||
- 확인하지 않은 구현을 "~로 보입니다", "~일 것입니다"라고 추측 서술하는 것은 금지. 먼저 <list_files path="..."/> 와 <read_file path="..."/> 태그로 관련 파일을 직접 열어 확인한 뒤 답하라. 태그를 emit 하면 시스템이 파일 내용을 주입하고 자동으로 이어서 답변하게 된다.
|
||||
- ⚠️ "소스 코드 확인이 필요합니다"라고 말만 하고 끝내는 것은 금지다. 확인이 필요하다고 판단했다면 *바로 이 답변 안에서* <list_files>/<read_file> 태그를 emit 하라 — 그것이 확인하는 방법이다. 태그로 접근 불가능한 대상(외부 시스템·미설치 도구 등)에 한해서만 "확인하지 못함"으로 명시하라.
|
||||
- 폴더 전체를 조사해야 하면 파일을 하나씩 읽지 말고 <investigate_files path="상대경로" focus="조사 목적"/> 태그 하나를 emit 하라 — 시스템이 폴더의 모든 텍스트 파일을 실제로 읽어 파일별 노트를 주입한다. list_files 로 받은 *파일명*만 보고 내용을 서술하는 것은 날조다.
|
||||
- "X 기능을 추가하라"고 제안하기 전에 그 기능이 이미 구현돼 있는지 해당 모듈을 찾아 읽어라. 이미 있는 기능을 새로 만들라고 제안하는 것은 잘못된 분석이다.
|
||||
- 일반론·추측으로 빈칸을 채우지 마라.`;
|
||||
}
|
||||
@@ -637,24 +665,28 @@ export class AgentExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
// [Correction Loop ①] 이 발화가 직전 답변에 대한 *정정*이면 fire-and-forget
|
||||
// 캡처 — 오류 분류 → 태깅 레슨 + 회귀 케이스(.astra/eval/corrections.jsonl).
|
||||
// 정정 자체가 Ground Truth 가 되어 주간 회귀 테스트·약점 프로필의 원료가 된다.
|
||||
// [Correction Loop ①] 이 발화가 직전 답변에 대한 *정정*(사실) 또는 *행동/스타일
|
||||
// 지적*("또 ~하네", "하지 말라고 했잖아")이면 fire-and-forget 캡처 — 오류 분류 →
|
||||
// 태깅 레슨 + 회귀 케이스. 행동 지적은 추가로 Standing Rule 로 정규화되어
|
||||
// 다음 턴부터(새 세션 포함) 매 턴 주입된다 (v2.2.308 — 세션 휘발 문제 해결).
|
||||
// 턴 응답을 막지 않는다 (await 없음).
|
||||
if (prompt && loopDepth === 0 && activeBrain?.localBrainPath && looksLikeCorrection(prompt)) {
|
||||
const isFactCorrection = !!(prompt && loopDepth === 0 && activeBrain?.localBrainPath && looksLikeCorrection(prompt));
|
||||
const isBehaviorComplaint = !!(prompt && loopDepth === 0 && activeBrain?.localBrainPath && looksLikeBehaviorComplaint(prompt));
|
||||
if (isFactCorrection || isBehaviorComplaint) {
|
||||
const visible = this.chatHistory.filter(m => !m.internal);
|
||||
const lastAssistant = [...visible].reverse().find(m => m.role === 'assistant');
|
||||
const lastUserIdx = lastAssistant ? visible.lastIndexOf(lastAssistant) - 1 : -1;
|
||||
const priorQuestion = lastUserIdx >= 0 && visible[lastUserIdx]?.role === 'user' ? visible[lastUserIdx].content : '';
|
||||
if (lastAssistant && priorQuestion) {
|
||||
void captureCorrection({
|
||||
brainPath: activeBrain.localBrainPath,
|
||||
brainPath: activeBrain!.localBrainPath,
|
||||
question: priorQuestion,
|
||||
wrongAnswer: lastAssistant.content,
|
||||
correction: prompt,
|
||||
correction: prompt!,
|
||||
llm: { baseUrl: config.ollamaUrl, model: configDefaultModel },
|
||||
behaviorHint: isBehaviorComplaint,
|
||||
}).then(file => {
|
||||
if (file) logInfo('Correction Loop: 정정 캡처 완료.', { lesson: file });
|
||||
if (file) logInfo('Correction Loop: 정정 캡처 완료.', { lesson: file, behavior: isBehaviorComplaint });
|
||||
}).catch((e: any) => logError('Correction Loop 캡처 실패 (무시).', { error: e?.message ?? String(e) }));
|
||||
}
|
||||
}
|
||||
@@ -670,7 +702,18 @@ export class AgentExecutor {
|
||||
}
|
||||
|
||||
// 3. API Request Setup (라인 229에서 이미 추출한 ollamaUrl, configDefaultModel 재사용)
|
||||
const actualModel = (modelName && modelName.trim()) || configDefaultModel;
|
||||
let actualModel = (modelName && modelName.trim()) || configDefaultModel;
|
||||
// [v2.2.309-D] 조사형 요청은 설정된 상위 모델로 라우팅 (예: "claude-code:sonnet").
|
||||
// 소형 로컬 모델의 lazy tool-use 할루시네이션 대책의 마지막 층 — depth 0 에서
|
||||
// 결정하고 continuation(액션 결과로 이어지는 답변)에도 같은 모델을 유지한다.
|
||||
if (loopDepth === 0 && prompt && config.investigationModel
|
||||
&& isLocalInvestigationPrompt(prompt)) {
|
||||
this._turnCtx.investigationModelOverride = config.investigationModel;
|
||||
logInfo('조사 요청 감지 — investigationModel 로 라우팅.', { model: config.investigationModel });
|
||||
}
|
||||
if (this._turnCtx.investigationModelOverride) {
|
||||
actualModel = this._turnCtx.investigationModelOverride;
|
||||
}
|
||||
// Bound the in-memory history before building the request — shrinks bulky
|
||||
// older tool-result bodies and drops the oldest messages past the cap.
|
||||
capChatHistory(this.chatHistory, {
|
||||
@@ -724,11 +767,17 @@ export class AgentExecutor {
|
||||
? `\n\n${renderSecondBrainTraceContext(secondBrainTrace)}`
|
||||
: '';
|
||||
const retrievalStartMs = Date.now();
|
||||
const memoryCtx = isCasualConversation
|
||||
? ''
|
||||
: await (async () => {
|
||||
// [v2.2.311] continuation depth 는 depth 0 의 memoryCtx 를 재사용 — 재검색
|
||||
// 3~8초 제거 + 빈 쿼리 재검색으로 청크가 갈리는 문제 제거 + 프롬프트 안정화
|
||||
// (dynamicBlocks 등 turnCtx 파생물도 depth 0 것이 그대로 유지된다).
|
||||
let memoryCtx: string;
|
||||
if (isCasualConversation) {
|
||||
memoryCtx = '';
|
||||
} else if (loopDepth > 0 && this._turnCtx.memoryCtxCache !== null) {
|
||||
memoryCtx = this._turnCtx.memoryCtxCache;
|
||||
} else {
|
||||
this.resetTurnContext();
|
||||
return buildMemoryContextFn({
|
||||
memoryCtx = await buildMemoryContextFn({
|
||||
currentPrompt: prompt || '',
|
||||
activeBrain,
|
||||
agentSkillFile: options.agentSkillFile,
|
||||
@@ -739,7 +788,8 @@ export class AgentExecutor {
|
||||
currentTaskId: this.currentTaskId,
|
||||
turnCtx: this._turnCtx,
|
||||
});
|
||||
})();
|
||||
this._turnCtx.memoryCtxCache = memoryCtx;
|
||||
}
|
||||
if (loopDepth === 0 && !isCasualConversation && this._turnCtx.retrieval) {
|
||||
recordTelemetry({
|
||||
kind: 'retrieval',
|
||||
@@ -775,9 +825,19 @@ export class AgentExecutor {
|
||||
? buildPriorTurnConclusionContext(this.chatHistory)
|
||||
: '';
|
||||
// System prompt build (agent vs astra mode) → src/agent/handlePrompt/{buildAgentModeSystemPrompt,buildAstraModeSystemPrompt}.ts
|
||||
const fullSystemPrompt: string = isAgentMode
|
||||
//
|
||||
// [KV 캐시 분리 v2.2.311] 기본 경로(호출자가 systemPrompt 를 넘기지 않은 경우)는
|
||||
// message[0] 을 *정적 본문만* 으로 고정하고, 날짜/RAG/[CONTEXT]/동적 블록 전부를
|
||||
// dynamicContextTail 로 분리해 computeBudgetedRequest 가 마지막 user 메시지 직전에
|
||||
// 삽입한다. llama.cpp prompt cache 가 정적 프롬프트+과거 히스토리를 재사용하게 되어
|
||||
// 매 턴 전체 재프리필(실측 13k 토큰 ≈ 90초)이 "직전 교환 + tail" 로 줄어든다.
|
||||
// 커스텀 systemPrompt 호출자(멀티에이전트 등)는 종전 단일-시스템 경로 유지.
|
||||
const kvSplitEnabled = getConfig().kvCachePromptSplit !== false
|
||||
&& options.systemPrompt === undefined;
|
||||
const builderBasePrompt = kvSplitEnabled ? '' : systemPrompt;
|
||||
const builtSystemPrompt: string = isAgentMode
|
||||
? buildAgentModeSystemPrompt({
|
||||
systemPrompt,
|
||||
systemPrompt: builderBasePrompt,
|
||||
agentSkillContext: options.agentSkillContext || '',
|
||||
modeBridgeCtx,
|
||||
priorConclusionCtx,
|
||||
@@ -793,7 +853,7 @@ export class AgentExecutor {
|
||||
})
|
||||
: buildAstraModeSystemPrompt({
|
||||
prompt,
|
||||
systemPrompt,
|
||||
systemPrompt: builderBasePrompt,
|
||||
modeBridgeCtx,
|
||||
priorConclusionCtx,
|
||||
designerCtx,
|
||||
@@ -808,6 +868,12 @@ export class AgentExecutor {
|
||||
knowledgeMix: this._turnCtx.knowledgeMix,
|
||||
dynamicBlocks: this._turnCtx.dynamicBlocks,
|
||||
});
|
||||
// Split 모드: head = 정적 프롬프트(불변), tail = 날짜 + 빌더 산출(동적 전부).
|
||||
// Legacy 모드: 종전 그대로 head 에 전부.
|
||||
const fullSystemPrompt: string = kvSplitEnabled ? getStaticSystemPrompt() : builtSystemPrompt;
|
||||
const dynamicContextTail: string | undefined = kvSplitEnabled
|
||||
? `${getDateTimeContextBlock()}${builtSystemPrompt}`
|
||||
: undefined;
|
||||
// Context budget computation → src/agent/handlePrompt/computeBudgetedRequest.ts
|
||||
const imageCount = (reqMessages as any[])
|
||||
.reduce((n, m) => n + (Array.isArray(m?.images) ? m.images.length : 0), 0);
|
||||
@@ -843,7 +909,7 @@ export class AgentExecutor {
|
||||
const lastUserIdx = reqMessages.map((m) => m.role).lastIndexOf('user');
|
||||
const lastUser = lastUserIdx >= 0 ? reqMessages[lastUserIdx] : undefined;
|
||||
const content = typeof lastUser?.content === 'string' ? lastUser.content : '';
|
||||
const sysTokens = estimateTokens(fullSystemPrompt) + 4;
|
||||
const sysTokens = estimateTokens(fullSystemPrompt) + (dynamicContextTail ? estimateTokens(dynamicContextTail) : 0) + 4;
|
||||
const mrCfg = {
|
||||
enabled: true,
|
||||
triggerRatio: config.mapReduceTriggerRatio,
|
||||
@@ -903,6 +969,7 @@ export class AgentExecutor {
|
||||
|
||||
const _budget = computeBudgetedRequest({
|
||||
fullSystemPrompt,
|
||||
dynamicContextTail,
|
||||
reqMessages,
|
||||
actualModel,
|
||||
config,
|
||||
@@ -994,6 +1061,12 @@ export class AgentExecutor {
|
||||
const liveFilter = postLiveDeltas ? new LiveReasoningFilter() : null;
|
||||
const postLiveToken = (token: string) => {
|
||||
const visible = liveFilter ? liveFilter.push(token) : token;
|
||||
// [v2.2.310] LM Studio LSEP 구분자 — 여는 마커 없이 흐른 추론이 화면에
|
||||
// 이미 표시된 상태. streamReplace 로 걷어내고 실제 답변부터 다시.
|
||||
if (liveFilter?.consumeSeparatorSignal()) {
|
||||
this.webview?.postMessage({ type: 'streamReplace', value: visible });
|
||||
return;
|
||||
}
|
||||
if (visible) this.webview?.postMessage({ type: 'streamChunk', value: visible });
|
||||
};
|
||||
|
||||
@@ -1425,6 +1498,22 @@ export class AgentExecutor {
|
||||
await this.context.workspaceState.update('lastActionStr', currentActionStr);
|
||||
logInfo('Autonomous loop continuing after actions.', { loopDepth: loopDepth + 1, actions: report });
|
||||
|
||||
// [v2.2.312] 중간 라운드 본문 표시 — 액션과 *함께* 작성된 섹션이 화면에서
|
||||
// 증발하던 버그 수정. 종전엔 액션이 있는 라운드는 여기서 return 하며 본문을
|
||||
// 한 번도 webview 에 보내지 않았고(표시는 라이브 스트리밍뿐 — depth 0 전용),
|
||||
// 최종 라운드만 streamChunk 로 붙었다. 그 결과 모델이 히스토리에서 자기 이전
|
||||
// 섹션(1~3)을 보고 "## 4."부터 이어 써서, 사용자에게는 4번부터 시작하는
|
||||
// 보고서가 도착했다 (실사례). 라이브로 이미 표시된 depth 0 는 중복 방지로 제외.
|
||||
if (loopDepth > 0 || !postLiveDeltas) {
|
||||
try {
|
||||
const { stripActionTagsForDisplay } = await import('./agent/actions/stripForDisplay');
|
||||
const roundVisible = stripActionTagsForDisplay(finalAssistantContent);
|
||||
if (roundVisible) {
|
||||
this.webview.postMessage({ type: 'streamChunk', value: `${loopDepth > 0 ? '\n\n' : ''}${roundVisible}` });
|
||||
}
|
||||
} catch { /* 표시 실패가 루프를 막지 않음 */ }
|
||||
}
|
||||
|
||||
// Explicitly tell the AI to look at the results and continue
|
||||
const continuationPrompt = `The requested local action has been executed.\nAction report:\n${report.join('\n')}\nUse the action result messages already in the conversation to answer the user's original request directly, in the user's language. Do not say you are waiting for the next instruction.`;
|
||||
|
||||
@@ -1499,6 +1588,7 @@ export class AgentExecutor {
|
||||
engine,
|
||||
selfCheckSources: this._turnCtx.selfCheckSources,
|
||||
confidenceSignals: this._turnCtx.confidenceSignals,
|
||||
actionStats: this._turnCtx.actionStats,
|
||||
callNonStreaming: (p) => this.callNonStreaming(p),
|
||||
getAbortSignal: () => this.abortController?.signal,
|
||||
getWebview: () => this.webview,
|
||||
@@ -1508,6 +1598,24 @@ export class AgentExecutor {
|
||||
});
|
||||
} else {
|
||||
this.webview.postMessage({ type: 'streamChunk', value: finalAssistantContent });
|
||||
// [v2.2.309] Hollow Investigation — 액션 turn 의 최종 답변(continuation)은
|
||||
// post-answer hooks(depth 0 전용)를 타지 않으므로 여기서 직접 검사한다.
|
||||
// "list_files 만 하고 read/investigate 없이 파일 여러 개를 서술" = 헛조사.
|
||||
try {
|
||||
const hollowInv = detectHollowInvestigation(finalAssistantContent, this._turnCtx.actionStats);
|
||||
if (hollowInv.hollow) {
|
||||
this.webview.postMessage({ type: 'streamChunk', value: formatHollowInvestigationFooter(hollowInv.fileMentions) });
|
||||
logInfo('Hollow Investigation 감지 (continuation).', { files: hollowInv.fileMentions, stats: this._turnCtx.actionStats });
|
||||
} else {
|
||||
// [v2.2.312] 반대 방향 — 파일을 읽고도 근거 인용 없는 일반론 분석 경고.
|
||||
const { detectUngroundedAnalysis, formatUngroundedAnalysisFooter } = await import('./intelligence/investigationPipeline');
|
||||
const ug = detectUngroundedAnalysis(finalAssistantContent, this._turnCtx.actionStats);
|
||||
if (ug.ungrounded) {
|
||||
this.webview.postMessage({ type: 'streamChunk', value: formatUngroundedAnalysisFooter(ug.readCount) });
|
||||
logInfo('Ungrounded Analysis 감지 (continuation).', { readCount: ug.readCount, fileRefs: ug.fileRefs });
|
||||
}
|
||||
}
|
||||
} catch { /* 감지 실패가 답변을 막지 않음 */ }
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
@@ -1766,6 +1874,15 @@ export class AgentExecutor {
|
||||
await applyCalculateActions(ctx);
|
||||
await applyRunCodeActions(ctx);
|
||||
await applyListFilesActions(ctx);
|
||||
await applyInvestigateFilesActions(ctx);
|
||||
|
||||
// [v2.2.309] Hollow Investigation 감지용 액션 통계 (report 마커 기반, turn 누적).
|
||||
for (const line of report) {
|
||||
if (line.startsWith('📖 Read:')) this._turnCtx.actionStats.reads++;
|
||||
else if (line.startsWith('📂 Listed:')) this._turnCtx.actionStats.lists++;
|
||||
else if (line.startsWith('🔎 Investigated:')) this._turnCtx.actionStats.investigates++;
|
||||
}
|
||||
|
||||
await applyWebFetchActions(ctx);
|
||||
await applyBrainOpsActions(ctx);
|
||||
await applyCalendarActions(ctx);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* <investigate_files path="..." focus="..."/> — 폴더(또는 단일 파일) 강제 정독 조사.
|
||||
*
|
||||
* list_files 가 "이름 목록"을 주는 것과 달리, 이 액션은 대상 파일들을 코드가 직접
|
||||
* 읽어 파일별 추출 노트(Map)를 만들고 병합 노트를 컨텍스트에 주입한다 — 모델이
|
||||
* 파일명만 보고 내용을 상상하는 헛조사(할루시네이션)를 구조적으로 차단. (v2.2.309)
|
||||
* 노트는 내용 해시 캐시로 영속화되어 재조사 시 재사용된다 (온디맨드 지식화).
|
||||
*/
|
||||
import { HandlerContext } from './types';
|
||||
import { validatePath } from '../../security';
|
||||
import { getConfig } from '../../config';
|
||||
import { simpleChatCompletion } from '../../intelligence/llmCall';
|
||||
import { runInvestigation, formatInvestigationNotes } from '../../intelligence/investigationPipeline';
|
||||
import { logInfo, logError } from '../../utils';
|
||||
|
||||
export async function applyInvestigateFilesActions(ctx: HandlerContext): Promise<void> {
|
||||
const { aiMessage, rootPath, report } = ctx;
|
||||
const regex = /<investigate_files\s+path=['"]?([^'">]+)['"]?(?:\s+focus=['"]?([^'">]*)['"]?)?\s*\/?>(?:<\/investigate_files>)?/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(aiMessage)) !== null) {
|
||||
const relPath = match[1].trim() || '.';
|
||||
const focus = (match[2] || '').trim();
|
||||
try {
|
||||
validatePath(rootPath, relPath); // 경로 탈출 방지 (결과는 pipeline 이 재해석)
|
||||
const cfg = getConfig();
|
||||
const llm = { baseUrl: cfg.ollamaUrl, model: cfg.defaultModel };
|
||||
const result = await runInvestigation({
|
||||
rootPath,
|
||||
relDir: relPath,
|
||||
llmCall: (system, user) => simpleChatCompletion(system, user, {
|
||||
...llm, temperature: 0.1, maxTokens: 600, timeoutMs: 120000,
|
||||
}),
|
||||
});
|
||||
|
||||
if (result.notes.length === 0) {
|
||||
report.push(`🔎 Investigate: ${relPath} — 읽을 텍스트 파일 없음 (제외 ${result.skipped.length}건)`);
|
||||
ctx.chatHistory.push({
|
||||
role: 'system', internal: true,
|
||||
content: `[Result of investigate_files "${relPath}"] 읽을 수 있는 텍스트 파일이 없다. 제외: ${result.skipped.slice(0, 10).join(', ') || '—'}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.chatHistory.push({ role: 'system', internal: true, content: formatInvestigationNotes(result, focus) });
|
||||
report.push(`🔎 Investigated: ${relPath} — 파일 ${result.notes.length}개 정독 (캐시 ${result.cacheHits} · LLM ${result.llmCalls} · 제외 ${result.skipped.length})`);
|
||||
logInfo('Investigation pipeline 완료.', { relPath, files: result.notes.length, cacheHits: result.cacheHits, llmCalls: result.llmCalls });
|
||||
} catch (err: any) {
|
||||
report.push(`❌ Investigate failed: ${relPath} — ${err?.message ?? err}`);
|
||||
logError('Investigation pipeline 실패.', { relPath, error: err?.message ?? String(err) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* [v2.2.312] 화면 표시용 액션 태그 제거.
|
||||
*
|
||||
* 액션 루프의 중간 라운드(액션과 함께 본문 섹션을 쓴 라운드)를 사용자 버블에
|
||||
* 표시할 때, <read_file/> 같은 실행 태그 원문이 그대로 노출되지 않도록 걷어낸다.
|
||||
* 실행 자체는 이 함수와 무관 — executeActions 는 원문(cleanedVisible)을 그대로
|
||||
* 받는다. 여기는 *표시 전용* 경로다.
|
||||
*/
|
||||
|
||||
const ACTION_TAG_NAMES = [
|
||||
'read_file', 'list_files', 'investigate_files',
|
||||
'create_file', 'edit_file', 'delete_file',
|
||||
'run_command', 'run_code',
|
||||
'read_brain', 'list_brain',
|
||||
'read_sheet', 'write_sheet', 'append_sheet',
|
||||
'fetch_url', 'calculate',
|
||||
'create_calendar_event', 'add_task', 'update_task', 'complete_task',
|
||||
// edit_file 내부 서브 태그 — 블록 제거가 실패한 잔재까지 정리.
|
||||
'search', 'replace',
|
||||
];
|
||||
|
||||
export function stripActionTagsForDisplay(text: string): string {
|
||||
let t = text || '';
|
||||
for (const name of ACTION_TAG_NAMES) {
|
||||
// 자기닫힘 <tag ... /> → 블록 <tag ...>...</tag> → 잔재 여는/닫는 태그 순.
|
||||
t = t.replace(new RegExp(`<${name}\\b[^>]*/>`, 'gi'), '');
|
||||
t = t.replace(new RegExp(`<${name}\\b[^>]*>[\\s\\S]*?</${name}>`, 'gi'), '');
|
||||
t = t.replace(new RegExp(`</?${name}\\b[^>]*>`, 'gi'), '');
|
||||
}
|
||||
return t.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
@@ -13,6 +13,14 @@ import { buildDroppedHistorySummary } from '../../lib/contextBuilders/droppedHis
|
||||
|
||||
export interface ComputeBudgetedRequestInput {
|
||||
fullSystemPrompt: string;
|
||||
/**
|
||||
* [KV 캐시 분리 v2.2.311] 턴마다 변하는 컨텍스트(RAG/[CONTEXT]/날짜/동적 블록)를
|
||||
* message[0] 이 아니라 *마지막 user 메시지 직전*의 internal system 메시지로 싣는다.
|
||||
* message[0](fullSystemPrompt)은 턴 사이에 불변 → llama.cpp prompt cache 가
|
||||
* "정적 프롬프트 + 과거 히스토리" 프리픽스를 재사용하고, 매 턴 프리필은
|
||||
* "직전 교환 + 이 블록"만으로 줄어든다. undefined 면 종전 단일-시스템 동작.
|
||||
*/
|
||||
dynamicContextTail?: string;
|
||||
/** Caller is expected to have run `capChatHistory` on this already. */
|
||||
reqMessages: ChatMessage[];
|
||||
actualModel: string;
|
||||
@@ -126,12 +134,27 @@ export function computeBudgetedRequest(input: ComputeBudgetedRequestInput): Comp
|
||||
1024,
|
||||
Math.floor((ctxLimits.contextLength - ctxLimits.safetyMargin - preferredOutputReserve - imageTokenReserve) * 0.65)
|
||||
);
|
||||
const { prompt: budgetedSystemPrompt, truncated: systemTruncated } =
|
||||
truncateSystemPromptContext(fullSystemPrompt, systemCapTokens);
|
||||
// Split 모드면 [CONTEXT] 는 dynamicContextTail 쪽에 있으므로 truncation 도 tail 에 적용.
|
||||
// (정적 head 에는 [CONTEXT] 마커가 없어 truncate 가 no-op — head 는 그대로 둔다.)
|
||||
const splitMode = typeof input.dynamicContextTail === 'string' && input.dynamicContextTail.trim().length > 0;
|
||||
let budgetedSystemPrompt = fullSystemPrompt;
|
||||
let budgetedTail = splitMode ? input.dynamicContextTail! : '';
|
||||
let systemTruncated = false;
|
||||
if (splitMode) {
|
||||
const headTokens = estimateTokens(fullSystemPrompt);
|
||||
const tailCap = Math.max(512, systemCapTokens - headTokens);
|
||||
const t = truncateSystemPromptContext(budgetedTail, tailCap);
|
||||
budgetedTail = t.prompt;
|
||||
systemTruncated = t.truncated;
|
||||
} else {
|
||||
const t = truncateSystemPromptContext(fullSystemPrompt, systemCapTokens);
|
||||
budgetedSystemPrompt = t.prompt;
|
||||
systemTruncated = t.truncated;
|
||||
}
|
||||
if (systemTruncated) {
|
||||
logInfo('System prompt context truncated to fit the context window.', { model: actualModel, systemCapTokens });
|
||||
}
|
||||
const systemTokens = estimateTokens(budgetedSystemPrompt) + 4;
|
||||
const systemTokens = estimateTokens(budgetedSystemPrompt) + (splitMode ? estimateTokens(budgetedTail) + 4 : 0) + 4;
|
||||
|
||||
// (2) 대화 기록 압축.
|
||||
const historyBudget = Math.max(
|
||||
@@ -156,9 +179,27 @@ export function computeBudgetedRequest(input: ComputeBudgetedRequestInput): Comp
|
||||
}
|
||||
}
|
||||
|
||||
// Split 모드: 동적 컨텍스트를 마지막 user 메시지 *직전*에 삽입. 이 위치라야
|
||||
// (a) 이전 요청과의 공통 프리픽스(정적 시스템 + 과거 히스토리)가 최대로 보존되고
|
||||
// (b) continuation(액션 결과가 뒤에 붙는 라운드)에서도 같은 자리라 턴 내 캐시가 유지된다.
|
||||
// user 메시지가 없으면(이론상) 히스토리 끝에 붙인다 — 생성 직전이므로 여전히 유효.
|
||||
let historyWithTail = budgetedHistory;
|
||||
if (splitMode) {
|
||||
const tailMsg: ChatMessage = { role: 'system', content: budgetedTail, internal: true };
|
||||
const lastUserIdx = (() => {
|
||||
for (let i = budgetedHistory.length - 1; i >= 0; i--) {
|
||||
if (budgetedHistory[i].role === 'user') return i;
|
||||
}
|
||||
return -1;
|
||||
})();
|
||||
historyWithTail = lastUserIdx >= 0
|
||||
? [...budgetedHistory.slice(0, lastUserIdx), tailMsg, ...budgetedHistory.slice(lastUserIdx)]
|
||||
: [...budgetedHistory, tailMsg];
|
||||
}
|
||||
|
||||
const messagesForRequest: ChatMessage[] = [
|
||||
{ role: 'system', content: budgetedSystemPrompt, internal: true },
|
||||
...budgetedHistory
|
||||
...historyWithTail
|
||||
];
|
||||
|
||||
// (3) 동적 출력 상한.
|
||||
|
||||
@@ -41,6 +41,12 @@ export async function streamChatOnce(deps: StreamChatOnceDeps, params: {
|
||||
const post = (token: string) => {
|
||||
if (params.postLiveDeltas && token) {
|
||||
const visible = liveFilter.push(token);
|
||||
// [v2.2.310] LM Studio LSEP 구분자 — 마커 이전에 라이브로 나간 추론 텍스트를
|
||||
// streamReplace 로 화면에서 걷어내고, 마커 이후(실제 답변)부터 다시 시작.
|
||||
if (liveFilter.consumeSeparatorSignal()) {
|
||||
deps.getWebview()?.postMessage({ type: 'streamReplace', value: visible });
|
||||
return;
|
||||
}
|
||||
if (visible) deps.getWebview()?.postMessage({ type: 'streamChunk', value: visible });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -26,6 +26,8 @@ import { appendSuccessPattern } from '../../intelligence/skillScore';
|
||||
import { getConfig } from '../../config';
|
||||
import { detectReimplementedProposals, formatReimplementationFooter } from '../../extension/featureConceptMap';
|
||||
import { isSelfAssessRequest, isAboutSelf } from '../../lib/contextBuilders/selfAssessContext';
|
||||
import { detectHollowInvestigation, formatHollowInvestigationFooter } from '../../intelligence/investigationPipeline';
|
||||
import { logInfo } from '../../utils';
|
||||
|
||||
const devilRebuttalHook: PostAnswerHook = {
|
||||
id: 'devil-rebuttal',
|
||||
@@ -86,6 +88,23 @@ const termValidatorHook: PostAnswerHook = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* [v2.2.309] Hollow Investigation — "목록만 조회(list_files)하고 읽지는 않은 채(read/
|
||||
* investigate 0회) 파일 여러 개를 서술한 답변" 감지. 소형 모델의 파일명 상상 조사를
|
||||
* 사용자에게 즉시 노출한다 (결정론적, LLM 호출 없음).
|
||||
*/
|
||||
const hollowInvestigationHook: PostAnswerHook = {
|
||||
id: 'hollow-investigation',
|
||||
runAsync: false,
|
||||
run(ctx: PostAnswerHookContext): void {
|
||||
if (!ctx.assistantAnswer?.trim()) return;
|
||||
const { hollow, fileMentions } = detectHollowInvestigation(ctx.assistantAnswer, ctx.actionStats);
|
||||
if (!hollow) return;
|
||||
ctx.getWebview()?.postMessage({ type: 'streamChunk', value: formatHollowInvestigationFooter(fileMentions) });
|
||||
logInfo('Hollow Investigation 감지 — list만 하고 read 없이 파일 서술.', { files: fileMentions });
|
||||
},
|
||||
};
|
||||
|
||||
const requirementCoverageHook: PostAnswerHook = {
|
||||
id: 'requirement-coverage',
|
||||
runAsync: false,
|
||||
@@ -253,6 +272,7 @@ export const POST_ANSWER_HOOKS: PostAnswerHook[] = [
|
||||
devilRebuttalHook,
|
||||
postHocSelfCheckHook,
|
||||
termValidatorHook,
|
||||
hollowInvestigationHook,
|
||||
requirementCoverageHook,
|
||||
confidenceEscalationHook,
|
||||
criticLoopHook,
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface PostAnswerHookContext {
|
||||
selfCheckSources: Array<{ title: string; excerpt: string }>;
|
||||
/** Confidence Engine 검색 신호 (Phase 2). memoryContext 가 채움 — 검색 안 돈 turn 은 null. */
|
||||
confidenceSignals?: import('../../intelligence/confidenceEngine').RetrievalConfidenceSignals | null;
|
||||
/** [v2.2.309] 이번 turn 액션 실행 통계 — Hollow Investigation(헛조사) 감지용. */
|
||||
actionStats?: { reads: number; lists: number; investigates: number } | null;
|
||||
/** Devil Agent 가 호출 — non-streaming LLM. */
|
||||
callNonStreaming: (params: any) => Promise<{ text: string; stopReason?: string }>;
|
||||
/** Abort signal accessor. */
|
||||
|
||||
@@ -227,6 +227,25 @@ export interface IAgentConfig {
|
||||
* 결과는 답변 아래 footer 한 줄로 표시.
|
||||
*/
|
||||
selfCheckEnabled: boolean;
|
||||
/**
|
||||
* [v2.2.309] 조사형 요청("~폴더 조사해줘") 전용 모델 오버라이드.
|
||||
* 예: "claude-code:sonnet" — 소형 로컬 모델의 lazy tool-use(파일명만 보고 서술)
|
||||
* 할루시네이션 대책. 빈 문자열이면 비활성 (현재 선택 모델 그대로).
|
||||
*/
|
||||
investigationModel: string;
|
||||
/**
|
||||
* [v2.2.311] KV 캐시 친화 프롬프트 분리 — message[0] 을 정적 본문으로 고정하고
|
||||
* 날짜/RAG/[CONTEXT]/동적 블록을 마지막 user 메시지 직전의 system 메시지로 옮긴다.
|
||||
* llama.cpp prompt cache 프리픽스 재사용으로 턴당 재프리필을 대폭 축소. 기본 true.
|
||||
* 특정 모델 템플릿에서 문제 시 false 로 종전 단일-시스템 프롬프트 복귀.
|
||||
*/
|
||||
kvCachePromptSplit: boolean;
|
||||
/**
|
||||
* [v2.2.311] RAG/메모리 검색 토큰 예산 상한. 0 = 자동(컨텍스트 창의 12%,
|
||||
* 2,500~6,000 클램프). 종전 자동값(창의 25%, 최대 80k)은 32k 창에서 8k 토큰을
|
||||
* 검색 결과에 써서 로컬 프리필만 수십 초를 태웠다 — 상한을 낮춰 답변 지연을 줄인다.
|
||||
*/
|
||||
retrievalTokenBudget: number;
|
||||
/** Self-check 전용 모델 ID. 비면 defaultModel. 빠른 작은 모델 권장. */
|
||||
selfCheckModel: string;
|
||||
/** Self-check LLM 호출 타임아웃 (초). 기본 6. */
|
||||
@@ -576,6 +595,9 @@ export function getConfig(): IAgentConfig {
|
||||
intentClarificationEnabled: cfg.get<boolean>('intentClarificationEnabled', true),
|
||||
intentClarificationStrictness: (cfg.get<string>('intentClarificationStrictness', 'medium') as 'low' | 'medium' | 'high') || 'medium',
|
||||
citationTraceEnabled: cfg.get<boolean>('citationTraceEnabled', true),
|
||||
investigationModel: (cfg.get<string>('investigationModel', '') || '').trim(),
|
||||
kvCachePromptSplit: cfg.get<boolean>('kvCachePromptSplit', true),
|
||||
retrievalTokenBudget: Math.max(0, cfg.get<number>('retrievalTokenBudget', 0) || 0),
|
||||
selfCheckEnabled: cfg.get<boolean>('selfCheckEnabled', false),
|
||||
selfCheckModel: cfg.get<string>('selfCheckModel', '') || '',
|
||||
selfCheckTimeoutSec: Math.max(1, Math.min(60, cfg.get<number>('selfCheckTimeoutSec', 6))),
|
||||
|
||||
@@ -86,6 +86,17 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
// 두뇌에 기록. 자기 평가가 구식 스냅샷 대신 항상 현행 소스를 근거로 하게 한다.
|
||||
void ensureFeatureInventory(context);
|
||||
|
||||
// [v2.2.311] 제2뇌 캐시 워밍 — 활성화 직후 백그라운드로 파일 목록 + 토큰 인덱스
|
||||
// (수십 MB JSON)를 메모리에 올려, 세션 첫 질의가 그 비용(수 초)을 뒤집어쓰지
|
||||
// 않게 한다. 워처가 같이 걸려 이후 턴은 변경 없으면 워크/stat 을 통째로 생략.
|
||||
try {
|
||||
const { getActiveBrainProfile } = require('./utils') as typeof import('./utils');
|
||||
const { warmBrainCache } = require('./retrieval/brainWatch') as typeof import('./retrieval/brainWatch');
|
||||
const { getBrainTokenIndex } = require('./retrieval/brainIndex') as typeof import('./retrieval/brainIndex');
|
||||
const brainPath = getActiveBrainProfile()?.localBrainPath;
|
||||
if (brainPath) warmBrainCache(brainPath, (files) => { getBrainTokenIndex(brainPath, files); });
|
||||
} catch { /* 워밍 실패는 비치명 — 첫 질의가 종전 경로로 처리 */ }
|
||||
|
||||
// Initialize Astra Path Resolver (.astra → ConnectAI/.astra/)
|
||||
initAstraPathResolver(context);
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import { computeSkillScores, formatSkillScoresMarkdown, loadSuccessPatterns, for
|
||||
import { runResearch, formatProposalMarkdown } from '../intelligence/researchAgent';
|
||||
import type { ExistingKnowledgeRef } from '../intelligence/knowledgeValidation';
|
||||
import { loadQueue, saveQueue, mergeNeedsIntoQueue, formatQueueMarkdown, LEARNING_QUEUE_REL_PATH } from '../intelligence/learningQueue';
|
||||
import { loadStandingRules, buildStandingRulesBlock, STANDING_RULES_REL_PATH } from '../intelligence/correctionLoop';
|
||||
|
||||
/**
|
||||
* 검색 평가 명령 묶음 (Phase 1-나).
|
||||
@@ -43,6 +44,7 @@ export function registerEvalCommands(): vscode.Disposable[] {
|
||||
vscode.commands.registerCommand('g1nation.embeddings.backfill', backfillEmbeddingsCommand),
|
||||
vscode.commands.registerCommand('g1nation.eval.tasks', runTaskEvalCommand),
|
||||
vscode.commands.registerCommand('g1nation.growth.report', growthReportCommand),
|
||||
vscode.commands.registerCommand('g1nation.growth.standingRules', standingRulesCommand),
|
||||
vscode.commands.registerCommand('g1nation.growth.learningQueue', learningQueueCommand),
|
||||
vscode.commands.registerCommand('g1nation.knowledge.decayAudit', decayAuditCommand),
|
||||
vscode.commands.registerCommand('g1nation.research.runQueue', researchRunQueueCommand),
|
||||
@@ -307,6 +309,57 @@ async function runTaskEvalCommand(): Promise<void> {
|
||||
}
|
||||
|
||||
/** 성장 리포트 — Reflection 기록(.astra/growth/reflections.jsonl)의 주별 추이 + 반복 실수 Top. */
|
||||
/**
|
||||
* Standing Rules 열람 — "주입엔 항상 도달 증거를" 원칙의 확인 창구.
|
||||
* 현재 매 턴 주입 중인 상시 행동 규칙과 실제 주입 블록 원문을 파일로 열어준다.
|
||||
* 규칙 수정/삭제는 standing-rules.json 을 직접 편집 (Permission Based Learning).
|
||||
*/
|
||||
async function standingRulesCommand(): Promise<void> {
|
||||
try {
|
||||
const brain = getActiveBrainProfile();
|
||||
if (!brain?.localBrainPath || !fs.existsSync(brain.localBrainPath)) {
|
||||
vscode.window.showErrorMessage('활성 두뇌 폴더를 찾을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
const rules = loadStandingRules(brain.localBrainPath);
|
||||
const active = rules.filter(r => r.status === 'active');
|
||||
const retired = rules.filter(r => r.status !== 'active');
|
||||
const block = buildStandingRulesBlock(rules);
|
||||
const md = [
|
||||
'# 상시 행동 규칙 (Standing Rules)',
|
||||
'',
|
||||
`사용자 지적("또 ~하네", "하지 말라고 했잖아")이 명령형 규칙으로 정규화되어 여기 쌓이고,`,
|
||||
`**매 턴 시스템 프롬프트 보호 구역에 주입**됩니다 — 세션을 새로 열어도 유지됩니다.`,
|
||||
`수정/삭제: \`${STANDING_RULES_REL_PATH}\` 파일을 직접 편집하세요.`,
|
||||
'',
|
||||
`## 활성 규칙 ${active.length}개`,
|
||||
'',
|
||||
...(active.length
|
||||
? active.sort((a, b) => b.hits - a.hits).map(r =>
|
||||
`- **${r.rule}** — 지적 ${r.hits}회, 최근 ${r.lastHitAt.slice(0, 10)}\n - 원 발화: "${r.origin}"`)
|
||||
: ['(아직 없음 — 행동/형식을 지적하면 자동으로 쌓입니다)']),
|
||||
'',
|
||||
`## 은퇴 규칙 ${retired.length}개`,
|
||||
...(retired.length ? retired.map(r => `- ${r.rule} (지적 ${r.hits}회)`) : ['(없음)']),
|
||||
'',
|
||||
'## 실제 주입 블록 (도달 증거)',
|
||||
'',
|
||||
'```',
|
||||
block || '(활성 규칙이 없어 이번 턴에는 주입되지 않음)',
|
||||
'```',
|
||||
'',
|
||||
].join('\n');
|
||||
const reportPath = path.join(brain.localBrainPath, '.astra', 'growth', 'standing-rules-report.md');
|
||||
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
||||
fs.writeFileSync(reportPath, md, 'utf8');
|
||||
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(reportPath));
|
||||
await vscode.window.showTextDocument(doc, { preview: false });
|
||||
} catch (err: any) {
|
||||
logError('Standing rules command failed.', { error: err?.message || String(err) });
|
||||
vscode.window.showErrorMessage(`상시 규칙 열람 실패: ${err?.message ?? err}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function growthReportCommand(): Promise<void> {
|
||||
try {
|
||||
const brain = getActiveBrainProfile();
|
||||
|
||||
@@ -178,8 +178,21 @@ export async function captureCorrection(opts: {
|
||||
wrongAnswer: string;
|
||||
correction: string;
|
||||
llm: { baseUrl: string; model: string };
|
||||
/** 행동/스타일 지적으로 감지됨 (looksLikeBehaviorComplaint) — Standing Rule 파생 대상. */
|
||||
behaviorHint?: boolean;
|
||||
}): Promise<string | null> {
|
||||
const { tag, title } = await classifyCorrection(opts.question, opts.wrongAnswer, opts.correction, opts.llm);
|
||||
|
||||
// [④ Standing Rules] 행동/스타일 교정은 명령형 규칙으로 정규화해 즉시 영속화 —
|
||||
// 다음 턴부터(그리고 새 세션에서도) 매 턴 주입된다. 태그가 형식오류/지시불이행
|
||||
// 이거나 감지 단계에서 행동 지적 패턴(behaviorHint)에 걸린 경우만.
|
||||
if (opts.behaviorHint || tag === '형식오류' || tag === '지시불이행') {
|
||||
try {
|
||||
const rule = await deriveStandingRule(opts.wrongAnswer, opts.correction, opts.llm);
|
||||
if (rule) upsertStandingRule(opts.brainPath, rule, opts.correction);
|
||||
} catch { /* 규칙 파생 실패가 레슨 캡처를 막지 않음 */ }
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const c: CorrectionCase = {
|
||||
ts: now.toISOString(),
|
||||
@@ -380,3 +393,165 @@ export function formatRegressionReport(results: RegressionResult[], meta: { date
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ── ④ Standing Rules — 세션을 넘는 상시 행동 규칙 ────────────────────────────
|
||||
//
|
||||
// 배경: 기존 파이프라인(①~③)은 *사실 정정*을 영속화하지만, "결론 수정 문구 쓰지 마"
|
||||
// 같은 *행동/스타일 교정*은 (a) 감지 패턴에 안 걸리고 (b) 레슨은 유사도 검색이라
|
||||
// 질문 내용과 무관한 상시 규칙이 주입되지 않아 새 세션에서 증발했다 (v2.2.308).
|
||||
//
|
||||
// 해법: 행동 교정을 명령형 1줄 규칙으로 정규화해 파일에 영속화하고, 검색과 무관하게
|
||||
// 매 턴 behavior-constraints 보호 구역에 주입한다. 상한(10개)과 재지적 카운트로
|
||||
// 프롬프트 오염을 막고, 파일은 사람이 열어 수정/삭제 가능 (Permission Based Learning).
|
||||
|
||||
export const STANDING_RULES_REL_PATH = path.join('.astra', 'growth', 'standing-rules.json');
|
||||
export const MAX_ACTIVE_STANDING_RULES = 10;
|
||||
|
||||
export interface StandingRule {
|
||||
id: string; // 정규화된 규칙의 해시
|
||||
rule: string; // 명령형 1줄 (모든 답변에 적용)
|
||||
origin: string; // 규칙을 만든 원 발화 (사용자 지적)
|
||||
createdAt: string; // ISO
|
||||
lastHitAt: string; // 마지막 재지적 ISO
|
||||
hits: number; // 지적 누계 (1 = 최초) — 클수록 은퇴 대상에서 멂
|
||||
status: 'active' | 'retired';
|
||||
}
|
||||
|
||||
// 행동/스타일 지적 감지 — 사실 정정(looksLikeCorrection)과 별개의 그물.
|
||||
// "또 ~하네", "하지 말라고 했잖아", "몇 번을 말해" 같은 반복 지적이 핵심 신호다.
|
||||
const BEHAVIOR_COMPLAINT_RE = [
|
||||
/^\s*또\s+.{1,80}(하네|하잖|이네|했네|라고\s*(썼|하)|나왔|그러)/, // "또 결론 수정이라고 썼네"
|
||||
/(하지\s*말라(고|니까)|말라고\s*했(잖|는데)|말랬잖)/, // "하지 말라고 했잖아"
|
||||
/(몇\s*번(을|이나)?\s*(말|얘기|이야기)|매번\s*똑같)/, // "몇 번을 말해"
|
||||
/(왜\s*자꾸|자꾸\s*왜|계속\s*(그러|반복)|또\s*반복)/, // "왜 자꾸 그래"
|
||||
/(붙이지\s*마|쓰지\s*마|넣지\s*마|빼\s*달라고|빼라고)/, // 출력 형식 지시
|
||||
];
|
||||
|
||||
export function looksLikeBehaviorComplaint(prompt: string): boolean {
|
||||
const t = (prompt || '').trim();
|
||||
if (t.length < 4 || t.length > 1500) return false;
|
||||
return BEHAVIOR_COMPLAINT_RE.some(re => re.test(t));
|
||||
}
|
||||
|
||||
// LLM 으로 지적 → 명령형 규칙 정규화. 실패 시 휴리스틱 fallback.
|
||||
const RULE_SYSTEM = [
|
||||
'너는 AI 행동 규칙 작성기다. 사용자가 AI 답변의 습관/형식/태도를 지적했다.',
|
||||
'이 지적을 앞으로 모든 답변에 적용할 명령형 규칙 한 줄(한국어, 70자 이내)로 요약하라.',
|
||||
'내용 지식(사실)이 아니라 행동 규칙만 만든다. 행동 규칙으로 만들 수 없으면 빈 문자열.',
|
||||
'반드시 JSON 한 줄만 출력: {"rule":"<규칙 또는 빈 문자열>"}',
|
||||
].join('\n');
|
||||
|
||||
export async function deriveStandingRule(
|
||||
wrongAnswer: string,
|
||||
correction: string,
|
||||
llm: { baseUrl: string; model: string },
|
||||
): Promise<string> {
|
||||
const fallback = (): string => {
|
||||
// "~하지 마/말라" 문장이 있으면 그 문장 자체가 규칙에 가장 가깝다.
|
||||
const m = correction.match(/[^.!?\n]*(?:하지\s*마|말라|쓰지\s*마|붙이지\s*마|넣지\s*마)[^.!?\n]*/);
|
||||
const base = (m ? m[0] : correction).trim().replace(/\s+/g, ' ').slice(0, 70);
|
||||
return base ? `${base} — 이 지적을 반복하게 하지 않는다` : '';
|
||||
};
|
||||
try {
|
||||
const user = [
|
||||
`[AI 답변 발췌] ${wrongAnswer.slice(0, MAX_FIELD_CHARS)}`,
|
||||
`[사용자 지적] ${correction.slice(0, MAX_FIELD_CHARS)}`,
|
||||
].join('\n');
|
||||
const raw = await simpleChatCompletion(RULE_SYSTEM, user, {
|
||||
baseUrl: llm.baseUrl, model: llm.model, temperature: 0.1, maxTokens: 120, timeoutMs: 30000,
|
||||
});
|
||||
const m = raw.match(/\{[\s\S]*?\}/);
|
||||
if (!m) return fallback();
|
||||
const rule = String(JSON.parse(m[0])?.rule || '').trim().replace(/\s+/g, ' ').slice(0, 90);
|
||||
return rule || fallback();
|
||||
} catch {
|
||||
return fallback();
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRuleText(rule: string): string {
|
||||
return rule.toLowerCase().replace(/[^a-z0-9가-힣]+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function ruleId(rule: string): string {
|
||||
// 의존성 없는 짧은 해시 (djb2) — 파일에서 사람이 규칙을 찾을 때 쓰는 식별자.
|
||||
const s = normalizeRuleText(rule);
|
||||
let h = 5381;
|
||||
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;
|
||||
return 'sr-' + h.toString(36);
|
||||
}
|
||||
|
||||
// 같은 규칙인지 — 토큰 자카드 유사도 (LLM 없이, 재지적 시 hits 만 올리기 위함).
|
||||
export function isSameRule(a: string, b: string): boolean {
|
||||
const ta = new Set(normalizeRuleText(a).split(' ').filter(w => w.length >= 2));
|
||||
const tb = new Set(normalizeRuleText(b).split(' ').filter(w => w.length >= 2));
|
||||
if (ta.size === 0 || tb.size === 0) return false;
|
||||
let inter = 0;
|
||||
for (const w of ta) if (tb.has(w)) inter++;
|
||||
return inter / (ta.size + tb.size - inter) >= 0.6;
|
||||
}
|
||||
|
||||
export function loadStandingRules(brainPath: string): StandingRule[] {
|
||||
try {
|
||||
const file = path.join(brainPath, STANDING_RULES_REL_PATH);
|
||||
if (!fs.existsSync(file)) return [];
|
||||
const o = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
return Array.isArray(o) ? o.filter(r => r && typeof r.rule === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveStandingRules(brainPath: string, rules: StandingRule[]): boolean {
|
||||
try {
|
||||
const file = path.join(brainPath, STANDING_RULES_REL_PATH);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, JSON.stringify(rules, null, 2) + '\n', 'utf8');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 규칙 추가/재지적 반영. 중복이면 hits++ (은퇴했던 규칙도 부활), 신규면 추가.
|
||||
* active 가 상한을 넘으면 hits 낮고 오래된 것부터 은퇴. 저장까지 수행.
|
||||
*/
|
||||
export function upsertStandingRule(brainPath: string, rule: string, origin: string, nowIso?: string): StandingRule[] {
|
||||
const now = nowIso || new Date().toISOString();
|
||||
const rules = loadStandingRules(brainPath);
|
||||
const existing = rules.find(r => isSameRule(r.rule, rule));
|
||||
if (existing) {
|
||||
existing.hits += 1;
|
||||
existing.lastHitAt = now;
|
||||
existing.status = 'active'; // 재지적 = 아직 필요한 규칙
|
||||
} else {
|
||||
rules.push({
|
||||
id: ruleId(rule), rule: rule.trim(), origin: origin.slice(0, 200),
|
||||
createdAt: now, lastHitAt: now, hits: 1, status: 'active',
|
||||
});
|
||||
}
|
||||
// 상한 초과 시 은퇴: hits 오름차순 → lastHitAt 오래된 순.
|
||||
const active = rules.filter(r => r.status === 'active');
|
||||
if (active.length > MAX_ACTIVE_STANDING_RULES) {
|
||||
const toRetire = active
|
||||
.sort((a, b) => (a.hits - b.hits) || a.lastHitAt.localeCompare(b.lastHitAt))
|
||||
.slice(0, active.length - MAX_ACTIVE_STANDING_RULES);
|
||||
for (const r of toRetire) r.status = 'retired';
|
||||
}
|
||||
saveStandingRules(brainPath, rules);
|
||||
return rules;
|
||||
}
|
||||
|
||||
/** 매 턴 주입 블록 — 검색과 무관한 상시 규칙. active 없으면 ''. */
|
||||
export function buildStandingRulesBlock(rules: StandingRule[]): string {
|
||||
const active = rules.filter(r => r.status === 'active')
|
||||
.sort((a, b) => (b.hits - a.hits) || b.lastHitAt.localeCompare(a.lastHitAt))
|
||||
.slice(0, MAX_ACTIVE_STANDING_RULES);
|
||||
if (active.length === 0) return '';
|
||||
const lines = ['[상시 행동 규칙 — 사용자 지적으로 축적됨. 모든 답변에서 반드시 지켜라]'];
|
||||
for (const r of active) {
|
||||
lines.push(`- ${r.rule}${r.hits >= 2 ? ` (지적 ${r.hits}회)` : ''}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Investigation Pipeline — "파일명만 보고 상상하는 조사" 를 구조적으로 차단. (v2.2.309)
|
||||
*
|
||||
* 문제: 소형 로컬 모델은 <list_files> 로 파일명 목록을 받으면 <read_file> 을 부르지
|
||||
* 않고 이름에서 내용을 상상해 조사 결과를 지어낸다 (lazy tool-use 할루시네이션).
|
||||
*
|
||||
* 해법 3중:
|
||||
* ① 증거 게이트 — 조사형 요청 감지 시 "읽지 않은 파일 서술 금지" 행동 제약 주입
|
||||
* (buildInvestigationGateBlock → memoryContext behavior-constraints 채널).
|
||||
* ② Map-Reduce — <investigate_files> 액션이 폴더의 파일을 *코드가 하나씩 강제로
|
||||
* 읽어* 파일별 추출 노트 생성(Map) 후 병합 노트를 컨텍스트에 주입(Reduce 는
|
||||
* 본 턴 모델이 노트 기반으로 수행). 모델에게 "읽을지" 선택권 자체가 없다.
|
||||
* ③ 온디맨드 지식화 캐시 — 파일 내용 해시를 키로 노트를 .astra/cache/investigate 에
|
||||
* 저장. 같은 내용은 재조사 시 LLM 호출 없이 재사용, 파일이 바뀐 것만 갱신.
|
||||
* 전량 위키화 없이 조사가 닿는 곳만 점진적으로 지식화된다.
|
||||
*
|
||||
* 노트는 질문 비의존(파일의 목적/핵심 사실/관계)으로 뽑는다 — 캐시 재사용률을
|
||||
* 최대화하기 위함 (질문 특화 해석은 Reduce 단계의 본 턴 모델 몫).
|
||||
*/
|
||||
import * as crypto from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { EXCLUDED_DIRS } from '../config';
|
||||
|
||||
// ── 상수 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const INVESTIGATE_CACHE_REL_DIR = path.join('.astra', 'cache', 'investigate');
|
||||
export const MAX_INVESTIGATION_FILES = 40; // 초과분은 skipped 로 보고 (조용한 누락 금지)
|
||||
export const MAX_CHARS_PER_FILE = 20_000; // 파일당 읽기 상한 (소형 모델 충실 구간)
|
||||
|
||||
const TEXT_EXTS = new Set([
|
||||
'.md', '.txt', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json', '.jsonl',
|
||||
'.html', '.css', '.py', '.yml', '.yaml', '.csv', '.xml', '.sh', '.bat', '.ps1', '.sql', '.env',
|
||||
]);
|
||||
|
||||
export interface InvestigationFileNote {
|
||||
relPath: string;
|
||||
note: string;
|
||||
fromCache: boolean;
|
||||
}
|
||||
|
||||
export interface InvestigationResult {
|
||||
dir: string;
|
||||
notes: InvestigationFileNote[];
|
||||
/** 상한·확장자·읽기실패로 제외된 파일 (조용한 누락 방지용 보고). */
|
||||
skipped: string[];
|
||||
cacheHits: number;
|
||||
llmCalls: number;
|
||||
}
|
||||
|
||||
// ── ① 조사형 요청 감지 + 증거 게이트 블록 ───────────────────────────────────
|
||||
|
||||
const INVESTIGATE_VERB_RE = /(조사|분석|검토|파악|리뷰|정리)\s*(해|을|를|좀|부탁|해줘|해주|하고|해서)/;
|
||||
const LOCAL_TARGET_RE = /(폴더|파일|디렉토리|프로젝트|코드|문서|리소스|소스)|([A-Za-z]:\\|\.{0,2}\/[\w가-힣])/;
|
||||
|
||||
/** 로컬 자료를 대상으로 한 조사형 요청인지 (증거 게이트·모델 라우팅 공용 감지기). */
|
||||
export function isLocalInvestigationPrompt(prompt: string): boolean {
|
||||
const t = (prompt || '').trim();
|
||||
if (t.length < 6) return false;
|
||||
return INVESTIGATE_VERB_RE.test(t) && LOCAL_TARGET_RE.test(t);
|
||||
}
|
||||
|
||||
/** 조사형 요청일 때만 행동 제약 블록 반환, 아니면 ''. */
|
||||
export function buildInvestigationGateBlock(prompt: string): string {
|
||||
if (!isLocalInvestigationPrompt(prompt)) return '';
|
||||
return [
|
||||
'[조사 증거 게이트 — 로컬 자료 조사 시 필수]',
|
||||
'- 파일명·폴더 목록만 보고 내용을 서술하는 것 금지. 내용에 대한 모든 주장은 실제로 읽은 것만 쓴다.',
|
||||
'- 폴더 단위 조사는 <investigate_files path="상대경로" focus="조사 목적"/> 한 번으로 위임하라 — 각 파일을 자동으로 읽어 파일별 노트를 만들어준다. 개별 파일은 <read_file>.',
|
||||
'- 읽지 않은 파일을 언급해야 하면 이름 뒤에 "(미확인)"을 붙여라.',
|
||||
'- 조사 결과의 각 주장 끝에 근거 파일 경로를 붙여라 (예: [src/agent.ts]). 근거 없는 주장은 쓰지 않는다.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ── ②③ 파일 수집 → 노트 추출(캐시) → 병합 ──────────────────────────────────
|
||||
|
||||
export function collectInvestigationFiles(
|
||||
rootPath: string,
|
||||
relDir: string,
|
||||
maxFiles = MAX_INVESTIGATION_FILES,
|
||||
): { files: Array<{ relPath: string; absPath: string }>; skipped: string[] } {
|
||||
const files: Array<{ relPath: string; absPath: string }> = [];
|
||||
const skipped: string[] = [];
|
||||
const baseAbs = path.resolve(rootPath, relDir || '.');
|
||||
|
||||
const walk = (abs: string, rel: string): void => {
|
||||
let entries: fs.Dirent[];
|
||||
try { entries = fs.readdirSync(abs, { withFileTypes: true }); } catch { skipped.push(rel + '/ (읽기 실패)'); return; }
|
||||
// 결정적 순서 (테스트·캐시 재현성)
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const e of entries) {
|
||||
if (e.name.startsWith('.') || EXCLUDED_DIRS.has(e.name)) continue;
|
||||
const childAbs = path.join(abs, e.name);
|
||||
const childRel = rel ? `${rel}/${e.name}` : e.name;
|
||||
if (e.isDirectory()) { walk(childAbs, childRel); continue; }
|
||||
if (!TEXT_EXTS.has(path.extname(e.name).toLowerCase())) { skipped.push(childRel + ' (비텍스트)'); continue; }
|
||||
if (files.length >= maxFiles) { skipped.push(childRel + ' (상한 초과)'); continue; }
|
||||
files.push({ relPath: childRel, absPath: childAbs });
|
||||
}
|
||||
};
|
||||
|
||||
const st = fs.existsSync(baseAbs) ? fs.statSync(baseAbs) : null;
|
||||
if (st?.isFile()) {
|
||||
files.push({ relPath: relDir, absPath: baseAbs });
|
||||
} else if (st?.isDirectory()) {
|
||||
walk(baseAbs, '');
|
||||
}
|
||||
return { files, skipped };
|
||||
}
|
||||
|
||||
const EXTRACT_SYSTEM = [
|
||||
'너는 파일 내용 분석기다. 제공된 파일 *내용*만 근거로 사실을 추출하라. 내용에 없는 것을 지어내지 말 것.',
|
||||
'출력 형식 (개조식, 명사형 종결):',
|
||||
'목적: <이 파일이 무엇인지 한 줄>',
|
||||
'핵심:',
|
||||
'- <핵심 사실/설정/결정 3~8개 — 수치·이름·경로 포함>',
|
||||
'관계: <다른 파일/모듈과의 연결이 내용에 명시되어 있으면, 없으면 "—">',
|
||||
].join('\n');
|
||||
|
||||
function cacheKey(content: string): string {
|
||||
return crypto.createHash('sha1').update(content).digest('hex');
|
||||
}
|
||||
|
||||
function cachePathFor(rootPath: string, key: string): string {
|
||||
return path.join(rootPath, INVESTIGATE_CACHE_REL_DIR, `${key}.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 폴더(또는 단일 파일)를 강제로 읽어 파일별 노트 생성. llmCall 은 주입식(테스트 용이).
|
||||
* 캐시: sha1(내용) → 노트. 내용이 같으면 질문이 달라도 재사용 (질문 비의존 노트).
|
||||
*/
|
||||
export async function runInvestigation(opts: {
|
||||
rootPath: string;
|
||||
relDir: string;
|
||||
llmCall: (system: string, user: string) => Promise<string>;
|
||||
maxFiles?: number;
|
||||
onProgress?: (done: number, total: number, relPath: string) => void;
|
||||
}): Promise<InvestigationResult> {
|
||||
const { files, skipped } = collectInvestigationFiles(opts.rootPath, opts.relDir, opts.maxFiles ?? MAX_INVESTIGATION_FILES);
|
||||
const result: InvestigationResult = { dir: opts.relDir, notes: [], skipped, cacheHits: 0, llmCalls: 0 };
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const f = files[i];
|
||||
let content: string;
|
||||
try { content = fs.readFileSync(f.absPath, 'utf8').slice(0, MAX_CHARS_PER_FILE); }
|
||||
catch { result.skipped.push(f.relPath + ' (읽기 실패)'); continue; }
|
||||
if (!content.trim()) { result.skipped.push(f.relPath + ' (빈 파일)'); continue; }
|
||||
|
||||
const key = cacheKey(content);
|
||||
const cacheFile = cachePathFor(opts.rootPath, key);
|
||||
let note: string | null = null;
|
||||
let fromCache = false;
|
||||
try {
|
||||
if (fs.existsSync(cacheFile)) {
|
||||
const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
|
||||
if (cached && typeof cached.note === 'string' && cached.note.trim()) {
|
||||
note = cached.note;
|
||||
fromCache = true;
|
||||
result.cacheHits++;
|
||||
}
|
||||
}
|
||||
} catch { /* 캐시 손상 → 재추출 */ }
|
||||
|
||||
if (note === null) {
|
||||
try {
|
||||
note = (await opts.llmCall(EXTRACT_SYSTEM, `[파일] ${f.relPath}\n\n${content}`)).trim();
|
||||
result.llmCalls++;
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
|
||||
fs.writeFileSync(cacheFile, JSON.stringify({ relPath: f.relPath, note, ts: new Date().toISOString() }, null, 2), 'utf8');
|
||||
} catch { /* 캐시 저장 실패는 무해 */ }
|
||||
} catch (e: any) {
|
||||
result.skipped.push(f.relPath + ` (추출 실패: ${e?.message ?? e})`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
result.notes.push({ relPath: f.relPath, note, fromCache });
|
||||
opts.onProgress?.(i + 1, files.length, f.relPath);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 병합 노트 — 액션이 chatHistory 에 internal system 메시지로 주입하는 본문. */
|
||||
export function formatInvestigationNotes(result: InvestigationResult, focus?: string): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`[Result of investigate_files "${result.dir}"] — 아래는 각 파일을 *실제로 읽고* 추출한 노트다 (${result.notes.length}개 파일, 캐시 재사용 ${result.cacheHits}건).`);
|
||||
if (focus?.trim()) lines.push(`조사 목적: ${focus.trim()}`);
|
||||
lines.push('이 노트를 근거로 답하되, 각 주장 끝에 근거 파일 경로를 붙여라. 노트에 없는 내용은 "노트에 근거 없음"으로 표기.');
|
||||
lines.push('');
|
||||
for (const n of result.notes) {
|
||||
lines.push(`── [${n.relPath}]`);
|
||||
lines.push(n.note);
|
||||
lines.push('');
|
||||
}
|
||||
if (result.skipped.length) {
|
||||
lines.push(`(미확인 파일 ${result.skipped.length}건 — 필요 시 <read_file> 로 개별 확인: ${result.skipped.slice(0, 10).join(', ')}${result.skipped.length > 10 ? ' 외' : ''})`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ── Hollow Investigation 감지 (postAnswerHook 용 순수 함수) ──────────────────
|
||||
|
||||
export interface TurnActionStats { reads: number; lists: number; investigates: number }
|
||||
|
||||
const FILE_MENTION_RE = /[\w가-힣.\-]{2,}\.(?:md|txt|ts|tsx|js|jsx|json|html|css|py|pdf|docx?|xlsx?|pptx?|ya?ml|csv)\b/gi;
|
||||
|
||||
/**
|
||||
* "목록만 보고(list) 읽지는 않았는데(read/investigate 0) 파일들을 서술" = 헛조사.
|
||||
* 파일명 언급 2개 이상일 때만 (1개는 단순 지칭일 수 있음 — 오탐 방지).
|
||||
*/
|
||||
export function detectHollowInvestigation(
|
||||
answer: string,
|
||||
stats: TurnActionStats | null | undefined,
|
||||
): { hollow: boolean; fileMentions: string[] } {
|
||||
if (!stats || stats.lists === 0) return { hollow: false, fileMentions: [] };
|
||||
if (stats.reads > 0 || stats.investigates > 0) return { hollow: false, fileMentions: [] };
|
||||
const mentions = [...new Set(Array.from((answer || '').matchAll(FILE_MENTION_RE), m => m[0]))];
|
||||
return { hollow: mentions.length >= 2, fileMentions: mentions.slice(0, 8) };
|
||||
}
|
||||
|
||||
export function formatHollowInvestigationFooter(fileMentions: string[]): string {
|
||||
return [
|
||||
'',
|
||||
'---',
|
||||
`⚠️ **헛조사 감지** — 이 답변은 파일 목록만 조회하고 실제 내용은 읽지 않은 채 파일 ${fileMentions.length}건(${fileMentions.slice(0, 4).join(', ')}${fileMentions.length > 4 ? ' 외' : ''})을 서술했습니다.`,
|
||||
'내용 검증이 필요하면 다시 요청해 주세요 — 다음 턴에서 `<investigate_files>`로 각 파일을 실제로 읽고 답합니다.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* [v2.2.312] 헛조사의 반대 방향 실패 — 파일을 *읽고도* 답변에 파일 근거를 하나도
|
||||
* 인용하지 않은 "일반론 분석" 감지. 실사례: 프로젝트 분석 보고가 7줄짜리
|
||||
* 프로젝트명만 바꿔도 성립하는 총평("~가 인상적입니다")으로 끝남.
|
||||
* 조건: read/investigate 를 1회 이상 했고, 답변이 실질 분량(300자+)인데
|
||||
* 파일 참조가 2개 미만 → ungrounded. (짧은 확인형 응답은 오탐 방지로 제외.)
|
||||
*/
|
||||
export function detectUngroundedAnalysis(
|
||||
answer: string,
|
||||
stats: TurnActionStats | null | undefined,
|
||||
): { ungrounded: boolean; readCount: number; fileRefs: number } {
|
||||
const readCount = (stats?.reads ?? 0) + (stats?.investigates ?? 0);
|
||||
if (!stats || readCount === 0) return { ungrounded: false, readCount, fileRefs: 0 };
|
||||
const body = (answer || '').trim();
|
||||
if (body.length < 300) return { ungrounded: false, readCount, fileRefs: 0 };
|
||||
const refs = new Set(Array.from(body.matchAll(FILE_MENTION_RE), m => m[0]));
|
||||
return { ungrounded: refs.size < 2, readCount, fileRefs: refs.size };
|
||||
}
|
||||
|
||||
export function formatUngroundedAnalysisFooter(readCount: number): string {
|
||||
return [
|
||||
'',
|
||||
'---',
|
||||
`⚠️ **근거 인용 없음** — 파일을 ${readCount}회 읽고도 답변에 실제 파일·코드 근거가 인용되지 않았습니다. 위 내용은 일반론일 수 있습니다.`,
|
||||
'구체 근거가 필요하면 "실제 읽은 파일명을 인용해서 다시 정리해줘"라고 요청해 주세요.',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -154,6 +154,42 @@ export const DEFAULT_TASK_REQUIREMENTS: TaskRequirement[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// [v2.2.312] 실사례 대응 — "E:\...프로젝트를 분석하고 어떻게 되어 있는지 보고해줘"가
|
||||
// 어떤 유형에도 안 걸려 7줄짜리 일반론 총평으로 끝났다. '보고해줘/현황 보고' 류를
|
||||
// 잡아 파일 근거 인용을 필수 요소로 강제한다.
|
||||
id: 'analysis-report',
|
||||
label: '분석 보고',
|
||||
detectKeywords: ['분석[^\\n]{0,20}보고', '보고해\\s*줘?', '현황\\s*(보고|정리)', '어떻게\\s*되어\\s*있는지', '검토[^\\n]{0,10}보고'],
|
||||
coverageCheck: true,
|
||||
elements: [
|
||||
{
|
||||
id: 'overview', label: '보고 개요',
|
||||
hint: '첫 줄에 "보고 개요: (분석 대상) — (확인한 근거: 실제 읽은 파일 수) — (한 줄 결론)" 형식으로 시작. 요청자가 첫 줄만 읽어도 무엇을 어떤 근거로 판단했는지 알 수 있게.',
|
||||
detectPatterns: ['보고 ?개요', '분석 ?대상', '개요'],
|
||||
},
|
||||
{
|
||||
id: 'file-evidence', label: '파일 근거',
|
||||
hint: '모든 주요 주장에 이번 대화에서 실제로 읽은 파일명·경로를 인용. 프로젝트 이름을 바꿔도 성립하는 일반론("~가 인상적입니다", "~할 가능성이 높습니다")은 금지 — 근거 없는 문장은 쓰지 말 것.',
|
||||
detectPatterns: ['\\.(ts|tsx|js|mjs|cjs|py|md|json|html|css|java|cs)\\b', 'src[\\\\/]', 'package\\.json'],
|
||||
},
|
||||
{
|
||||
id: 'structure', label: '구조·현황',
|
||||
hint: '프로젝트/대상의 실제 구성 — 핵심 모듈, 진행 상태, 확인된 사실. STATUS.md 등 현재 상태 기록이 있으면 그것을 기준으로.',
|
||||
detectPatterns: ['구조', '구성', '모듈', '아키텍처', '현황', '상태'],
|
||||
},
|
||||
{
|
||||
id: 'findings', label: '핵심 발견·이슈',
|
||||
hint: '분석에서 드러난 강점·문제·이슈. 각 항목에 근거 파일 연결.',
|
||||
detectPatterns: ['발견', '강점', '문제', '이슈', '리스크', '과제'],
|
||||
},
|
||||
{
|
||||
id: 'next-steps', label: '다음 단계',
|
||||
hint: '이 보고를 받은 사람이 취할 수 있는 구체적 다음 행동.',
|
||||
detectPatterns: ['다음 ?단계', '권장', '제안', '후속'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'work-research',
|
||||
label: '업무조사',
|
||||
@@ -233,6 +269,9 @@ export function buildRequirementGraphBlock(
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('제출 전 위 체크리스트를 스스로 점검하고, 누락 요소가 있으면 보완 후 답변할 것.');
|
||||
// [v2.2.312] 업무 유형이 감지된 답변은 일반 "최대 3섹션" 규칙보다 필수 요소 커버가
|
||||
// 우선 — 보고서가 3섹션 제한 때문에 얕아지는 상충 제거.
|
||||
lines.push('이 업무 유형이 감지된 답변에서는 "최대 3섹션" 일반 규칙보다 필수 요소 커버가 우선한다 — 요소별로 섹션을 나눠도 된다.');
|
||||
lines.push('[/TASK REQUIREMENTS]');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -27,6 +27,28 @@ const TAG_OPENERS: { literal: string; closer: RegExp }[] = [
|
||||
{ literal: '<analysis>', closer: /<\/analysis>/i },
|
||||
];
|
||||
|
||||
/**
|
||||
* [v2.2.310] LM Studio 합성 추론 구분자 — 일부 모델/버전 조합에서 LM Studio 가
|
||||
* 추론 텍스트를 *여는 마커 없이* content 스트림 맨 앞에 흘리고, 실제 답변과의
|
||||
* 경계에 이 합성 마커를 삽입한다:
|
||||
* `<추론 텍스트>__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_<hex>__<답변>`
|
||||
* 여는 마커가 없어 앞부분을 미리 숨길 수는 없다 — 대신 END 마커를 만나는 순간
|
||||
* `separatorSeen` 신호를 세워, 호출자(스트림 루프)가 streamReplace 로 그때까지
|
||||
* 표시된 추론을 화면에서 걷어내게 한다. START 변형이 오면 일반 opener 처럼 숨긴다.
|
||||
*/
|
||||
const LSEP_CONST_PREFIX = '__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_';
|
||||
const LSEP_END_RE = /__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_[0-9a-f]{6,64}__/i;
|
||||
const LSEP_START_RE = /__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_START_[0-9a-f]{6,64}__/i;
|
||||
|
||||
/** tail 이 LSEP 마커의 접두사가 될 가능성 (emit 보류 판단, '<' 계열과 별도). */
|
||||
function couldBeLsepPrefix(tail: string): boolean {
|
||||
if (!tail.startsWith('_')) return false;
|
||||
if (tail.length <= LSEP_CONST_PREFIX.length) return LSEP_CONST_PREFIX.startsWith(tail);
|
||||
if (!tail.startsWith(LSEP_CONST_PREFIX)) return false;
|
||||
// 상수부 이후: (START|END 일부) + '_' + hex + 닫는 '__' 진행 중이면 보류
|
||||
return /^[A-Z]{0,5}_?[0-9a-f]{0,64}_{0,2}$/i.test(tail.slice(LSEP_CONST_PREFIX.length));
|
||||
}
|
||||
|
||||
/** Harmony 채널 마커 — `<|channel|>` 외에 `<channel|>` / `<|channel>` 변형도 (sanitize 와 동일). */
|
||||
const CHANNEL_MARKER = /<\|?channel\|?>/i;
|
||||
const HIDDEN_CHANNEL_WORD = /^(?:thought|analysis|commentary|reasoning)\b/i;
|
||||
@@ -58,6 +80,15 @@ export class LiveReasoningFilter {
|
||||
private pending = '';
|
||||
/** hiddenTag 모드에서 기다리는 닫힘 패턴. */
|
||||
private closer: RegExp | null = null;
|
||||
/** [v2.2.310] LSEP END 마커 감지 신호 — 호출자가 streamReplace 로 화면 리셋. */
|
||||
private separatorSeen = false;
|
||||
|
||||
/** LSEP 구분자를 만났는지 (1회성 소비 — 읽으면 리셋). */
|
||||
consumeSeparatorSignal(): boolean {
|
||||
const v = this.separatorSeen;
|
||||
this.separatorSeen = false;
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰을 누적하고, 화면에 내보내도 안전한 텍스트 델타를 반환한다.
|
||||
@@ -70,6 +101,29 @@ export class LiveReasoningFilter {
|
||||
// 상태 전이가 한 토큰 안에서 여러 번 일어날 수 있어 (열림+닫힘 동시 도착) 루프.
|
||||
for (;;) {
|
||||
if (this.mode === 'visible') {
|
||||
// [v2.2.310] LSEP 마커 — START 가 END 보다 먼저면 정상 블록(숨김),
|
||||
// END 만 단독 등장(여는 마커 없이 추론이 먼저 흐른 케이스)이면 리셋 신호.
|
||||
const sepStart = this.pending.match(LSEP_START_RE);
|
||||
const sepEnd = this.pending.match(LSEP_END_RE);
|
||||
const startIdx = sepStart?.index ?? -1;
|
||||
const endIdx = sepEnd?.index ?? -1;
|
||||
if (startIdx >= 0 && (endIdx < 0 || startIdx < endIdx)) {
|
||||
// START 변형 — 일반 opener 처럼 END 까지 숨김.
|
||||
out += this.pending.slice(0, startIdx);
|
||||
this.pending = this.pending.slice(startIdx + sepStart![0].length);
|
||||
this.mode = 'hiddenTag';
|
||||
this.closer = LSEP_END_RE;
|
||||
continue;
|
||||
}
|
||||
if (endIdx >= 0) {
|
||||
// 여는 마커 없이 흘러온 추론의 끝 — 이미 화면에 나간 앞부분은 되돌릴
|
||||
// 수 없으니 신호만 세우고(호출자가 streamReplace), 잔여 추론+마커 폐기.
|
||||
this.pending = this.pending.slice(endIdx + sepEnd![0].length);
|
||||
this.separatorSeen = true;
|
||||
out = ''; // 마커 앞 잔여분(추론)은 방출 취소
|
||||
continue;
|
||||
}
|
||||
|
||||
const hit = this.findEarliestOpener(this.pending);
|
||||
if (hit) {
|
||||
out += this.pending.slice(0, hit.index);
|
||||
@@ -127,7 +181,8 @@ export class LiveReasoningFilter {
|
||||
continue;
|
||||
}
|
||||
// 닫힘이 토큰 경계에 걸칠 수 있으니 꼬리만 남기고 버린다.
|
||||
this.pending = this.pending.slice(-24);
|
||||
// (LSEP END 마커는 ~85자라 넉넉히 보존 — v2.2.310)
|
||||
this.pending = this.pending.slice(-100);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -179,6 +234,12 @@ export class LiveReasoningFilter {
|
||||
const tail = s.slice(s.length - k);
|
||||
if (tail.startsWith('<') && couldBeOpenerPrefix(tail)) return k;
|
||||
}
|
||||
// [v2.2.310] LSEP 마커('_' 시작, 최대 ~110자) 접두사 보류 — 토큰 경계 분절 대응.
|
||||
const maxLsep = Math.min(s.length, 110);
|
||||
for (let k = 1; k <= maxLsep; k++) {
|
||||
const tail = s.slice(s.length - k);
|
||||
if (tail.startsWith('_') && couldBeLsepPrefix(tail)) return k;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +140,7 @@ export function buildLocalProjectIntentGuidance(intent: LocalProjectIntent): str
|
||||
'- Do NOT list the file structure tree back to the user — they already see it. Reference files only when making a specific claim.',
|
||||
'- Do NOT use the words "blind spot", "파이프라인 안정화", "골격은 있습니다" — these are tells of the old canned response.',
|
||||
'- If a file preview is insufficient to support a claim, USE <read_file path="..."> immediately to read it before writing the section. Do not hedge with "preview만으로는 판단할 수 없습니다".',
|
||||
'- If STATUS.md exists, read it before forming any claim about current phase/progress. Never describe a project\'s state using only design docs or docs/plans/* — those capture intent at write-time and go stale; STATUS.md (updated per work unit) is the current-state source of truth. If STATUS.md shows a later phase or a different chosen approach than a design doc implies, report the STATUS.md version as current and note that the design doc is superseded.',
|
||||
'- Strengths and weaknesses must be SPECIFIC to this project. A sentence that would still be true if you swapped the project name is not allowed.',
|
||||
'- Skip every section that has nothing concrete to say. Better to write 잘된 점 with 2 strong items than 4 weak ones.',
|
||||
].join('\n');
|
||||
|
||||
@@ -125,6 +125,8 @@ export function listProjectTree(root: string, current: string, depth: number, ma
|
||||
export function findPriorityProjectFiles(root: string): string[] {
|
||||
const exactNames = new Set([
|
||||
'package.json',
|
||||
'STATUS.md',
|
||||
'status.md',
|
||||
'README.md',
|
||||
'readme.md',
|
||||
'tsconfig.json',
|
||||
@@ -173,15 +175,16 @@ export function findPriorityProjectFiles(root: string): string[] {
|
||||
const rank = (file: string) => {
|
||||
const relative = path.relative(root, file);
|
||||
if (path.basename(file) === 'package.json') return 0;
|
||||
if (/readme\.md$/i.test(file)) return 1;
|
||||
if (/^src[\\/]App\.tsx$/i.test(relative)) return 2;
|
||||
if (/^src[\\/]main\.tsx$/i.test(relative)) return 3;
|
||||
if (/^src[\\/]features[\\/]game[\\/]hooks[\\/]useGameEngine\.ts$/i.test(relative)) return 4;
|
||||
if (/^src[\\/]features[\\/]game[\\/]systems[\\/]/i.test(relative)) return 5;
|
||||
if (/^src[\\/]features[\\/]game[\\/]ui[\\/]/i.test(relative)) return 6;
|
||||
if (/^src[\\/]/i.test(relative)) return 7;
|
||||
if (/^docs[\\/]|\.md$/i.test(relative)) return 8;
|
||||
return 9;
|
||||
if (/^status\.md$/i.test(relative)) return 1;
|
||||
if (/readme\.md$/i.test(file)) return 2;
|
||||
if (/^src[\\/]App\.tsx$/i.test(relative)) return 3;
|
||||
if (/^src[\\/]main\.tsx$/i.test(relative)) return 4;
|
||||
if (/^src[\\/]features[\\/]game[\\/]hooks[\\/]useGameEngine\.ts$/i.test(relative)) return 5;
|
||||
if (/^src[\\/]features[\\/]game[\\/]systems[\\/]/i.test(relative)) return 6;
|
||||
if (/^src[\\/]features[\\/]game[\\/]ui[\\/]/i.test(relative)) return 7;
|
||||
if (/^src[\\/]/i.test(relative)) return 8;
|
||||
if (/^docs[\\/]|\.md$/i.test(relative)) return 9;
|
||||
return 10;
|
||||
};
|
||||
return rank(a) - rank(b) || a.localeCompare(b);
|
||||
});
|
||||
@@ -276,6 +279,7 @@ export function buildLocalProjectPathContext(prompt: string, rootPath: string):
|
||||
'Proceed IMMEDIATELY with analysis or with using action tags to gather more context. Do not ask for confirmation like "진행할까요?" or "분석을 시작할까요?". Just do it.',
|
||||
'If multiple files are mentioned, analyze them sequentially in the order the user specified without pausing for confirmation between each.',
|
||||
'The user provided a local project path for review, analysis, documentation, or knowledge creation. Use this inspected context, and if needed use <read_file> to dig deeper before answering.',
|
||||
'If a STATUS.md is present in the priority previews or scanned tree, it is the canonical current-state record for this project (updated per work unit) — read it FIRST and let it anchor your understanding of "current" phase/status. Design docs and plans under docs/ (e.g. dated design specs, docs/plans/*) describe intent at the time they were written and may be stale; if their content conflicts with STATUS.md (different phase, different chosen approach), STATUS.md wins and you should say so explicitly rather than silently presenting the older doc as current.',
|
||||
'If access failed, explain the concrete failure.',
|
||||
'If access succeeded and priority file previews are present, do not say that code was not provided.',
|
||||
'Treat the Local project intent line as the routing decision for this response.',
|
||||
|
||||
@@ -8,7 +8,8 @@ import type { RetrievalOrchestrator } from '../../retrieval';
|
||||
import { buildLessonChecklistBlock } from '../../retrieval/lessonHelpers';
|
||||
import { embedQuery, embedTexts } from '../../retrieval/embeddings';
|
||||
import { backfillBrainEmbeddings, backfillBrainChunkEmbeddings } from '../../retrieval/brainIndex';
|
||||
import { loadWeaknessProfile, buildSelfReviewBlock, registerKnowledgeGap } from '../../intelligence/correctionLoop';
|
||||
import { loadWeaknessProfile, buildSelfReviewBlock, registerKnowledgeGap, loadStandingRules, buildStandingRulesBlock } from '../../intelligence/correctionLoop';
|
||||
import { buildInvestigationGateBlock } from '../../intelligence/investigationPipeline';
|
||||
import { resolveScopeForAgent } from '../../skills/agentKnowledgeMap';
|
||||
import { resolveDomainScope, classifyKnowledgeDomain, buildDomainRoutingHint } from '../../retrieval/domainRouter';
|
||||
import {
|
||||
@@ -165,11 +166,13 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
|
||||
if (domainScope.folders.length > 0) effectiveScopeFolders = domainScope.folders;
|
||||
}
|
||||
|
||||
// Context 윈도우 비례 retrieval 예산. 32K → 8K, 230K → 57K, 80K cap (scoring 속도).
|
||||
const scaledTotalBudget = Math.min(
|
||||
80000,
|
||||
Math.max(8000, Math.floor(config.contextLength * 0.25)),
|
||||
);
|
||||
// Context 윈도우 비례 retrieval 예산.
|
||||
// [v2.2.311] 종전 자동값(창의 25%, 8k~80k)은 32K 창에서 검색 결과에만 8k 토큰을 태워
|
||||
// 로컬 프리필 수십 초의 주범이었다. 자동값을 창의 12%(2.5k~6k 클램프)로 낮추고,
|
||||
// 더 풍부한 근거가 필요하면 g1nation.retrievalTokenBudget 으로 명시 상향.
|
||||
const scaledTotalBudget = config.retrievalTokenBudget > 0
|
||||
? config.retrievalTokenBudget
|
||||
: Math.min(6000, Math.max(2500, Math.floor(config.contextLength * 0.12)));
|
||||
|
||||
// medium-term layer 용 옛 세션 후보. sidebar 가 직접 쓰는 key 를 read-through.
|
||||
const rawSessions = deps.context.globalState.get<any[]>('chat_sessions', []) || [];
|
||||
@@ -418,6 +421,14 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
|
||||
// [Correction Loop ③-c] 약점 프로필 → 자기검토 블록. 최근 정정 통계가 다음 턴의
|
||||
// 행동을 직접 바꾼다 (태그 2회 이상만 — 1회성 실수로 프롬프트를 어지럽히지 않게).
|
||||
const selfReviewBlock = buildSelfReviewBlock(loadWeaknessProfile(deps.activeBrain.localBrainPath));
|
||||
// [Correction Loop ④] Standing Rules — 행동/스타일 교정의 영속 규칙. 레슨(유사도
|
||||
// 검색)과 달리 질문 내용과 무관하게 *매 턴* 주입된다. 세션을 새로 열어도 유지 —
|
||||
// "self-reflection 이 단기 기억에 갇힌다" 문제의 구조적 해결 (v2.2.308).
|
||||
const standingRulesBlock = buildStandingRulesBlock(loadStandingRules(deps.activeBrain.localBrainPath));
|
||||
|
||||
// [조사 증거 게이트 v2.2.309] 로컬 자료 조사형 요청이면 "읽지 않은 파일 서술 금지 +
|
||||
// investigate_files 위임" 제약 주입 — 파일명만 보고 상상하는 헛조사 차단의 1차 방어선.
|
||||
const investigationGateBlock = buildInvestigationGateBlock(deps.currentPrompt);
|
||||
|
||||
// 행동 제약 블록(자기검토·확신도 정책·레슨 체크리스트)은 dynamicBlocks 채널로 —
|
||||
// [CONTEXT] *밖* 보호 구역에 주입되어 context-overflow truncation 에서도 살아남는다.
|
||||
@@ -438,7 +449,7 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
|
||||
'- 첫 문장은 그 목적에 바로 답하는 결론으로 시작하라. 해석이 갈리면 가장 가능성 높은 해석으로 답하되, 어떤 해석으로 답했는지 한 줄로 밝혀라.',
|
||||
].join('\n');
|
||||
|
||||
const constraintBlock = [intentPrinciple, routingHint, selfReviewBlock, groundingBlock, lessonBlock].filter(Boolean).join('\n\n');
|
||||
const constraintBlock = [intentPrinciple, standingRulesBlock, investigationGateBlock, routingHint, selfReviewBlock, groundingBlock, lessonBlock].filter(Boolean).join('\n\n');
|
||||
if (constraintBlock) blocks.set('behavior-constraints', constraintBlock);
|
||||
return memoryBlock;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,16 @@
|
||||
* `<|return|>`) 보수적으로 매칭.
|
||||
*/
|
||||
export function sanitizeAssistantContent(text: string): string {
|
||||
const stripped = text
|
||||
// [v2.2.310] LM Studio 합성 추론 구분자 — 일부 모델 조합에서 추론 텍스트가
|
||||
// *여는 마커 없이* 답변 앞에 흐르고 이 END 마커로만 경계가 표시된다.
|
||||
// 마지막 END 마커 이전은 전부 추론 → 폐기. 남은 마커 잔재도 제거.
|
||||
let t = text;
|
||||
const lsepEnd = /__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_[0-9a-f]{6,64}__/gi;
|
||||
const lsepParts = t.split(lsepEnd);
|
||||
if (lsepParts.length > 1) t = lsepParts[lsepParts.length - 1];
|
||||
t = t.replace(/__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_[A-Z]+_[0-9a-f]{6,64}__/gi, '');
|
||||
|
||||
const stripped = t
|
||||
.replace(/<rationale>[\s\S]*?<\/rationale>/gi, '')
|
||||
.replace(/^\s*\[PROBLEM\][\s\S]*?\[GOAL\][\s\S]*?\[REASONING\][^\n]*(?:\n+|$)/i, '')
|
||||
.replace(/^\s*\[PROBLEM\][\s\S]*?(?:\n\s*\n|$)/i, '')
|
||||
|
||||
@@ -17,6 +17,7 @@ import { tokenize, countConflictIndicators } from './scoring';
|
||||
import { detectLessonKind } from './lessonHelpers';
|
||||
import { splitIntoSections } from './chunker';
|
||||
import { logInfo } from '../utils';
|
||||
import { ensureBrainWatcher, getBrainGeneration } from './brainWatch';
|
||||
|
||||
// v5 adds optional per-file `chunks` (section-level index, Phase 1-가) alongside the
|
||||
// v4 per-file `embedding`. Older indexes are auto-rebuilt on first load — no migration
|
||||
@@ -109,6 +110,16 @@ interface BrainState {
|
||||
diskPath: string | null; // null if we can't determine a writable path
|
||||
writeTimer?: ReturnType<typeof setTimeout>;
|
||||
evictTimer?: ReturnType<typeof setTimeout>;
|
||||
/**
|
||||
* [v2.2.311] statSync 생략 최적화의 장부. statValidatedGen 세대에서 실제로
|
||||
* stat(또는 재색인)을 거친 파일만 statValidated 에 들어간다 — 세대가 그대로면
|
||||
* 그 파일들은 재-stat 없이 캐시를 신뢰한다. 세대가 바뀌면(파일시스템 변경)
|
||||
* 집합을 비워 전 파일이 다시 한 번 stat 을 거친다. 파일 *단위* 로 기록하는
|
||||
* 이유: 스코프 검색(부분 목록) 뒤 전체 검색이 와도, 검증 안 된 파일이
|
||||
* 신뢰 경로로 새지 않게 하기 위함.
|
||||
*/
|
||||
statValidatedGen?: number;
|
||||
statValidated?: Set<string>;
|
||||
}
|
||||
|
||||
const _states = new Map<string, BrainState>();
|
||||
@@ -117,7 +128,16 @@ const _states = new Map<string, BrainState>();
|
||||
// 확장 호스트 메모리에 세션 내내 상주했다. 인덱스는 mtime 키 디스크 캐시에서 언제든 싸게
|
||||
// 재로드되므로, 마지막 사용 후 IDLE_EVICT_MS 가 지나면 dirty 를 flush 하고 메모리에서 내린다.
|
||||
// 상시 인터벌 없이 접근할 때마다 재예약되는 one-shot unref 타이머라 그 자체의 발열 비용은 없다.
|
||||
const IDLE_EVICT_MS = 30 * 60_000;
|
||||
// [v2.2.311] 30분 → 2시간: 점심/회의 뒤 첫 질의가 대형 인덱스(수십 MB JSON) 재파싱을
|
||||
// 뒤집어쓰는 빈도를 줄인다. 메모리 절약보다 응답 지연이 더 아픈 로컬-퍼스트 워크로드.
|
||||
const IDLE_EVICT_MS = 120 * 60_000;
|
||||
|
||||
/**
|
||||
* [v2.2.311] stat 생략 신뢰 명부에 올리지 않는 "갓 수정된 파일" 창. fs.watch 이벤트는
|
||||
* 비동기(+디바운스)라, 수정 직후 같은 틱에 들어온 질의는 세대 bump 를 아직 못 본다 —
|
||||
* 그 창 안의 파일은 명부에서 제외해 다음 호출에서도 stat 을 거치게 한다.
|
||||
*/
|
||||
const RECENT_MTIME_GUARD_MS = 3000;
|
||||
|
||||
function touchEviction(st: BrainState, brainPath: string): void {
|
||||
if (st.evictTimer) clearTimeout(st.evictTimer);
|
||||
@@ -206,8 +226,35 @@ export function getBrainTokenIndex(brainPath: string, files: string[]): IndexedB
|
||||
const seen = new Set<string>();
|
||||
let reindexed = 0;
|
||||
|
||||
// [v2.2.311] 워처 세대가 마지막 stat 검증 이후 그대로면 — 파일시스템 변경 없음 —
|
||||
// 그 세대에서 이미 stat 을 거친 파일의 statSync 를 생략하고 캐시 항목을 신뢰.
|
||||
// 워처가 없으면(gen === -1) 종전대로 매번 stat — 정확성 저하 없음.
|
||||
ensureBrainWatcher(brainPath);
|
||||
const watchGen = getBrainGeneration(brainPath);
|
||||
if (watchGen >= 0 && st.statValidatedGen !== watchGen) {
|
||||
st.statValidatedGen = watchGen;
|
||||
st.statValidated = new Set();
|
||||
}
|
||||
const validated = watchGen >= 0 ? (st.statValidated ?? (st.statValidated = new Set())) : null;
|
||||
|
||||
for (const file of files) {
|
||||
seen.add(file);
|
||||
const cachedFast = validated && validated.has(file) ? st.index.entries[file] : undefined;
|
||||
if (cachedFast
|
||||
&& Array.isArray(cachedFast.tokens)
|
||||
&& Array.isArray(cachedFast.titleTokens)) {
|
||||
out.push({
|
||||
filePath: file,
|
||||
relativePath: cachedFast.relativePath,
|
||||
title: cachedFast.title,
|
||||
tokens: cachedFast.tokens,
|
||||
titleTokens: cachedFast.titleTokens,
|
||||
conflictCount: cachedFast.conflictCount || 0,
|
||||
mtimeMs: cachedFast.mtimeMs,
|
||||
kind: cachedFast.kind || '',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(file);
|
||||
@@ -220,6 +267,7 @@ export function getBrainTokenIndex(brainPath: string, files: string[]): IndexedB
|
||||
&& cached.size === stat.size
|
||||
&& Array.isArray(cached.tokens)
|
||||
&& Array.isArray(cached.titleTokens)) {
|
||||
if (Date.now() - stat.mtimeMs > RECENT_MTIME_GUARD_MS) validated?.add(file);
|
||||
out.push({
|
||||
filePath: file,
|
||||
relativePath: cached.relativePath,
|
||||
@@ -255,6 +303,7 @@ export function getBrainTokenIndex(brainPath: string, files: string[]): IndexedB
|
||||
st.index.entries[file] = entry;
|
||||
st.dirty = true;
|
||||
reindexed++;
|
||||
if (Date.now() - entry.mtimeMs > RECENT_MTIME_GUARD_MS) validated?.add(file);
|
||||
out.push({
|
||||
filePath: file,
|
||||
relativePath,
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* ============================================================
|
||||
* Brain Watch — 제2뇌 파일시스템 워처 + 상주 파일 목록 캐시 (v2.2.311)
|
||||
*
|
||||
* 문제: 검색은 이미 mtime 키 토큰 인덱스(brain-index.json)로 파일 *내용* 재파싱을
|
||||
* 피하지만, 매 질의마다 (a) 전체 디렉터리 재귀 워크(수천 파일)와 (b) 파일별
|
||||
* fs.statSync 수천 회를 다시 수행했다 — 인덱스가 신선한지 확인할 방법이 "전부
|
||||
* 다시 stat" 뿐이었기 때문. 브레인 6,500 파일 기준 턴당 0.5~1.5초.
|
||||
*
|
||||
* 해결: 브레인 루트에 재귀 fs.watch 를 하나 걸고 변경 시 세대(generation) 카운터만
|
||||
* 올린다. 세대가 그대로면 — 파일 목록도, mtime 도 안 변한 것 — 워크/stat 을 통째로
|
||||
* 생략하고 메모리의 목록·인덱스를 그대로 신뢰한다. 이것이 "제2뇌를 항상 메모리에
|
||||
* 올려놓기"의 안전한 형태다: 원본 파일을 통째로 상주시키는 대신(수백 MB), 검색에
|
||||
* 실제로 쓰는 파생물(토큰 인덱스 + 파일 목록)을 상주시키고 워처가 무효화를 맡는다.
|
||||
*
|
||||
* 워처를 못 여는 환경(권한/네트워크 드라이브 등)에서는 generation 이 -1 로 고정되고
|
||||
* 모든 호출자는 종전 동작(매번 stat)으로 폴백한다 — 정확성은 워처 가용성과 무관.
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { findBrainFiles, invalidateBrainFilesCache, logInfo } from '../utils';
|
||||
|
||||
interface WatchState {
|
||||
watcher: fs.FSWatcher | null;
|
||||
/** 파일시스템 변경마다 증가. 워처 없으면 상태 자체가 failed=true. */
|
||||
generation: number;
|
||||
failed: boolean;
|
||||
/** generation 기준 파일 목록 캐시. */
|
||||
listGeneration: number;
|
||||
list: string[] | null;
|
||||
/** 이벤트 폭주(대량 sync) 시 bump 를 묶는 디바운스 타이머. */
|
||||
bumpTimer: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
|
||||
const _watches = new Map<string, WatchState>();
|
||||
|
||||
/** 인덱스 캐시 등 우리 자신의 산출물 경로 — 이 아래 변경은 무효화 사유가 아니다. */
|
||||
function isSelfNoise(relPath: string): boolean {
|
||||
// .astra (brain-index.json, eval, growth …) 와 .git 은 검색 대상도 아니고
|
||||
// 턴마다 우리가 직접 쓰는 곳이라, 여기서 세대를 올리면 캐시가 영원히 못 산다.
|
||||
return /(^|[\\/])(\.astra|\.git)([\\/]|$)/.test(relPath);
|
||||
}
|
||||
|
||||
function bump(st: WatchState, brainPath: string): void {
|
||||
if (st.bumpTimer) return; // 이미 예약됨 — 몰아서 1회
|
||||
st.bumpTimer = setTimeout(() => {
|
||||
st.bumpTimer = null;
|
||||
st.generation++;
|
||||
st.list = null;
|
||||
invalidateBrainFilesCache(brainPath);
|
||||
}, 300);
|
||||
if (typeof (st.bumpTimer as any).unref === 'function') (st.bumpTimer as any).unref();
|
||||
}
|
||||
|
||||
/**
|
||||
* 브레인 루트에 워처를 보장한다 (idempotent). 실패해도 throw 하지 않는다 —
|
||||
* 이후 getBrainGeneration() 이 -1 을 반환해 호출자들이 stat 경로로 폴백.
|
||||
*/
|
||||
export function ensureBrainWatcher(brainPath: string): void {
|
||||
if (!brainPath) return;
|
||||
if (_watches.has(brainPath)) return;
|
||||
const st: WatchState = { watcher: null, generation: 0, failed: false, listGeneration: -1, list: null, bumpTimer: null };
|
||||
_watches.set(brainPath, st);
|
||||
try {
|
||||
// recursive fs.watch 는 Windows/macOS 에서 네이티브 지원. (Linux 는 Node 20+.)
|
||||
st.watcher = fs.watch(brainPath, { recursive: true }, (_event, filename) => {
|
||||
const rel = filename ? String(filename) : '';
|
||||
if (rel && isSelfNoise(rel)) return;
|
||||
bump(st, brainPath);
|
||||
});
|
||||
st.watcher.on('error', () => {
|
||||
st.failed = true;
|
||||
try { st.watcher?.close(); } catch { /* already closed */ }
|
||||
st.watcher = null;
|
||||
logInfo('Brain watcher failed — falling back to per-query stat.', { brainPath });
|
||||
});
|
||||
logInfo('Brain watcher started — file list & stat checks now cached until change.', { brainPath });
|
||||
} catch (e: any) {
|
||||
st.failed = true;
|
||||
st.watcher = null;
|
||||
logInfo('Brain watcher unavailable — falling back to per-query stat.', { brainPath, error: e?.message ?? String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 세대 번호. 워처가 없거나 죽었으면 -1 — 호출자는 캐시 신뢰 최적화를 꺼야 한다.
|
||||
*/
|
||||
export function getBrainGeneration(brainPath: string): number {
|
||||
const st = _watches.get(brainPath);
|
||||
if (!st || st.failed || !st.watcher) return -1;
|
||||
return st.generation;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세대 기반 파일 목록 — 세대가 그대로면 디렉터리 워크 없이 메모리 목록을 반환.
|
||||
* 워처가 없으면 findBrainFiles(5초 TTL)로 폴백해 종전과 동일하게 동작.
|
||||
*/
|
||||
export function listBrainFilesCached(brainPath: string): string[] {
|
||||
if (!brainPath) return [];
|
||||
ensureBrainWatcher(brainPath);
|
||||
const st = _watches.get(brainPath)!;
|
||||
if (st.failed || !st.watcher) return findBrainFiles(brainPath);
|
||||
if (st.list !== null && st.listGeneration === st.generation) return st.list.slice();
|
||||
const files = findBrainFiles(brainPath);
|
||||
st.list = files.slice();
|
||||
st.listGeneration = st.generation;
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성화 직후 백그라운드 워밍 — 첫 질의가 78MB 인덱스 파싱 + 전체 워크를
|
||||
* 뒤집어쓰지 않도록 미리 올려 둔다. 실패는 조용히 무시 (다음 질의가 정상 경로).
|
||||
*/
|
||||
export function warmBrainCache(brainPath: string, warmTokenIndex: (files: string[]) => void): void {
|
||||
if (!brainPath) return;
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const t0 = Date.now();
|
||||
const files = listBrainFilesCached(brainPath);
|
||||
warmTokenIndex(files);
|
||||
logInfo('Brain cache warmed.', { brainPath, files: files.length, ms: Date.now() - t0 });
|
||||
} catch (e: any) {
|
||||
logInfo('Brain cache warm failed (non-fatal).', { brainPath, error: e?.message ?? String(e) });
|
||||
}
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
/** 테스트/브레인 전환용 — 워처 해제. */
|
||||
export function disposeBrainWatcher(brainPath?: string): void {
|
||||
const targets = brainPath ? [brainPath] : Array.from(_watches.keys());
|
||||
for (const key of targets) {
|
||||
const st = _watches.get(key);
|
||||
if (!st) continue;
|
||||
if (st.bumpTimer) { clearTimeout(st.bumpTimer); st.bumpTimer = null; }
|
||||
try { st.watcher?.close(); } catch { /* noop */ }
|
||||
_watches.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,8 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { BrainProfile } from '../config';
|
||||
import { findBrainFiles, summarizeText } from '../utils';
|
||||
import { summarizeText } from '../utils';
|
||||
import { listBrainFilesCached } from './brainWatch';
|
||||
import { isInside } from '../lib/paths';
|
||||
import { MemoryManager } from '../memory';
|
||||
import { RetrievalChunk, RetrievalResult, ContextBudgetConfig } from './types';
|
||||
@@ -329,7 +330,8 @@ export class RetrievalOrchestrator {
|
||||
try {
|
||||
const scoped = (file: string) => scopeFolders.length === 0
|
||||
|| scopeFolders.some((folder) => isInside(folder, file));
|
||||
const allFiles = findBrainFiles(brain.localBrainPath)
|
||||
// [v2.2.311] 워처 세대 기반 상주 목록 — 변경 없으면 디렉터리 재귀 워크 생략.
|
||||
const allFiles = listBrainFilesCached(brain.localBrainPath)
|
||||
.filter(scoped)
|
||||
.filter((file) => {
|
||||
const rel = path.relative(brain.localBrainPath, file);
|
||||
|
||||
+27
-5
@@ -494,14 +494,19 @@ function getEnvironmentBlock(): string {
|
||||
- Canonical <run_command> example for this OS: ${example}`;
|
||||
}
|
||||
|
||||
export function getSystemPrompt(): string {
|
||||
const now = new Date();
|
||||
const dateTimeStr = now.toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'long', hour: '2-digit', minute: '2-digit' });
|
||||
const isoDate = now.toISOString().split('T')[0];
|
||||
/**
|
||||
* [KV 캐시 분리 v2.2.311] 시스템 프롬프트의 *턴 사이에 변하지 않는* 부분만 반환.
|
||||
* 여기에 분 단위 시각 같은 가변 요소가 섞이면 LM Studio(llama.cpp)의 prompt cache 가
|
||||
* 요청 첫 토큰부터 무효화되어 매 턴 1만+ 토큰 전체를 다시 프리필한다 — 실측 94초/턴의
|
||||
* 주범. 날짜/시각은 `getDateTimeContextBlock()` 으로 분리해 요청 후미(동적 컨텍스트
|
||||
* 메시지)에 싣는다.
|
||||
*/
|
||||
export function getStaticSystemPrompt(): string {
|
||||
const envBlock = getEnvironmentBlock();
|
||||
const base = `${BASE_SYSTEM_PROMPT}\n\n[CURRENT DATE/TIME]\nToday: ${isoDate} (${dateTimeStr})\nUse this date as the absolute reference for any date-related calculations (e.g., "this week", "today", "yesterday").\n\n[출력 위생 규칙 — 반드시 준수]\n- 자연스러운 한국어로 작성하고, 한 단어 안에 한글과 영문 알파벳을 섞지 마시오 ("결ently", "인orp" 같은 깨진 합성 표기 절대 금지).\n- 외래어·기술 용어는 완전한 한글 표기 또는 완전한 영문 단어 중 하나로 일관되게 쓰시오.\n- 내부 검증·체크 로그(Consistency/Completeness/Accuracy 등) 블록을 사용자 출력에 포함하지 마시오.${envBlock}`;
|
||||
const base = `${BASE_SYSTEM_PROMPT}\n\n[출력 위생 규칙 — 반드시 준수]\n- 자연스러운 한국어로 작성하고, 한 단어 안에 한글과 영문 알파벳을 섞지 마시오 ("결ently", "인orp" 같은 깨진 합성 표기 절대 금지).\n- 외래어·기술 용어는 완전한 한글 표기 또는 완전한 영문 단어 중 하나로 일관되게 쓰시오.\n- 내부 검증·체크 로그(Consistency/Completeness/Accuracy 등) 블록을 사용자 출력에 포함하지 마시오.${envBlock}`;
|
||||
// Self-Reflector Phase A — 사용자 설정이 켜져 있으면 답변 끝에 자기검증
|
||||
// 블록을 강제하는 룰을 prepend. require로 동적 로드해 순환 import 회피.
|
||||
// (설정은 세션 중 바뀔 수 있지만 드물다 — 바뀌면 그 턴만 캐시 미스, 문제 없음.)
|
||||
try {
|
||||
const { getConfig } = require('./config') as typeof import('./config');
|
||||
const { appendSelfReflectorRule } = require('./features/selfReflector/selfReflectorPrompt') as typeof import('./features/selfReflector/selfReflectorPrompt');
|
||||
@@ -513,4 +518,21 @@ export function getSystemPrompt(): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** [CURRENT DATE/TIME] 블록 — 가변이므로 정적 프롬프트가 아니라 동적 컨텍스트에 주입. */
|
||||
export function getDateTimeContextBlock(): string {
|
||||
const now = new Date();
|
||||
const dateTimeStr = now.toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'long', hour: '2-digit', minute: '2-digit' });
|
||||
const isoDate = now.toISOString().split('T')[0];
|
||||
return `[CURRENT DATE/TIME]\nToday: ${isoDate} (${dateTimeStr})\nUse this date as the absolute reference for any date-related calculations (e.g., "this week", "today", "yesterday").`;
|
||||
}
|
||||
|
||||
export function getSystemPrompt(): string {
|
||||
// 기존 호출부 호환 — 정적 본문 + 날짜 블록을 합친 종전 형태 그대로.
|
||||
const staticPart = getStaticSystemPrompt();
|
||||
const dateBlock = getDateTimeContextBlock();
|
||||
// BASE 직후·위생 규칙 앞이던 종전 위치와 달리 끝에 붙지만, 단독 사용처(브리핑·평가
|
||||
// 등 1회성 호출)에는 위치가 의미 없다. 채팅 본류는 split 경로를 쓴다.
|
||||
return `${staticPart}\n\n${dateBlock}`;
|
||||
}
|
||||
|
||||
export const SYSTEM_PROMPT = BASE_SYSTEM_PROMPT;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* [v2.2.311] 제2뇌 상주 캐시 계층 — 워처 세대 기반 stat 생략 + 파일 목록 캐시.
|
||||
*
|
||||
* 핵심 계약:
|
||||
* 1. 워처 세대가 그대로면 getBrainTokenIndex 는 파일별 statSync 를 생략한다.
|
||||
* 2. 세대가 바뀌면(파일시스템 변경) 다시 stat 을 거쳐 변경분을 재색인한다.
|
||||
* 3. 부분(스코프) 목록으로 검증된 상태에서 전체 목록 질의가 와도, 검증 안 된
|
||||
* 파일은 신뢰 경로로 새지 않는다 (파일 단위 검증 장부).
|
||||
* 4. 워처 불가 환경(gen === -1)에서는 종전과 동일하게 매번 stat.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
// brainWatch 를 결정적으로 제어 — 실제 fs.watch 이벤트 타이밍에 의존하지 않는다.
|
||||
let mockGen = 0;
|
||||
jest.mock('../src/retrieval/brainWatch', () => ({
|
||||
ensureBrainWatcher: jest.fn(),
|
||||
getBrainGeneration: jest.fn(() => mockGen),
|
||||
listBrainFilesCached: jest.fn(),
|
||||
warmBrainCache: jest.fn(),
|
||||
disposeBrainWatcher: jest.fn(),
|
||||
}));
|
||||
|
||||
import { getBrainTokenIndex } from '../src/retrieval/brainIndex';
|
||||
|
||||
function makeBrain(files: Record<string, string>): { root: string; paths: string[] } {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'astra-brain-'));
|
||||
const paths: string[] = [];
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
const p = path.join(root, name);
|
||||
fs.writeFileSync(p, content, 'utf8');
|
||||
// RECENT_MTIME_GUARD(3초): 갓 수정된 파일은 신뢰 명부에 오르지 않으므로,
|
||||
// "변경 없음" 시나리오를 테스트하려면 mtime 을 과거로 되돌린다.
|
||||
const past = new Date(Date.now() - 60_000);
|
||||
fs.utimesSync(p, past, past);
|
||||
paths.push(p);
|
||||
}
|
||||
return { root, paths };
|
||||
}
|
||||
|
||||
function countStatsFor(paths: string[], spy: jest.SpyInstance): number {
|
||||
return spy.mock.calls.filter((c) => paths.includes(String(c[0]))).length;
|
||||
}
|
||||
|
||||
describe('getBrainTokenIndex — 워처 세대 기반 stat 생략', () => {
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
test('같은 세대의 2번째 호출은 statSync 0회 (캐시 신뢰)', () => {
|
||||
mockGen = 0;
|
||||
const { root, paths } = makeBrain({ 'a.md': '알파 내용', 'b.md': '베타 내용' });
|
||||
expect(getBrainTokenIndex(root, paths)).toHaveLength(2);
|
||||
|
||||
const spy = jest.spyOn(fs, 'statSync');
|
||||
const r2 = getBrainTokenIndex(root, paths);
|
||||
expect(r2).toHaveLength(2);
|
||||
expect(countStatsFor(paths, spy)).toBe(0);
|
||||
expect(r2.map((d) => d.title).sort()).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
test('세대가 바뀌면 재-stat 후 변경 내용을 재색인한다', () => {
|
||||
mockGen = 0;
|
||||
const { root, paths } = makeBrain({ 'a.md': '원본 토큰들' });
|
||||
getBrainTokenIndex(root, paths);
|
||||
|
||||
// 파일 변경 + 세대 bump (mtime 차이를 보장하기 위해 utimesSync 로 강제)
|
||||
fs.writeFileSync(paths[0], '완전히 새로운 내용입니다', 'utf8');
|
||||
const future = new Date(Date.now() + 5000);
|
||||
fs.utimesSync(paths[0], future, future);
|
||||
mockGen = 1;
|
||||
|
||||
const spy = jest.spyOn(fs, 'statSync');
|
||||
const r = getBrainTokenIndex(root, paths);
|
||||
expect(countStatsFor(paths, spy)).toBeGreaterThan(0);
|
||||
expect(r[0].tokens).toEqual(expect.arrayContaining(['새로운']));
|
||||
});
|
||||
|
||||
test('스코프 검증 후 전체 질의 — 미검증 파일은 신뢰 경로로 새지 않는다', () => {
|
||||
mockGen = 0;
|
||||
const { root, paths } = makeBrain({ 'in-scope.md': '스코프 안', 'out-scope.md': '스코프 밖' });
|
||||
const [inScope, outScope] = paths;
|
||||
getBrainTokenIndex(root, [inScope]); // 부분 목록만 검증됨
|
||||
|
||||
const spy = jest.spyOn(fs, 'statSync');
|
||||
getBrainTokenIndex(root, paths); // 전체 질의
|
||||
expect(countStatsFor([inScope], spy)).toBe(0); // 검증된 파일은 생략
|
||||
expect(countStatsFor([outScope], spy)).toBeGreaterThan(0); // 미검증 파일은 stat
|
||||
});
|
||||
|
||||
test('워처 불가(gen=-1) 환경은 매번 stat (종전 동작)', () => {
|
||||
mockGen = -1;
|
||||
const { root, paths } = makeBrain({ 'a.md': '내용' });
|
||||
getBrainTokenIndex(root, paths);
|
||||
const spy = jest.spyOn(fs, 'statSync');
|
||||
getBrainTokenIndex(root, paths);
|
||||
expect(countStatsFor(paths, spy)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -56,3 +56,92 @@ describe('computeBudgetedRequest — real-window alignment', () => {
|
||||
expect(run({ actualContextLength: NaN }).effectiveContextLength).toBe(32768);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// [v2.2.311] KV 캐시 분리 — dynamicContextTail 삽입 계약.
|
||||
// message[0] 은 불변 정적 프롬프트로 남고, 동적 컨텍스트는 마지막 user 메시지
|
||||
// 직전의 internal system 메시지로 들어가야 프롬프트 프리픽스 캐시가 산다.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('computeBudgetedRequest — dynamicContextTail (KV cache split)', () => {
|
||||
const HEAD = 'STATIC HEAD PROMPT';
|
||||
const TAIL = '[CURRENT DATE/TIME]\nToday: 2026-07-20\n\n[CONTEXT]\nRAG chunk A\n[/CONTEXT]';
|
||||
|
||||
// 주의: default parameter 는 명시적 undefined 에도 적용되므로 쓰지 않는다 —
|
||||
// "tail 없음" 케이스를 정확히 표현하기 위해 항상 명시적으로 넘긴다.
|
||||
function runSplit(reqMessages: ChatMessage[], tail: string | undefined) {
|
||||
return computeBudgetedRequest({
|
||||
fullSystemPrompt: HEAD,
|
||||
dynamicContextTail: tail,
|
||||
reqMessages,
|
||||
actualModel: 'some-13b-model',
|
||||
config: baseConfig,
|
||||
imageCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
test('tail 은 마지막 user 메시지 직전에 internal system 으로 삽입된다', () => {
|
||||
const msgs: ChatMessage[] = [
|
||||
{ role: 'user', content: '첫 질문' },
|
||||
{ role: 'assistant', content: '첫 답' },
|
||||
{ role: 'user', content: '두번째 질문' },
|
||||
];
|
||||
const r = runSplit(msgs, TAIL);
|
||||
expect(r.messagesForRequest[0]).toMatchObject({ role: 'system', content: HEAD });
|
||||
const idx = r.messagesForRequest.findIndex((m) => m.content === TAIL);
|
||||
expect(idx).toBeGreaterThan(0);
|
||||
expect(r.messagesForRequest[idx].role).toBe('system');
|
||||
expect(r.messagesForRequest[idx + 1]).toMatchObject({ role: 'user', content: '두번째 질문' });
|
||||
});
|
||||
|
||||
test('continuation(마지막 user 뒤 액션 결과 system)이 있어도 tail 은 user 앞', () => {
|
||||
const msgs: ChatMessage[] = [
|
||||
{ role: 'user', content: '조사해줘' },
|
||||
{ role: 'system', content: '[Result of read_file] ...', internal: true },
|
||||
];
|
||||
const r = runSplit(msgs, TAIL);
|
||||
const tailIdx = r.messagesForRequest.findIndex((m) => m.content === TAIL);
|
||||
const userIdx = r.messagesForRequest.findIndex((m) => m.role === 'user');
|
||||
const actionIdx = r.messagesForRequest.findIndex((m) => String(m.content).startsWith('[Result of'));
|
||||
expect(tailIdx).toBeLessThan(userIdx);
|
||||
expect(actionIdx).toBeGreaterThan(userIdx);
|
||||
});
|
||||
|
||||
test('user 메시지가 없으면 tail 을 히스토리 끝에 붙인다', () => {
|
||||
const msgs: ChatMessage[] = [{ role: 'system', content: 'internal only', internal: true }];
|
||||
const r = runSplit(msgs, TAIL);
|
||||
const last = r.messagesForRequest[r.messagesForRequest.length - 1];
|
||||
expect(last.content).toBe(TAIL);
|
||||
});
|
||||
|
||||
test('tail 미지정이면 종전 단일-시스템 동작 그대로', () => {
|
||||
const msgs: ChatMessage[] = [{ role: 'user', content: 'hello' }];
|
||||
const r = runSplit(msgs, undefined);
|
||||
expect(r.messagesForRequest).toHaveLength(2);
|
||||
expect(r.messagesForRequest[0].content).toBe(HEAD);
|
||||
});
|
||||
|
||||
test('예산 초과 시 [CONTEXT] truncation 은 tail 에 적용되고 head 는 보존', () => {
|
||||
const hugeTail = `[CONTEXT]\n${'긴 배경 지식 청크. '.repeat(20000)}\n[/CONTEXT]`;
|
||||
const msgs: ChatMessage[] = [{ role: 'user', content: 'hello' }];
|
||||
const r = computeBudgetedRequest({
|
||||
fullSystemPrompt: HEAD,
|
||||
dynamicContextTail: hugeTail,
|
||||
reqMessages: msgs,
|
||||
actualModel: 'some-13b-model',
|
||||
config: { ...baseConfig, contextLength: 8192 },
|
||||
imageCount: 0,
|
||||
});
|
||||
expect(r.systemTruncated).toBe(true);
|
||||
expect(r.messagesForRequest[0].content).toBe(HEAD);
|
||||
const tailMsg = r.messagesForRequest.find((m) => String(m.content).includes('[CONTEXT]'));
|
||||
expect(tailMsg).toBeDefined();
|
||||
expect(String(tailMsg!.content).length).toBeLessThan(hugeTail.length);
|
||||
});
|
||||
|
||||
test('systemTokens 는 head+tail 합산으로 보고된다', () => {
|
||||
const msgs: ChatMessage[] = [{ role: 'user', content: 'hello' }];
|
||||
const withTail = runSplit(msgs, TAIL);
|
||||
const withoutTail = runSplit(msgs, undefined);
|
||||
expect(withTail.systemTokens).toBeGreaterThan(withoutTail.systemTokens);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Investigation Pipeline — 파일명만 보고 상상하는 헛조사 차단 3중 방어. (v2.2.309)
|
||||
* ① 조사형 요청 감지 + 증거 게이트 ② 강제 정독 Map + 해시 캐시 ③ Hollow 감지
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
isLocalInvestigationPrompt,
|
||||
buildInvestigationGateBlock,
|
||||
collectInvestigationFiles,
|
||||
runInvestigation,
|
||||
formatInvestigationNotes,
|
||||
detectHollowInvestigation,
|
||||
formatHollowInvestigationFooter,
|
||||
INVESTIGATE_CACHE_REL_DIR,
|
||||
MAX_INVESTIGATION_FILES,
|
||||
} from '../src/intelligence/investigationPipeline';
|
||||
|
||||
function tmpRoot(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'astra-inv-'));
|
||||
}
|
||||
|
||||
describe('isLocalInvestigationPrompt — 조사형 요청 감지', () => {
|
||||
test('폴더/프로젝트 조사 요청 감지', () => {
|
||||
expect(isLocalInvestigationPrompt('E:\\Wiki\\Weekly 프로젝트 조사해줘')).toBe(true);
|
||||
expect(isLocalInvestigationPrompt('이 폴더의 문서들 분석해서 정리해줘')).toBe(true);
|
||||
expect(isLocalInvestigationPrompt('./docs 검토 부탁해')).toBe(true);
|
||||
});
|
||||
test('로컬 대상이 없는 일반 요청은 미감지', () => {
|
||||
expect(isLocalInvestigationPrompt('오늘 날씨 어때?')).toBe(false);
|
||||
expect(isLocalInvestigationPrompt('삼성전자 주가 조사해줘')).toBe(false); // 로컬 자료 아님
|
||||
});
|
||||
test('증거 게이트 블록은 감지 시에만 생성', () => {
|
||||
expect(buildInvestigationGateBlock('이 폴더 코드 분석해줘')).toContain('investigate_files');
|
||||
expect(buildInvestigationGateBlock('안녕')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectInvestigationFiles — 파일 수집', () => {
|
||||
test('텍스트 파일만, 결정적 순서, 제외 목록 보고', () => {
|
||||
const root = tmpRoot();
|
||||
fs.mkdirSync(path.join(root, 'docs'));
|
||||
fs.writeFileSync(path.join(root, 'docs', 'b.md'), '내용B');
|
||||
fs.writeFileSync(path.join(root, 'docs', 'a.md'), '내용A');
|
||||
fs.writeFileSync(path.join(root, 'docs', 'img.png'), 'binary');
|
||||
const { files, skipped } = collectInvestigationFiles(root, 'docs');
|
||||
expect(files.map(f => f.relPath)).toEqual(['a.md', 'b.md']);
|
||||
expect(skipped.some(s => s.includes('img.png'))).toBe(true);
|
||||
});
|
||||
test('상한 초과분은 skipped 로 (조용한 누락 금지)', () => {
|
||||
const root = tmpRoot();
|
||||
fs.mkdirSync(path.join(root, 'many'));
|
||||
for (let i = 0; i < MAX_INVESTIGATION_FILES + 5; i++) {
|
||||
fs.writeFileSync(path.join(root, 'many', `f${String(i).padStart(3, '0')}.txt`), 'x' + i);
|
||||
}
|
||||
const { files, skipped } = collectInvestigationFiles(root, 'many');
|
||||
expect(files).toHaveLength(MAX_INVESTIGATION_FILES);
|
||||
expect(skipped.filter(s => s.includes('상한 초과'))).toHaveLength(5);
|
||||
});
|
||||
test('단일 파일 경로도 지원', () => {
|
||||
const root = tmpRoot();
|
||||
fs.writeFileSync(path.join(root, 'one.md'), '단일');
|
||||
const { files } = collectInvestigationFiles(root, 'one.md');
|
||||
expect(files).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runInvestigation — 강제 정독 + 캐시', () => {
|
||||
test('모든 파일에 LLM 추출 실행, 노트 생성', async () => {
|
||||
const root = tmpRoot();
|
||||
fs.mkdirSync(path.join(root, 'p'));
|
||||
fs.writeFileSync(path.join(root, 'p', 'x.md'), 'X 파일 내용');
|
||||
fs.writeFileSync(path.join(root, 'p', 'y.md'), 'Y 파일 내용');
|
||||
const calls: string[] = [];
|
||||
const r = await runInvestigation({
|
||||
rootPath: root, relDir: 'p',
|
||||
llmCall: async (_s, u) => { calls.push(u); return '목적: 테스트\n핵심:\n- 사실1'; },
|
||||
});
|
||||
expect(r.notes).toHaveLength(2);
|
||||
expect(r.llmCalls).toBe(2);
|
||||
expect(r.cacheHits).toBe(0);
|
||||
// LLM 입력에 실제 파일 내용이 들어감 (파일명만이 아니라)
|
||||
expect(calls[0]).toContain('X 파일 내용');
|
||||
});
|
||||
|
||||
test('같은 내용 재조사 → 캐시 재사용 (LLM 0회) / 내용 변경 → 재추출', async () => {
|
||||
const root = tmpRoot();
|
||||
fs.mkdirSync(path.join(root, 'p'));
|
||||
fs.writeFileSync(path.join(root, 'p', 'x.md'), '원본 내용');
|
||||
const llm = jest.fn(async () => '목적: v1');
|
||||
await runInvestigation({ rootPath: root, relDir: 'p', llmCall: llm });
|
||||
expect(llm).toHaveBeenCalledTimes(1);
|
||||
|
||||
// 2차: 동일 내용 → 캐시
|
||||
const r2 = await runInvestigation({ rootPath: root, relDir: 'p', llmCall: llm });
|
||||
expect(llm).toHaveBeenCalledTimes(1); // 추가 호출 없음
|
||||
expect(r2.cacheHits).toBe(1);
|
||||
expect(r2.notes[0].fromCache).toBe(true);
|
||||
expect(fs.existsSync(path.join(root, INVESTIGATE_CACHE_REL_DIR))).toBe(true);
|
||||
|
||||
// 3차: 내용 변경 → 재추출
|
||||
fs.writeFileSync(path.join(root, 'p', 'x.md'), '변경된 내용');
|
||||
const r3 = await runInvestigation({ rootPath: root, relDir: 'p', llmCall: llm });
|
||||
expect(llm).toHaveBeenCalledTimes(2);
|
||||
expect(r3.cacheHits).toBe(0);
|
||||
});
|
||||
|
||||
test('추출 실패 파일은 skipped 로 계속 진행', async () => {
|
||||
const root = tmpRoot();
|
||||
fs.mkdirSync(path.join(root, 'p'));
|
||||
fs.writeFileSync(path.join(root, 'p', 'ok.md'), '정상');
|
||||
fs.writeFileSync(path.join(root, 'p', 'zz.md'), '실패 대상');
|
||||
const r = await runInvestigation({
|
||||
rootPath: root, relDir: 'p',
|
||||
llmCall: async (_s, u) => {
|
||||
if (u.includes('실패 대상')) throw new Error('LLM down');
|
||||
return '목적: ok';
|
||||
},
|
||||
});
|
||||
expect(r.notes).toHaveLength(1);
|
||||
expect(r.skipped.some(s => s.includes('zz.md') && s.includes('추출 실패'))).toBe(true);
|
||||
});
|
||||
|
||||
test('병합 노트에 파일 경로·미확인 목록 포함', async () => {
|
||||
const root = tmpRoot();
|
||||
fs.mkdirSync(path.join(root, 'p'));
|
||||
fs.writeFileSync(path.join(root, 'p', 'a.md'), 'A');
|
||||
fs.writeFileSync(path.join(root, 'p', 'bin.png'), 'x');
|
||||
const r = await runInvestigation({ rootPath: root, relDir: 'p', llmCall: async () => '목적: A' });
|
||||
const notes = formatInvestigationNotes(r, '구조 파악');
|
||||
expect(notes).toContain('[a.md]');
|
||||
expect(notes).toContain('조사 목적: 구조 파악');
|
||||
expect(notes).toContain('미확인 파일');
|
||||
expect(notes).toContain('실제로 읽고');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectHollowInvestigation — 헛조사 감지', () => {
|
||||
const answer = '이 프로젝트는 server.mjs가 백엔드이고 dashboard.html이 화면입니다. config.json은 설정입니다.';
|
||||
|
||||
test('list만 하고 read 0 + 파일 2개 이상 서술 = 헛조사', () => {
|
||||
const r = detectHollowInvestigation(answer, { reads: 0, lists: 1, investigates: 0 });
|
||||
expect(r.hollow).toBe(true);
|
||||
expect(r.fileMentions.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
test('실제로 읽었으면(read>0) 정상', () => {
|
||||
expect(detectHollowInvestigation(answer, { reads: 2, lists: 1, investigates: 0 }).hollow).toBe(false);
|
||||
});
|
||||
test('investigate_files 를 썼으면 정상', () => {
|
||||
expect(detectHollowInvestigation(answer, { reads: 0, lists: 1, investigates: 1 }).hollow).toBe(false);
|
||||
});
|
||||
test('list 자체가 없으면 판정 대상 아님', () => {
|
||||
expect(detectHollowInvestigation(answer, { reads: 0, lists: 0, investigates: 0 }).hollow).toBe(false);
|
||||
expect(detectHollowInvestigation(answer, null).hollow).toBe(false);
|
||||
});
|
||||
test('파일 언급 1개는 오탐 방지 위해 통과', () => {
|
||||
const one = 'README.md 를 확인해보세요.';
|
||||
expect(detectHollowInvestigation(one, { reads: 0, lists: 1, investigates: 0 }).hollow).toBe(false);
|
||||
});
|
||||
test('경고 footer 에 파일명 포함', () => {
|
||||
const r = detectHollowInvestigation(answer, { reads: 0, lists: 1, investigates: 0 });
|
||||
const footer = formatHollowInvestigationFooter(r.fileMentions);
|
||||
expect(footer).toContain('헛조사 감지');
|
||||
expect(footer).toContain('investigate_files');
|
||||
});
|
||||
});
|
||||
|
||||
// [v2.2.312] 근거 없는 분석 감지 — 파일을 읽고도 인용이 없는 일반론 보고.
|
||||
import { detectUngroundedAnalysis, formatUngroundedAnalysisFooter } from '../src/intelligence/investigationPipeline';
|
||||
|
||||
describe('detectUngroundedAnalysis — 일반론 분석 감지', () => {
|
||||
const generic = '이 프로젝트는 로컬 LLM과 RAG 기술의 결합 시도가 매우 인상적입니다. '.repeat(10);
|
||||
|
||||
test('read 를 하고도 파일 인용 없는 장문 = ungrounded', () => {
|
||||
const r = detectUngroundedAnalysis(generic, { reads: 3, lists: 1, investigates: 0 });
|
||||
expect(r.ungrounded).toBe(true);
|
||||
expect(r.readCount).toBe(3);
|
||||
});
|
||||
test('파일 근거를 2개 이상 인용하면 정상', () => {
|
||||
const grounded = `${generic} 근거: agent.ts 의 handlePrompt 와 memoryContext.ts 의 buildMemoryContext 를 확인함.`;
|
||||
expect(detectUngroundedAnalysis(grounded, { reads: 3, lists: 1, investigates: 0 }).ungrounded).toBe(false);
|
||||
});
|
||||
test('read 가 없으면 판정 대상 아님 (hollow 감지의 영역)', () => {
|
||||
expect(detectUngroundedAnalysis(generic, { reads: 0, lists: 1, investigates: 0 }).ungrounded).toBe(false);
|
||||
});
|
||||
test('짧은 확인형 응답은 오탐 방지로 제외', () => {
|
||||
expect(detectUngroundedAnalysis('네, 서버 정상입니다.', { reads: 1, lists: 0, investigates: 0 }).ungrounded).toBe(false);
|
||||
});
|
||||
test('footer 에 읽은 횟수 포함', () => {
|
||||
expect(formatUngroundedAnalysisFooter(3)).toContain('3회');
|
||||
});
|
||||
});
|
||||
@@ -63,3 +63,74 @@ describe('LiveReasoningFilter — 스트리밍 중 추론 구간 실시간 차
|
||||
expect(run(['답변 끝 <'])).toBe('답변 끝 <');
|
||||
});
|
||||
});
|
||||
|
||||
describe('[v2.2.310] LM Studio LSEP 합성 추론 구분자', () => {
|
||||
const MARKER = '__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_f4e9a8d2c6b14d0c9e5f3a7b8c1d2e6a__';
|
||||
|
||||
/** run + separator 신호 관찰 버전. */
|
||||
function runWithSignal(tokens: string[]): { out: string; replacedAt: number[] } {
|
||||
const f = new LiveReasoningFilter();
|
||||
let out = '';
|
||||
const replacedAt: number[] = [];
|
||||
tokens.forEach((t, i) => {
|
||||
const visible = f.push(t);
|
||||
if (f.consumeSeparatorSignal()) {
|
||||
out = visible; // streamReplace 시뮬레이션 — 화면 리셋
|
||||
replacedAt.push(i);
|
||||
} else {
|
||||
out += visible;
|
||||
}
|
||||
});
|
||||
out += f.flush();
|
||||
return { out, replacedAt };
|
||||
}
|
||||
|
||||
test('END 마커 뒤 텍스트만 최종 화면에 남는다 (실사례 재현)', () => {
|
||||
const { out, replacedAt } = runWithSignal([
|
||||
'User message: "안녕, 잘지내?" 분석... Option 1... Final choice...',
|
||||
MARKER + '잘 지내고 있습니다. 별일 없으셨나요?',
|
||||
]);
|
||||
expect(out).toBe('잘 지내고 있습니다. 별일 없으셨나요?');
|
||||
expect(replacedAt).toEqual([1]); // 마커 도착 시점에 streamReplace 신호
|
||||
});
|
||||
|
||||
test('마커가 토큰 경계에 잘게 쪼개져도 잡는다', () => {
|
||||
const { out } = runWithSignal([
|
||||
'추론 텍스트 어쩌고 ',
|
||||
'__LM_STUDIO_INTERNAL_LSEP',
|
||||
'_SYNTHETIC_REASONING_END_f4e9a8d2c6b14d0c',
|
||||
'9e5f3a7b8c1d2e6a__',
|
||||
'실제 답변',
|
||||
]);
|
||||
expect(out).toBe('실제 답변');
|
||||
});
|
||||
|
||||
test('START 변형이 오면 END 까지 통째로 숨긴다', () => {
|
||||
const start = '__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_START_abc123def456__';
|
||||
const { out } = runWithSignal(['답변 앞부분 ', start + '숨길 추론 ' + MARKER, '답변 뒷부분']);
|
||||
expect(out).toBe('답변 앞부분 답변 뒷부분');
|
||||
});
|
||||
|
||||
test('마커 없는 일반 밑줄 텍스트는 그대로 통과 (오탐 없음)', () => {
|
||||
const { out, replacedAt } = runWithSignal(['snake_case_variable 와 __init__ 메서드', ' 설명입니다']);
|
||||
expect(out).toBe('snake_case_variable 와 __init__ 메서드 설명입니다');
|
||||
expect(replacedAt).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('[v2.2.310] sanitizeAssistantContent — LSEP 최종 정리 (이중 방어)', () => {
|
||||
const { sanitizeAssistantContent } = require('../src/lib/contextBuilders/outputSanitization');
|
||||
const MARKER = '__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_f4e9a8d2c6b14d0c9e5f3a7b8c1d2e6a__';
|
||||
|
||||
test('마커 앞 추론 전체 제거, 답변만 남긴다', () => {
|
||||
const raw = 'User message 분석... Option 1... Final choice...' + MARKER + '잘 지내고 있습니다. 별일 없으셨나요?';
|
||||
expect(sanitizeAssistantContent(raw)).toBe('잘 지내고 있습니다. 별일 없으셨나요?');
|
||||
});
|
||||
test('마커가 여러 번이면 마지막 마커 뒤만', () => {
|
||||
const raw = '추론1' + MARKER + '중간' + MARKER + '최종 답변';
|
||||
expect(sanitizeAssistantContent(raw)).toBe('최종 답변');
|
||||
});
|
||||
test('마커 없으면 기존 동작 그대로', () => {
|
||||
expect(sanitizeAssistantContent('일반 답변입니다.')).toBe('일반 답변입니다.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* [v2.2.311] KV 캐시 친화 프롬프트 분리 — 정적/동적 경계 계약.
|
||||
*
|
||||
* 정적 프롬프트(getStaticSystemPrompt)에 분 단위 시각 같은 가변 요소가 섞이는 순간
|
||||
* llama.cpp prompt cache 는 요청 첫 토큰부터 무효화된다 (실측: 13k 토큰 전체 재프리필
|
||||
* ≈ 90초/턴). 이 테스트는 그 회귀를 막는 가드레일이다.
|
||||
*/
|
||||
import { getStaticSystemPrompt, getDateTimeContextBlock, getSystemPrompt } from '../src/utils';
|
||||
import { buildAstraModeSystemPrompt } from '../src/agent/handlePrompt/buildAstraModeSystemPrompt';
|
||||
|
||||
describe('정적/동적 시스템 프롬프트 분리', () => {
|
||||
test('정적 프롬프트에는 날짜/시각이 없다 (캐시 무효화 가드)', () => {
|
||||
const s = getStaticSystemPrompt();
|
||||
expect(s).not.toContain('[CURRENT DATE/TIME]');
|
||||
// 오늘 날짜 문자열이 어떤 형태로도 들어있으면 안 된다.
|
||||
const iso = new Date().toISOString().split('T')[0];
|
||||
expect(s).not.toContain(iso);
|
||||
});
|
||||
|
||||
test('정적 프롬프트는 연속 호출에서 동일하다 (프리픽스 불변)', () => {
|
||||
expect(getStaticSystemPrompt()).toBe(getStaticSystemPrompt());
|
||||
});
|
||||
|
||||
test('날짜 블록은 오늘 날짜를 포함한다', () => {
|
||||
const block = getDateTimeContextBlock();
|
||||
expect(block).toContain('[CURRENT DATE/TIME]');
|
||||
expect(block).toContain(new Date().toISOString().split('T')[0]);
|
||||
});
|
||||
|
||||
test('레거시 getSystemPrompt 는 정적 본문 + 날짜 블록을 모두 포함 (호환)', () => {
|
||||
const s = getSystemPrompt();
|
||||
expect(s).toContain('[CURRENT DATE/TIME]');
|
||||
expect(s).toContain('[출력 위생 규칙 — 반드시 준수]');
|
||||
});
|
||||
|
||||
test('빌더에 systemPrompt="" 를 넘겨도 [CONTEXT] 골격은 유지된다 (tail 생성 경로)', () => {
|
||||
const tail = buildAstraModeSystemPrompt({
|
||||
prompt: '질문',
|
||||
systemPrompt: '',
|
||||
modeBridgeCtx: '',
|
||||
priorConclusionCtx: '',
|
||||
designerCtx: '',
|
||||
projectArchitectureCtx: '',
|
||||
secondBrainTraceCtx: '',
|
||||
memoryCtx: 'RAG 본문',
|
||||
knowledgeContextForPrompt: '',
|
||||
contextBlock: '열린 파일 내용',
|
||||
negativeCtx: '',
|
||||
isCasualConversation: false,
|
||||
localPathContext: '',
|
||||
knowledgeMix: null,
|
||||
dynamicBlocks: new Map([['behavior-constraints', '[GROUNDING] 근거 강도: 보통']]),
|
||||
});
|
||||
expect(tail).toContain('[CONTEXT]');
|
||||
expect(tail).toContain('RAG 본문');
|
||||
expect(tail).toContain('열린 파일 내용');
|
||||
expect(tail).toContain('[GROUNDING]');
|
||||
expect(tail.startsWith('You are Astra')).toBe(false); // 정적 본문이 섞이면 안 됨
|
||||
});
|
||||
});
|
||||
@@ -124,3 +124,23 @@ describe('DEFAULT_TASK_REQUIREMENTS 무결성', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// [v2.2.312] 분석 보고 유형 — "프로젝트 분석하고 보고해줘" 실사례가 유형 미감지로
|
||||
// 일반론 총평이 되던 갭을 막는다.
|
||||
describe('detectTaskType — analysis-report (v2.2.312)', () => {
|
||||
test('실사례 문장을 정확히 감지한다', () => {
|
||||
expect(detectTaskType('E:\Wiki\connectai 프로젝트를 분석하고 어떻게 되어 있는지 보고해줘')?.id).toBe('analysis-report');
|
||||
expect(detectTaskType('진행 현황 보고 부탁해')?.id).toBe('analysis-report');
|
||||
expect(detectTaskType('이 코드 검토해서 보고해줘')?.id).toBe('analysis-report');
|
||||
});
|
||||
test('더 구체적인 유형이 우선한다 (회의록·시장조사)', () => {
|
||||
expect(detectTaskType('회의록으로 정리해서 보고해줘')?.id).toBe('meeting-minutes');
|
||||
expect(detectTaskType('시장조사 결과 보고해줘')?.id).toBe('market-research');
|
||||
});
|
||||
test('블록에 파일 근거 요소와 섹션 규칙 오버라이드가 포함된다', () => {
|
||||
const block = buildRequirementGraphBlock('프로젝트 분석하고 보고해줘');
|
||||
expect(block).toContain('파일 근거');
|
||||
expect(block).toContain('보고 개요');
|
||||
expect(block).toContain('최대 3섹션');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Standing Rules (Correction Loop ④) — 행동/스타일 교정의 세션 영속화. (v2.2.308)
|
||||
*
|
||||
* 배경: self-reflection 이 컨텍스트 창(단기 기억)에 갇혀 새 세션에서 증발하는 문제.
|
||||
* 행동 지적 → 명령형 규칙 파일 영속화 → 매 턴 주입으로 구조적으로 해결한다.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
looksLikeBehaviorComplaint,
|
||||
loadStandingRules,
|
||||
saveStandingRules,
|
||||
upsertStandingRule,
|
||||
buildStandingRulesBlock,
|
||||
isSameRule,
|
||||
MAX_ACTIVE_STANDING_RULES,
|
||||
STANDING_RULES_REL_PATH,
|
||||
StandingRule,
|
||||
} from '../src/intelligence/correctionLoop';
|
||||
|
||||
function tmpBrain(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'astra-sr-'));
|
||||
}
|
||||
|
||||
describe('looksLikeBehaviorComplaint — 행동/스타일 지적 감지', () => {
|
||||
test('반복 지적: "또 ~라고 썼네" (이번 이슈의 원 사례)', () => {
|
||||
expect(looksLikeBehaviorComplaint('또 결론 수정이라고 썼네')).toBe(true);
|
||||
});
|
||||
test('"하지 말라고 했잖아"', () => {
|
||||
expect(looksLikeBehaviorComplaint('결론 유지 문구 넣지 말라고 했잖아')).toBe(true);
|
||||
});
|
||||
test('"몇 번을 말해"', () => {
|
||||
expect(looksLikeBehaviorComplaint('몇 번을 말해야 알아들어')).toBe(true);
|
||||
});
|
||||
test('"왜 자꾸 ~"', () => {
|
||||
expect(looksLikeBehaviorComplaint('왜 자꾸 영어로 답해')).toBe(true);
|
||||
});
|
||||
test('출력 형식 지시: "붙이지 마"', () => {
|
||||
expect(looksLikeBehaviorComplaint('출처 표기를 답변 중간에 붙이지 마')).toBe(true);
|
||||
});
|
||||
test('일반 질문은 오탐하지 않음', () => {
|
||||
expect(looksLikeBehaviorComplaint('오늘 주가 어때?')).toBe(false);
|
||||
expect(looksLikeBehaviorComplaint('회의록 요약해줘')).toBe(false);
|
||||
// "또"로 시작하지만 지적이 아닌 추가 요청
|
||||
expect(looksLikeBehaviorComplaint('또 다른 방법도 알려줘')).toBe(false);
|
||||
});
|
||||
test('빈/초장문 입력 방어', () => {
|
||||
expect(looksLikeBehaviorComplaint('')).toBe(false);
|
||||
expect(looksLikeBehaviorComplaint('또 ' + 'x'.repeat(2000))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSameRule — 재지적 판정 (토큰 자카드)', () => {
|
||||
test('같은 규칙의 표현 변형은 동일 판정', () => {
|
||||
expect(isSameRule(
|
||||
'답변에 결론 수정 문구를 붙이지 않는다',
|
||||
'답변에 결론 수정 문구를 절대 붙이지 않는다',
|
||||
)).toBe(true);
|
||||
});
|
||||
test('다른 규칙은 구분', () => {
|
||||
expect(isSameRule(
|
||||
'답변에 결론 수정 문구를 붙이지 않는다',
|
||||
'모든 답변은 한국어로 작성한다',
|
||||
)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertStandingRule — 저장/중복/상한', () => {
|
||||
test('신규 규칙 저장 → 파일 생성 + active', () => {
|
||||
const brain = tmpBrain();
|
||||
const rules = upsertStandingRule(brain, '결론 수정 라벨을 붙이지 않는다', '또 결론 수정이라고 썼네');
|
||||
expect(rules).toHaveLength(1);
|
||||
expect(rules[0].status).toBe('active');
|
||||
expect(rules[0].hits).toBe(1);
|
||||
expect(fs.existsSync(path.join(brain, STANDING_RULES_REL_PATH))).toBe(true);
|
||||
// 라운드트립
|
||||
const loaded = loadStandingRules(brain);
|
||||
expect(loaded[0].rule).toBe('결론 수정 라벨을 붙이지 않는다');
|
||||
});
|
||||
|
||||
test('같은 규칙 재지적 → hits 증가 (새 항목 추가 아님)', () => {
|
||||
const brain = tmpBrain();
|
||||
upsertStandingRule(brain, '결론 수정 라벨을 붙이지 않는다', '지적1');
|
||||
const rules = upsertStandingRule(brain, '결론 수정 라벨을 절대 붙이지 않는다', '지적2');
|
||||
expect(rules).toHaveLength(1);
|
||||
expect(rules[0].hits).toBe(2);
|
||||
});
|
||||
|
||||
test('은퇴한 규칙이 재지적되면 부활', () => {
|
||||
const brain = tmpBrain();
|
||||
const retired: StandingRule[] = [{
|
||||
id: 'sr-x', rule: '영어 단어를 남발하지 않는다', origin: 'o',
|
||||
createdAt: '2026-01-01T00:00:00Z', lastHitAt: '2026-01-01T00:00:00Z', hits: 1, status: 'retired',
|
||||
}];
|
||||
saveStandingRules(brain, retired);
|
||||
const rules = upsertStandingRule(brain, '영어 단어를 남발하지 않는다', '또 영어 남발이네');
|
||||
expect(rules[0].status).toBe('active');
|
||||
expect(rules[0].hits).toBe(2);
|
||||
});
|
||||
|
||||
test('상한 초과 시 hits 낮고 오래된 규칙부터 은퇴', () => {
|
||||
const brain = tmpBrain();
|
||||
// 서로 유사도 판정(isSameRule)에 걸리지 않는 실제로 구분되는 규칙 10개
|
||||
const distinct = [
|
||||
'답변은 항상 한국어로 작성한다',
|
||||
'수치를 말할 때 근거 문서를 인용한다',
|
||||
'표를 남용하지 않는다',
|
||||
'이모지 사용을 최소화한다',
|
||||
'결론부터 먼저 말한다',
|
||||
'추측일 때는 추정임을 명시한다',
|
||||
'링크는 마크다운 형식으로 쓴다',
|
||||
'제목에 물음표를 넣지 않는다',
|
||||
'인사말을 매번 반복하지 않는다',
|
||||
'코드 예시에는 설명 주석을 단다',
|
||||
];
|
||||
distinct.forEach((r, i) => upsertStandingRule(brain, r, `origin${i}`, `2026-01-0${(i % 9) + 1}T00:00:00Z`));
|
||||
expect(loadStandingRules(brain)).toHaveLength(MAX_ACTIVE_STANDING_RULES);
|
||||
// 규칙 하나에 hits 보강 (은퇴 대상에서 멀어지게)
|
||||
upsertStandingRule(brain, '이모지 사용을 최소화한다', 'again', '2026-02-01T00:00:00Z');
|
||||
// 상한+1 번째 신규 규칙
|
||||
const rules = upsertStandingRule(brain, '긴 목록 대신 요약 문단을 우선한다', 'new', '2026-03-01T00:00:00Z');
|
||||
const active = rules.filter(r => r.status === 'active');
|
||||
expect(active).toHaveLength(MAX_ACTIVE_STANDING_RULES);
|
||||
// hits 2인 규칙은 살아남고, 방금 들어온 신규도 active
|
||||
expect(active.some(r => r.rule.includes('이모지'))).toBe(true);
|
||||
expect(active.some(r => r.rule.includes('요약 문단'))).toBe(true);
|
||||
expect(rules.some(r => r.status === 'retired')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildStandingRulesBlock — 매 턴 주입 블록', () => {
|
||||
test('active 규칙을 hits 내림차순으로 포함', () => {
|
||||
const rules: StandingRule[] = [
|
||||
{ id: 'a', rule: '규칙A', origin: '', createdAt: '', lastHitAt: '2026-01-01', hits: 1, status: 'active' },
|
||||
{ id: 'b', rule: '규칙B', origin: '', createdAt: '', lastHitAt: '2026-01-02', hits: 3, status: 'active' },
|
||||
{ id: 'c', rule: '규칙C', origin: '', createdAt: '', lastHitAt: '2026-01-03', hits: 1, status: 'retired' },
|
||||
];
|
||||
const block = buildStandingRulesBlock(rules);
|
||||
expect(block).toContain('[상시 행동 규칙');
|
||||
expect(block.indexOf('규칙B')).toBeLessThan(block.indexOf('규칙A')); // hits 순
|
||||
expect(block).toContain('(지적 3회)');
|
||||
expect(block).not.toContain('규칙C'); // retired 제외
|
||||
});
|
||||
test('규칙 없으면 빈 문자열 (프롬프트 오염 없음)', () => {
|
||||
expect(buildStandingRulesBlock([])).toBe('');
|
||||
expect(buildStandingRulesBlock([
|
||||
{ id: 'c', rule: 'x', origin: '', createdAt: '', lastHitAt: '', hits: 1, status: 'retired' },
|
||||
])).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* [v2.2.312] 중간 라운드 표시용 액션 태그 제거 — "## 4.부터 시작하는 보고서" 버그의
|
||||
* 표시 경로 헬퍼. 태그는 걷어내되 본문 섹션은 보존해야 한다.
|
||||
*/
|
||||
import { stripActionTagsForDisplay } from '../src/agent/actions/stripForDisplay';
|
||||
|
||||
describe('stripActionTagsForDisplay', () => {
|
||||
test('자기닫힘 태그 제거 + 본문 보존', () => {
|
||||
const input = '## 1. 프로젝트 개요\n구조를 확인한다.\n<read_file path="src/agent.ts"/>\n<list_files path="src"/>';
|
||||
const out = stripActionTagsForDisplay(input);
|
||||
expect(out).toContain('## 1. 프로젝트 개요');
|
||||
expect(out).not.toContain('<read_file');
|
||||
expect(out).not.toContain('<list_files');
|
||||
});
|
||||
test('블록 태그(내용 포함) 제거', () => {
|
||||
const input = '분석 시작.\n<run_command>cd x; npm test</run_command>\n<create_file path="a.md">본문</create_file>\n끝.';
|
||||
const out = stripActionTagsForDisplay(input);
|
||||
expect(out).toContain('분석 시작.');
|
||||
expect(out).toContain('끝.');
|
||||
expect(out).not.toContain('npm test');
|
||||
expect(out).not.toContain('<create_file');
|
||||
});
|
||||
test('잔재 여는/닫는 태그도 정리', () => {
|
||||
const out = stripActionTagsForDisplay('앞 <edit_file path="x.ts"> 뒤');
|
||||
expect(out).not.toContain('<edit_file');
|
||||
expect(out).toContain('앞');
|
||||
expect(out).toContain('뒤');
|
||||
});
|
||||
test('태그 없는 본문은 그대로', () => {
|
||||
expect(stripActionTagsForDisplay('## 2. 구조\n- src/agent.ts')).toBe('## 2. 구조\n- src/agent.ts');
|
||||
});
|
||||
test('빈 입력 안전', () => {
|
||||
expect(stripActionTagsForDisplay('')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -50,7 +50,8 @@ describe('pickWikiDir — 저장 폴더 우선순위', () => {
|
||||
|
||||
test('[v2.2.304] ../ 상대경로로 두뇌 밖 형제 폴더도 지정 가능', () => {
|
||||
const r = pickWikiDir('../../Premium/자료', '/Wiki/10_Wiki/Topics', null);
|
||||
expect(r!.dir.replace(/\\/g, '/')).toBe('/Wiki/Premium/자료');
|
||||
// Windows에서는 path.resolve가 드라이브 문자를 붙이므로(E:/Wiki/...) 끝부분만 검증
|
||||
expect(r!.dir.replace(/\\/g, '/')).toMatch(/\/Wiki\/Premium\/자료$/);
|
||||
});
|
||||
|
||||
test('상대경로인데 두뇌도 없으면 다음 후보(워크스페이스)로', () => {
|
||||
|
||||
Reference in New Issue
Block a user