diff --git a/.astra/project-context/architecture.md b/.astra/project-context/architecture.md index 4081f62..deac270 100644 --- a/.astra/project-context/architecture.md +++ b/.astra/project-context/architecture.md @@ -3,15 +3,15 @@ ## Snapshot -- **Workspace**: `connectai` `v2.2.161` _(absolute path varies by environment; resolved from the active VS Code workspace)_ +- **Workspace**: `connectai` `v2.2.193` _(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**: 396 source files, ~63,667 lines across 5 top-level modules. +- **Stats**: 416 source files, ~70,115 lines across 5 top-level modules. ## Last Refresh -- **Time**: 2026-05-26T08:10:10.182Z +- **Time**: 2026-05-29T06:58:50.818Z - **Files newly analysed**: 3 -- **Files reused from cache**: 393 +- **Files reused from cache**: 413 ## Directory Map ```mermaid @@ -22,8 +22,8 @@ mindmap sidebar/ lib/ agent/ + retrieval/ core/ - extension/ media/ tests/ helpers/ @@ -40,11 +40,11 @@ mindmap > Arrows: which top-level module imports from which. ```mermaid flowchart LR - src["src/
247 files"] + src["src/
262 files"] media["media/
6 files"] tests["tests/
37 files"] core_py["core_py/
6 files"] - docs["docs/
100 files"] + docs["docs/
105 files"] tests --> src ``` @@ -58,8 +58,8 @@ flowchart LR > Imported by many other files — touching these has wide blast radius. - `src/utils.ts` — referenced by **87** files - `src/agent.ts` — referenced by **34** files -- `src/config.ts` — referenced by **32** files -- `src/core/services.ts` — referenced by **14** files +- `src/config.ts` — referenced by **33** files +- `src/core/services.ts` — referenced by **15** files - `src/features/company/index.ts` — referenced by **14** files · Public API for 1인 기업 모드. Consumers (sidebarProvider, chatHandlers, command handlers) import from this barrel so internal layout can move around without touching every call site. - `src/features/company/types.ts` — referenced by **14** files · Type definitions for the 1인 기업 (One-Person Company) mode. The mode turns the user into a virtual CEO that dispatches work to a roster of specialist agents. Each turn produces a session directory conta - `src/sidebarProvider.ts` — referenced by **11** files @@ -67,17 +67,17 @@ flowchart LR ## Modules -### `src/` — 247 files, ~46,059 lines +### `src/` — 262 files, ~52,385 lines **Sub-directories** -- `src/features/` (87) — Astra Office — public API. 다음 세션에서 추가될 OfficeSnapshot presenter / schema 도 같은 entry 로 노출 예정. 현재 노출: full webview panel H +- `src/features/` (92) — Astra Office — public API. 다음 세션에서 추가될 OfficeSnapshot presenter / schema 도 같은 entry 로 노출 예정. 현재 노출: full webview panel H - `src/sidebar/` (35) — Brain profile lifecycle 의 pure helpers — sidebarProvider 의 add/edit/delete 흐름에서 modal UI 와 config 쓰기를 제외한 데이터 변환 만 격리. 현 - `src/lib/` (28) — Astra Mode Architecture Context Builder. 의도: 사용자가 Astra 자체의 mode 디자인 (Guard vs Multi-Agent 가 별도 모드여야 하는지) 을 묻는 메타 질문에 답할 -- `src/agent/` (25) — 25 files (.ts) +- `src/agent/` (26) — Post-hoc Self-Check — 답변 완료 후 LLM 한 번 호출로 3가지 평가. 사용자 제안: "[Self-Check] 단계 — 이 답변이 사용자 질문에 직접 답하는가 / 규칙 준수 / 논리 모순 없는가". +- `src/retrieval/` (16) — Actionability Scoring — 검색 결과를 "현재 작업 상태" 신호로 재가중. 기존 TF-IDF (단어 매칭) + recency (시간) 만으로는 "지금 이 사용자가 하고 있는 작업과 직접 연결 된 문서 - `src/core/` (15) — Astra Path Resolver (경로 해결기) Astra의 모든 데이터 파일(.astra 디렉토리)의 경로를 중앙에서 관리합니다. 확장 프로그램의 설치 경로(extensionUri) 기반으로 .astra 디렉토 +- `src/memory/` (9) — Distillation Loop — stale Episodic Memory → Long-Term "episode-digest" 승급. 배경: Episodic Memory 가 무한히 누적되면 검색 노이즈. 30일+ 지 - `src/extension/` (8) — 8 files (.ts) -- `src/memory/` (8) — Episodic Memory (일화 기억) 과거 대화/회의/결정의 맥락 흐름을 저장합니다. 세션 종료 시 자동으로 에피소드를 요약하여 저장합니다. "왜 이렇게 결정했는지", "어떤 흐름으로 진행했는지" 기록. 저장 -- `src/retrieval/` (8) — Brain Index — persistent, mtime-keyed tokenized cache of the Second Brain RAG 검색은 매 질의마다 브레인의 모든 .md 파일을 읽고 토크나이즈해서 TF-I - `src/docs/` (6) — 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) @@ -85,22 +85,23 @@ flowchart LR **Key files** - `src/utils.ts` (471 lines) -- `src/agent.ts` (1484 lines) -- `src/config.ts` (418 lines) +- `src/config.ts` (550 lines) +- `src/agent.ts` (1568 lines) - `src/features/company/types.ts` (446 lines) — Type definitions for the 1인 기업 (One-Person Company) mode. The mode turns the user into a virtual CEO that dispatches work to a roster of specialist agents. Each turn produces a session directory conta -- `src/sidebarProvider.ts` (3186 lines) - `src/core/services.ts` (176 lines) +- `src/sidebarProvider.ts` (3186 lines) - `src/lib/contextManager.ts` (278 lines) — Context Manager (컨텍스트 한계 관리) "context length = 132k" 는 "답변을 132k 토큰까지 생성해도 된다" 가 아닙니다. 시스템 프롬프트 + 대화 기록 + 입력 문서 + 생성될 답변 + 여유분 ≤ context length 이 모듈은 요청을 보내기 전에 입력 토큰을 추정하고, - 동적으로 출력 상한(maxTokens)을 계 - `src/features/company/companyConfig.ts` (896 lines) — State + config plumbing for 1인 기업 모드. Two surfaces: - CompanyState (runtime data: enabled flag, company name, which agents are active, per-agent model overrides). Persisted in VS Code's globalState so - `src/integrations/telegram/telegramClient.ts` (154 lines) - `src/lib/paths.ts` (151 lines) - `src/agent/actions/types.ts` (41 lines) - `src/skills/agentKnowledgeMap.ts` (374 lines) +- `src/retrieval/types.ts` (66 lines) — Retrieval Types (검색 결과 통합 타입) 모든 검색 소스(Brain, Memory, Project, Episode)의 결과를 통합 인터페이스로 정의합니다. +- `src/memory/types.ts` (151 lines) — Memory Type Definitions (메모리 타입 정의) Astra의 5-Layer Cognitive Memory System의 모든 타입을 정의합니다. ① Short-Term ② Long-Term ③ Project ④ Procedural ⑤ Episodic +- `src/retrieval/scoring.ts` (541 lines) — Scoring Engine — TF-IDF + Bilingual Tokenizer 단순 includes() 키워드 매칭을 넘어서, TF-IDF 가중치 기반의 문서 스코어링을 제공합니다. 한국어/영어 양국어 토크나이저를 포함합니다. - `src/features/stocks/types.ts` (53 lines) — Stocks 모듈 공유 타입. investresults/targetstocks.json 스키마를 그대로 받아서, ConnectAI 의 /.astra/stocks.json 으로 옮긴 뒤 같은 필드명을 유지. 한글 필드명은 사용자의 도메인 데이터라 변경하지 않는다 — 마이그레이션 충돌 회피 + 사용자가 직접 JSON 편집할 때 frictio - `src/lib/contextBuilders/promptDetection.ts` (85 lines) — 사용자 prompt 의 의도 분류 류 detection helpers. 모두 stateless 정규식 매칭. 옛 코드는 agent.ts 의 private 메서드로 박혀 있었는데, system prompt 빌더 (buildJarvisProjectBriefContext 등) 가 이걸 의존하면서 god-file 안에서 서로 얽힘. 헬퍼만 먼저 떼면 의존 그래프가 - `src/retrieval/lessonHelpers.ts` (325 lines) — Lesson / Experience Memory — pure helpers (no vscode dependency) "Lesson" = a markdown file in the active brain that captures a past mistake/risk and how to avoid repeating it. Identified by a lessons -- `src/memory/types.ts` (126 lines) — Memory Type Definitions (메모리 타입 정의) Astra의 5-Layer Cognitive Memory System의 모든 타입을 정의합니다. ① Short-Term ② Long-Term ③ Project ④ Procedural ⑤ Episodic -- `src/retrieval/scoring.ts` (541 lines) — Scoring Engine — TF-IDF + Bilingual Tokenizer 단순 includes() 키워드 매칭을 넘어서, TF-IDF 가중치 기반의 문서 스코어링을 제공합니다. 한국어/영어 양국어 토크나이저를 포함합니다. - `src/security.ts` (159 lines) - `src/features/secondBrainTrace.ts` (792 lines) - `src/features/providers/types.ts` (63 lines) — Cloud LLM provider routing — model id prefix → provider id 매핑. Prefix 규칙: openrouter:anthropic/claude-3.5-sonnet → { provider: 'openrouter', model: 'anthropic/claude-3.5-sonnet' } anthropic:claude-3-5 @@ -108,7 +109,6 @@ flowchart LR - `src/lib/contextBuilders/localProjectIntent.ts` (233 lines) - `src/lib/engine.ts` (1114 lines) - `src/lmstudio/streamer.ts` (252 lines) -- `src/core/responseRecovery.ts` (310 lines) — Response Recovery — Thought Quarantine + Final-only Retry + Auto-Continuation The user already asked their question; they're waiting for an answer, not for a chance to babysit the generation engine. S ### `media/` — 6 files, ~7,671 lines @@ -165,17 +165,17 @@ flowchart LR - `core_py/optimizer.py` (55 lines) - `core_py/queue_worker.py` (82 lines) -### `docs/` — 100 files, ~3,653 lines +### `docs/` — 105 files, ~3,775 lines **Sub-directories** -- `docs/records/` (87) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 프로젝트 코드 리뷰 해줄 수 있어? 개선할 부분이 있는지, 그러고... +- `docs/records/` (92) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 프로젝트 코드 리뷰 해줄 수 있어? 개선할 부분이 있는지, 그러고... - `docs/docs/` (5) — Bug: Viewed integrationretrieval.test.ts:1-59 integrationretrieval.test.ts를 통해 ... - `docs/Meeting/` (0) **Key files** - `docs/TELEGRAM_REMOTE_EXECUTION_PLAN.md` (452 lines) — Telegram Remote Execution 기획서 - `docs/AgentEngine_Architecture.md` (314 lines) — AgentEngine Architecture Document -- `docs/records/ConnectAI/timeline.md` (212 lines) — Project Timeline +- `docs/records/ConnectAI/timeline.md` (227 lines) — Project Timeline - `docs/ASTRA_OFFICE_REFACTOR.md` (198 lines) — Astra Office Refactor — Design Doc - `docs/EXPERIENCE_MEMORY_PLAN.md` (122 lines) — Experience Memory (Mistake / Lesson Loop) — Implementation Plan - `docs/records/ConnectAI/development/2026-05-02_connectai_project_knowledge_overview.md` (121 lines) — Astra Project Knowledge Overview @@ -232,7 +232,7 @@ flowchart LR - `g1nation.calendar.refresh` — Astra: Google Calendar 새로고침 📅 - `g1nation.calendar.connectOAuth` — Astra: Google Calendar OAuth 연결 (쓰기) 🔐 - `g1nation.devilAgent.toggle` — Astra: Toggle Devil Agent 🎭 -- **Configuration** (93 settings): +- **Configuration** (121 settings): - `g1nation.multiAgentEnabled` *(boolean)* _(default: `false`)_ — Enable Multi-Agent Workflow (Planner -> Researcher -> Writer) for complex tasks. - `g1nation.datacollectBridgeUrl` *(string)* _(default: `"http://127.0.0.1:3002"`)_ — Wiki/Datacollect MCP Bridge URL. /research, /benchmark, /youtube chat slash commands route here. The Bridge must be running (`npm run bridge` in the Datacollect project). - `g1nation.datacollectSavePath` *(string)* _(default: `""`)_ @@ -240,6 +240,9 @@ flowchart LR - `g1nation.datacollectMaxPages` *(number)* _(default: `8`)_ - `g1nation.datacollectSynthesisTemperature` *(number)* _(default: `0.1`)_ - `g1nation.chatTemperature` *(number)* _(default: `0.3`)_ + - `g1nation.meetUsesTasks` *(boolean)* _(default: `true`)_ + - `g1nation.meetUsesCalendar` *(boolean)* _(default: `false`)_ + - `g1nation.teamVoiceGuide` *(string)* _(default: `""`)_ - `g1nation.memoryEnabled` *(boolean)* _(default: `true`)_ — Enable layered memory injection before each model response. - `g1nation.memoryShortTermMessages` *(number)* _(default: `8`)_ — Number of recent conversation messages included as short-term memory. - `g1nation.memoryMediumTermSessions` *(number)* _(default: `5`)_ — Number of recent saved chat sessions included as medium-term memory. @@ -288,12 +291,9 @@ flowchart LR - `g1nation.agentSkillsPath` *(string)* _(default: `""`)_ — Absolute path to the agent skills folder (`.agent/skills/*.md`). When empty, defaults to '/.agent/skills'. Use this on Windows or when your skills live outside the workspace. - `g1nation.embeddingModel` *(string)* _(default: `""`)_ — Embedding model registered in LM Studio / Ollama (e.g. 'text-embedding-bge-small-en-v1.5', 'nomic-embed-text', 'multilingual-e5-small'). When empty, Astra uses TF-IDF only. When set, the brain is embe - `g1nation.embeddingBlendAlpha` *(number)* _(default: `0.5`)_ — Hybrid score blend: 0 = pure TF-IDF (sparse / keyword), 1 = pure embedding cosine (dense / semantic), 0.5 = balanced. Only used when g1nation.embeddingModel is set. Default 0.5. - - `g1nation.knowledgeMix.secondBrainWeight` *(number)* _(default: `50`)_ — Knowledge Mix (0–100): how heavily the assistant should lean on Second Brain evidence vs. its own general knowledge. 0 = Second Brain disabled (model knowledge only). 50 = balanced (legacy default). 1 - - `g1nation.workflow.multiAgentMode` *(string)* _(default: `"auto"`)_ - - `g1nation.workflow.autoCtxFractionThreshold` *(number)* _(default: `0.3`)_ - - `g1nation.chunkedSwitchTokens` *(number)* _(default: `50000`)_ - - `g1nation.chunkedMaxSections` *(number)* _(default: `3`)_ - - _…and 33 more_ + - `g1nation.conflictHighlightingEnabled` *(boolean)* _(default: `true`)_ — Conflict Surface — 검색된 출처에서 충돌/논란 신호 감지 시 [CONFLICT WARNINGS] 블록을 시스템 프롬프트에 주입. LLM 이 상충되는 관점을 명시하고 사용자 판단에 위임하도록. 기본 켜짐. + - `g1nation.conflictSeverityThreshold` *(string)* _(default: `"medium"`)_ — Conflict 자기-신호 surface 시 최소 severity 임계. low=가장 민감(노이즈 가능), medium=균형(기본), high=강한 충돌만. + - _…and 61 more_ ## Dependencies - **Runtime** (2): `@lmstudio/sdk`, `pdf-parse` @@ -341,7 +341,7 @@ Astra는 대표님의 명시적인 승인 하에 로컬 시스템의 강력한 **Designed for High-Performance Decision Making.** Copyright (C) **g1nation**. All rights reserved. -_Last auto-scan: 2026-05-26T08:10:10.182Z · signature `850d1550`_ +_Last auto-scan: 2026-05-29T06:58:50.818Z · signature `e7ea4eff`_ ## Purpose diff --git a/.astra/project-context/scan-cache.json b/.astra/project-context/scan-cache.json index 9b609ad..82a23bd 100644 --- a/.astra/project-context/scan-cache.json +++ b/.astra/project-context/scan-cache.json @@ -1,6 +1,6 @@ { "version": 1, - "generatedAt": "2026-05-26T08:10:10.234Z", + "generatedAt": "2026-05-29T06:58:50.872Z", "files": { "src/agent/actions/brainOps.ts": { "mtimeMs": 1779764602582.9768, @@ -135,9 +135,9 @@ ] }, "src/agent/handlePrompt/buildAstraModeSystemPrompt.ts": { - "mtimeMs": 1779764602589.828, - "size": 5335, - "lines": 82, + "mtimeMs": 1780033670993.9626, + "size": 8427, + "lines": 133, "role": "", "imports": [ "src/lib/contextBuilders/localProjectIntent", @@ -298,6 +298,13 @@ "src/agent" ] }, + "src/agent/postHocSelfCheck.ts": { + "mtimeMs": 1780033021215.5054, + "size": 8943, + "lines": 230, + "role": "Post-hoc Self-Check — 답변 완료 후 LLM 한 번 호출로 3가지 평가. 사용자 제안: \"[Self-Check] 단계 — 이 답변이 사용자 질문에 직접 답하는가 / 규칙 준수 / 논리 모순 없는가\". 기존 CoVe (v2.2.184) 와 차이: - CoVe = 답변 작성 전 모델에게 self-verify 지시 (instructional, 1", + "imports": [] + }, "src/agent/sessions/compressSummary.ts": { "mtimeMs": 1779764602596.8809, "size": 2780, @@ -310,9 +317,9 @@ ] }, "src/agent.ts": { - "mtimeMs": 1779764602581.5493, - "size": 81527, - "lines": 1484, + "mtimeMs": 1780033640914.1235, + "size": 85839, + "lines": 1568, "role": "", "imports": [ "src/utils", @@ -383,6 +390,7 @@ "src/agent/handlePrompt/buildAstraModeSystemPrompt", "src/agent/handlePrompt/computeBudgetedRequest", "src/agent/handlePrompt/processFinalAnswer", + "src/agent/postHocSelfCheck", "src/agent/handlePrompt/applyAutoContinuation", "src/features/approval/approvalQueue", "src/features/providers", @@ -425,9 +433,9 @@ ] }, "src/config.ts": { - "mtimeMs": 1779764602599.9685, - "size": 22439, - "lines": 418, + "mtimeMs": 1780033618147.1584, + "size": 30668, + "lines": 550, "role": "", "imports": [] }, @@ -850,24 +858,34 @@ "imports": [] }, "src/features/calendar/index.ts": { - "mtimeMs": 1779065453871.9966, - "size": 593, - "lines": 32, + "mtimeMs": 1779879082214.362, + "size": 711, + "lines": 40, "role": "", "imports": [ "src/features/calendar/icsParser", "src/features/calendar/calendarCache", "src/features/calendar/oauth", - "src/features/calendar/calendarApi" + "src/features/calendar/calendarApi", + "src/features/calendar/tasksApi" ] }, "src/features/calendar/oauth.ts": { - "mtimeMs": 1779065453873.0256, - "size": 10940, - "lines": 235, + "mtimeMs": 1779869692244.0415, + "size": 10986, + "lines": 236, "role": "Google OAuth 2.0 — loopback (Desktop app) 흐름. Google 은 Desktop 앱 OAuth client 에 대해 http://127.0.0.1: redirect URI 를 허용한다. 본 모듈은: 1. ephemeral port 에 일회용 HTTP 서버 띄움 2. 사용자 브라우저로 Google 로", "imports": [] }, + "src/features/calendar/tasksApi.ts": { + "mtimeMs": 1779879080585.057, + "size": 7206, + "lines": 169, + "role": "Google Tasks API v1 — task create 호출. Calendar / Sheets 와 같은 OAuth 토큰을 공유한다 (scope 에 tasks 포함). Tasks 는 date-only 모델(시간 없음)이라 /meet 의 액션 아이템처럼 \"시간 없이 마감일만 있는 할 일\" 에 자연스럽게 맞는다. 외부 라이브러리 안 씀 — Tasks API", + "imports": [ + "src/features/calendar/calendarApi" + ] + }, "src/features/company/agents.ts": { "mtimeMs": 1779764602611.032, "size": 38122, @@ -1090,6 +1108,13 @@ "role": "Type definitions for the 1인 기업 (One-Person Company) mode. The mode turns the user into a virtual CEO that dispatches work to a roster of specialist agents. Each turn produces a session directory conta", "imports": [] }, + "src/features/customers/customersStore.ts": { + "mtimeMs": 1779952731356.5476, + "size": 6815, + "lines": 176, + "role": "고객사 / MRR / 갱신 트래커. 4인 기업의 수입 쪽 — /runway 가 통장과 burn 을 본다면, 여기는 어디서 돈이 들어오나. Salesforce / HubSpot 같은 CRM 아닌 가벼운 event-sourced 로그. 저장 형식: JSON Lines (.jsonl) — append-only event log. 같은 customer 의 여러 이", + "imports": [] + }, "src/features/datacollect/bridgeClient.ts": { "mtimeMs": 1779764602617.1548, "size": 7210, @@ -1119,9 +1144,9 @@ "imports": [] }, "src/features/datacollect/prompts/youtubePrompts.ts": { - "mtimeMs": 1779764602619.1604, - "size": 18108, - "lines": 331, + "mtimeMs": 1779852277877.3347, + "size": 19109, + "lines": 347, "role": "/youtube slash command 의 LLM 입력 빌더 + 자막 변환 헬퍼. - formatHms / fullScriptFromSegments / bucketSegments — segment list 가공 - YoutubeAnalysisMode — info/benchmark/both 라우팅 enum (slashRouter 가 사용) - buildIn", "imports": [] }, @@ -1133,21 +1158,24 @@ "imports": [] }, "src/features/datacollect/slashRouter.ts": { - "mtimeMs": 1779764602621.1665, - "size": 63071, - "lines": 1132, + "mtimeMs": 1780035781878.5417, + "size": 226019, + "lines": 4090, "role": "", "imports": [ "src/utils", "src/features/datacollect/bridgeClient", "src/features/calendar", + "src/sidebar/managers/chronicleProjectStore", + "src/memory", + "src/config", + "src/retrieval/terminologyBlock", "src/features/setup/datacollectSetup", "src/features/datacollect/prompts/synthesisPrompt", "src/features/datacollect/prompts/youtubePrompts", "src/features/datacollect/prompts/wikifyPrompt", "src/features/datacollect/prompts/meetPrompt", - "src/features/datacollect/scheduling/calendarHelpers", - "src/features/stocks" + "src/features/datacollect/scheduling/calendarHelpers" ] }, "src/features/devilAgent/devilPrompt.ts": { @@ -1176,6 +1204,20 @@ "src/features/devilAgent/devilService" ] }, + "src/features/feedback/feedbackStore.ts": { + "mtimeMs": 1779931731117.0737, + "size": 3406, + "lines": 81, + "role": "고객 피드백 누적 저장소. 단일 운영자(대표) 모드에서 슬랙·이메일·CS 채널에 흩어진 고객 피드백을 /feedback <텍스트> 한 줄로 모아 둔다. 패턴 분석은 /feedback summary 로 LLM 이 누적 데이터를 보고 카테고리 분포 + 반복 주제를 추출. 저장 형식: JSON Lines (.jsonl) — 한 줄 = 한 entry. 누적·app", + "imports": [] + }, + "src/features/hire/hireStore.ts": { + "mtimeMs": 1779964473915.006, + "size": 5586, + "lines": 150, + "role": "채용 파이프라인 트래커. 4인 → 5인 이상 확장 시점에 후보자가 여러 명, 여러 역할(개발/기획/디자인) 로 들어오기 시작 — 노션·스프레드시트·이메일에 흩어진 정보를 한 명령으로 본다. Event-sourced (customersStore 와 동일 패턴) — append-only 이벤트 로그를 재생해 후보자별 현재 단계 + 노트 누적 도출. 위치: /.as", + "imports": [] + }, "src/retrieval/types.ts": { "mtimeMs": 1779764602656.6587, "size": 2485, @@ -3206,7 +3348,7 @@ "imports": [] }, "docs/records/ConnectAI/chronicle.config.json": { - "mtimeMs": 1779783003968.8809, + "mtimeMs": 1780037924615.7922, "size": 371, "lines": 11, "role": "JSON configuration", @@ -3387,6 +3529,27 @@ "role": "ADR: E:\\Wiki\\connectai 프로젝트에 대한 너의 평가 해줘.", "imports": [] }, + "docs/records/ConnectAI/decisions/ADR-0026-질문이-있어-내가-당근이라는-중고-거래-사이트에서-8tb-hdd를-구매했어-안전거래-escrow-를-사용했어.md": { + "mtimeMs": 1780032500203.2405, + "size": 2483, + "lines": 19, + "role": "ADR: 질문이 있어, 내가 당근이라는 중고 거래 사이트에서 8TB HDD를 구매했어. 안전거래 (Escrow)를 사용했어. 일단 물건을 어제 받았고, ...", + "imports": [] + }, + "docs/records/ConnectAI/decisions/ADR-0027-connectai-프로젝트의-아스트라-대부분-답을-잘-줘-근대-간혹-오타가-발생하거나-상황에-맞지-않는-단어.md": { + "mtimeMs": 1780033038688.5405, + "size": 1749, + "lines": 19, + "role": "ADR: connectAI 프로젝트의 아스트라 대부분 답을 잘 줘. 근대 간혹 오타가 발생하거나 상황에 맞지 않는 단어를 간혹 사용해서 혼선을 줄때가 있...", + "imports": [] + }, + "docs/records/ConnectAI/decisions/ADR-0028-질문이-있어-내가-당근이라는-중고-거래-사이트에서-8tb-hdd를-구매했어-안전거래-escrow-를-사용했어.md": { + "mtimeMs": 1780037924606.6091, + "size": 2481, + "lines": 19, + "role": "ADR: 질문이 있어, 내가 당근이라는 중고 거래 사이트에서 8TB HDD를 구매했어. 안전거래 (Escrow)를 사용했어. 일단 물건을 어제 받았고, ...", + "imports": [] + }, "docs/records/ConnectAI/development/2026-05-02_answer-format-readability-tuning.md": { "mtimeMs": 1778028987330.4185, "size": 1564, @@ -3618,6 +3781,20 @@ "role": "Development Log: /Volumes/Data/project/Antigravity/ConnectAI 코드 리뷰하고 사용자 입장에서 개선이 필요한 부분이 있는지, 제안...", "imports": [] }, + "docs/records/ConnectAI/development/2026-05-29_e-wiki-connectai-프로젝트에-대해서-이야기-하는거야_implementation.md": { + "mtimeMs": 1780031872506.9214, + "size": 1611, + "lines": 28, + "role": "Development Log: E:\\Wiki\\connectai 프로젝트에 대해서 이야기 하는거야.", + "imports": [] + }, + "docs/records/ConnectAI/development/2026-05-29_아스트라를-지금-보다-더-날카롭고-신뢰감이-높은-아이-그리고-나의-의도를-잘-파악하게-하기-위해서는-어떤부분_implementation.md": { + "mtimeMs": 1780031951811.4153, + "size": 1670, + "lines": 22, + "role": "Development Log: 아스트라를 지금 보다 더 날카롭고 신뢰감이 높은 아이 그리고 나의 의도를 잘 파악하게 하기 위해서는 어떤부분을 강화하는게 좋을까?", + "imports": [] + }, "docs/records/ConnectAI/discussions/2026-05-13_volumes-data-project-antigravity-connectai-이-프로젝트-작업-할-거야.md": { "mtimeMs": 1778720117404.2578, "size": 668, @@ -3710,9 +3887,9 @@ "imports": [] }, "docs/records/ConnectAI/timeline.md": { - "mtimeMs": 1779783003962.848, - "size": 13603, - "lines": 212, + "mtimeMs": 1780037924607.6199, + "size": 14606, + "lines": 227, "role": "Project Timeline", "imports": [] }, diff --git a/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json b/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json index db5339d..63014da 100644 --- a/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json +++ b/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json @@ -1,5 +1,5 @@ { "result": "직답 결과 — single-pass mock 응답입니다.", - "createdAt": 1779872260856, + "createdAt": 1780037632204, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json b/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json index 8000d7a..07d894f 100644 --- a/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json +++ b/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json @@ -1,5 +1,5 @@ { - "result": "---\nid: wiki_on\ndate: 2026-05-27T08:57:40.858Z\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) (24ms)\n", - "createdAt": 1779872260858, + "result": "---\nid: wiki_on\ndate: 2026-05-29T06:53:52.206Z\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) (23ms)\n", + "createdAt": 1780037632207, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/engine/.astra/missions/wiki_on.json b/.astra/tests/engine/.astra/missions/wiki_on.json index ddcfd81..17c8e91 100644 --- a/.astra/tests/engine/.astra/missions/wiki_on.json +++ b/.astra/tests/engine/.astra/missions/wiki_on.json @@ -1,7 +1,7 @@ { "missionId": "wiki_on", "status": "completed", - "startTime": "2026-05-27T08:57:40.831Z", + "startTime": "2026-05-29T06:53:52.180Z", "totalElapsedMs": 28, "results": { "direct": "직답 결과 — single-pass mock 응답입니다." @@ -12,16 +12,16 @@ { "from": "idle", "to": "direct", - "durationMs": 24, + "durationMs": 23, "message": "답변 작성 중... (단일 호출 fast-path)", - "ts": "2026-05-27T08:57:40.855Z" + "ts": "2026-05-29T06:53:52.203Z" }, { "from": "direct", "to": "completed", - "durationMs": 3, + "durationMs": 4, "message": "미션 완료", - "ts": "2026-05-27T08:57:40.858Z" + "ts": "2026-05-29T06:53:52.207Z" } ], "resilienceMetrics": { diff --git a/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json b/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json index 8467ab7..c726d93 100644 --- a/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json +++ b/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json @@ -1,5 +1,5 @@ { "result": "Final report with inconsistencies. This should be long enough to pass validation.", - "createdAt": 1779872267564, + "createdAt": 1780037639311, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json b/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json index 9fa213f..bdadd8e 100644 --- a/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json +++ b/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json @@ -1,5 +1,5 @@ { "result": "Final report with inconsistencies. This should be long enough to pass validation.", - "createdAt": 1779872267563, + "createdAt": 1780037639310, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json b/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json index ef03ca7..8284473 100644 --- a/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json +++ b/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json @@ -1,5 +1,5 @@ { "result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]", - "createdAt": 1779872267560, + "createdAt": 1780037639305, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json b/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json index 21214bc..7c150ba 100644 --- a/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json +++ b/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json @@ -1,5 +1,5 @@ { "result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", - "createdAt": 1779872267562, + "createdAt": 1780037639308, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/missions/stress_conflict_1779872267539.json b/.astra/tests/stress/.astra/missions/stress_conflict_1780037639285.json similarity index 78% rename from .astra/tests/stress/.astra/missions/stress_conflict_1779872267539.json rename to .astra/tests/stress/.astra/missions/stress_conflict_1780037639285.json index 07154f7..f6c33de 100644 --- a/.astra/tests/stress/.astra/missions/stress_conflict_1779872267539.json +++ b/.astra/tests/stress/.astra/missions/stress_conflict_1780037639285.json @@ -1,7 +1,7 @@ { - "missionId": "stress_conflict_1779872267539", + "missionId": "stress_conflict_1780037639285", "status": "completed", - "startTime": "2026-05-27T08:57:47.539Z", + "startTime": "2026-05-29T06:53:59.285Z", "totalElapsedMs": 26, "results": { "outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]", @@ -14,30 +14,30 @@ { "from": "idle", "to": "outline", - "durationMs": 20, + "durationMs": 19, "message": "답변 구조 잡는 중...", - "ts": "2026-05-27T08:57:47.559Z" + "ts": "2026-05-29T06:53:59.304Z" }, { "from": "outline", "to": "section", - "durationMs": 2, + "durationMs": 3, "message": "본문 작성 중...", - "ts": "2026-05-27T08:57:47.561Z" + "ts": "2026-05-29T06:53:59.307Z" }, { "from": "section", "to": "polish", "durationMs": 2, "message": "최종 다듬기 중...", - "ts": "2026-05-27T08:57:47.563Z" + "ts": "2026-05-29T06:53:59.309Z" }, { "from": "polish", "to": "completed", "durationMs": 2, "message": "미션 완료", - "ts": "2026-05-27T08:57:47.565Z" + "ts": "2026-05-29T06:53:59.311Z" } ], "resilienceMetrics": { diff --git a/PATCHNOTES.md b/PATCHNOTES.md index 4a8c811..a8a51d6 100644 --- a/PATCHNOTES.md +++ b/PATCHNOTES.md @@ -1,5 +1,911 @@ # Astra Patch Notes +## v2.2.193 (2026-05-29) +### 📚 /help — 슬래시 명령 카테고리 브라우저 + 엔진 상태 +누적된 슬래시 명령이 25개+. 사용자 발견 부담을 줄이기 위해 카테고리별 그룹핑 + ASTRA 추론 엔진 상태(6종 verification 블록 on/off) 한 화면. + +**신규 `/help` 명령:** +- `/help` — 전체 명령 카테고리별 + 엔진 상태 +- `/help <명령>` — 특정 명령의 짧은 설명 + 세부 도움말 안내 + +**카테고리 자동 그룹핑 (7개):** +- ☀️ **일과 리듬 (Daily Cycle)** — /morning · /evening · /weekly · /cohort · /standup +- 🏢 **4인 팀 운영 트래커** — /runway · /customers · /hire (event-sourced .jsonl) +- 📋 **작업·블로커·1:1** — /task · /blocked · /onesie · /decisions +- ✉️ **외부 출력·기록** — /draft · /feedback +- 🔬 **리서치·분석** — /research · /benchmark · /youtube · /blog · /wikify · /meet (Datacollect bridge) +- ⚙️ **시스템·메모리** — /memory · /glossary +- 📈 **주식·외부** — /stocks +- 🔌 **기타·외부 플러그인** — 위 카테고리에 안 잡히는 모든 명령 (외부 플러그인 자동 포함) + +**ASTRA 추론 엔진 상태 (자동 표시):** +6종 verification 블록의 *현재 on/off* 를 한 화면. 각 블록의 system prompt 라벨과 *어느 시점* 에 동작하는지 표시: +- ✓ Intent Clarification — `[INTENT CLARIFICATION GUIDANCE]` (답변 시작 전) +- ✓ Terminology Dictionary — `[TERMINOLOGY DICTIONARY]` (답변 작성 중) +- ✓ Conflict Surface — `[CONFLICT WARNINGS]` (답변 작성 중) +- ✓ Chain-of-Verification — `[VERIFICATION CHECKLIST]` (답변 작성 중) +- ✓ Citation Trace — `[CITATION TRACE]` (답변 끝) +- ⊘ Post-hoc Self-Check — `footer` (답변 완료 후, opt-in) + +추가 신호 (검색 단계): Recency · Actionability · Hierarchical Level · Semantic Re-rank (opt-in). +메모리 관리: Temporal Markers · Distillation Loop. + +**동적 명령 목록:** +- `listSlashCommands()` 사용 — 외부 플러그인이 추가한 명령도 자동 노출 +- 카테고리 매칭 함수 기반 — 새 명령 추가 시 매칭 함수만 업데이트하면 자동 분류 + +**신규 패키징:** `astra-2.2.193.vsix`. + +--- + + + +## v2.2.192 (2026-05-29) +### 📖 Terminology Dictionary + Term Check (엔진 변경) +사용자 제안 "출력 품질 관리" 중 *진짜 새 가치* 만 추출 — Terminology Glossary 단일 메커니즘으로 #1(typo/용어 self-check) + #2(표준 용어집) 통합. **#3 sampling 튜닝은 이미 적용된 상태라 스킵**. + +**감사 결과 (스킵한 이유):** +- #3 Temperature/Sampling 튜닝 → 이미 `modelTemperature=0.3`, `datacollectSynthesisTemperature=0.1`, `top_p=0.85`, `top_k=20`, `repeat_penalty=1.1`, `repairKoreanGlitches` 적용. 추가 튜닝 시 다양성↓ 위험. +- #1 별도 [Output Quality Control] 블록 → 시스템 프롬프트 비대 위험, *Glossary 블록 내 Term Check sub-clause* 로 통합이 깔끔. + +--- + +**신규 메커니즘 — `.astra/glossary.md` 사용자 편집 파일** + +- **위치**: `/.astra/glossary.md` (설정으로 변경 가능) +- **형식**: 자유 markdown — ASTRA 는 형식 강제 안 함, 본문 통째로 시스템 프롬프트에 주입 +- **권장 컨벤션** (`/glossary init` 템플릿): + - `## 표준 표기` — `**ASTRA** (X: astra) — 본 extension` 형태 + - `## 영-한 표기 컨벤션` — Performance → 성능 + - `## 금지·비추 표현` + - `## 슬래시 명령 표기` +- **캐시**: mtime 기반 — 사용자가 편집할 때만 다시 읽음 (매 turn 디스크 read 0회) + +**시스템 프롬프트 블록 `[TERMINOLOGY DICTIONARY]`:** +1. 글로서리 본문 (cap 4000자, 초과분 잘림 안내) +2. **[Term Check — 답변 직전 자기 점검]** 5개 규칙 (사용자 제안 #1 통합): + - 표준 표기 우선, 변형·번역 임의 적용 금지 + - 표기 흔들림 방지 (같은 답변 안에서 일관) + - 새 용어 도입 시 "새 용어: X" 명시 + - 금지 표기 검증 + 재작성 + - 표기 확신 없으면 "글로서리에 없어 일반 표기 사용" 명시 + +**신규 모듈: [terminologyBlock.ts](src/retrieval/terminologyBlock.ts)** +- `buildTerminologyBlock(opts)` — 글로서리 + Term Check 합쳐 블록 생성 +- mtime cache 내장 +- `GLOSSARY_TEMPLATE` — 권장 템플릿 export + +**신규 슬래시 명령 `/glossary`:** +- `/glossary` — 상태 카드 (파일 존재/크기/cap/주입 여부/첫 5줄 미리보기) +- `/glossary path` — 절대 경로 +- `/glossary init` — 권장 템플릿으로 초기 파일 생성 (이미 있으면 덮어쓰지 않음) +- `/glossary reload` — 캐시 비우기 (편집 직후 즉시 반영 강제) +- `/glossary help` — 가이드 + +**Wiring:** +- [memoryContext.ts](src/lib/contextBuilders/memoryContext.ts) — 매 turn 글로서리 블록 계산 → `turnCtx.terminology` +- [agent.ts](src/agent.ts) — turn reset 시 비움, system prompt build 시 전달 +- [buildAstraModeSystemPrompt.ts](src/agent/handlePrompt/buildAstraModeSystemPrompt.ts) — `[CONTEXT]` 밖, `[INTENT CLARIFICATION]` 직후 (verification 블록 군집) +- Casual conversation 모드 비활성 (greeting 에 용어 강제 의미 없음) + +**신규 설정 3개:** +- `g1nation.glossaryEnabled` (boolean, 기본 true) — 마스터 +- `g1nation.glossaryPath` (string, 기본 `.astra/glossary.md`) +- `g1nation.glossaryMaxBodyLength` (number 500~20000, 기본 4000) + +**ASTRA 검증 6종 (engine 변경 누적, v2.2.183~192):** +| # | 블록 | 시점 | 빌드 | +|---|---|---|---| +| INTENT | `[INTENT CLARIFICATION GUIDANCE]` | 답변 시작 전 | v2.2.190 | +| **TERMINOLOGY** | **`[TERMINOLOGY DICTIONARY]`** | **답변 작성 중 (용어)** | **v2.2.192** | +| CONFLICT | `[CONFLICT WARNINGS]` | 답변 작성 중 | v2.2.183 | +| COVE | `[VERIFICATION CHECKLIST]` | 답변 작성 중 | v2.2.184 | +| CITATION | `[CITATION TRACE]` | 답변 끝 | v2.2.190 | +| SELF-CHECK | footer (separate LLM) | 답변 완료 후 | v2.2.191 | + +**신규 패키징:** `astra-2.2.192.vsix`. + +--- + + + +## v2.2.191 (2026-05-29) +### 🔍 추론 신뢰도 — Post-hoc Self-Check (2-pass 검증, 엔진 변경) +v2.2.190 의 #1+#2 (Intent + Citation) 짝꿍. 답변 *완료 후* 별도 LLM 호출 1회로 사후 검증. CoVe (v2.2.184) 가 *답변 전* instructional self-check 이면, 이번엔 *답변 후* 객관 평가. + +**설계 결정 — 비동기 후처리:** +- 기존 `_maybeEmitDevilRebuttal` (Devil Agent) 패턴 그대로 — main turn 종료 후 비동기, 실패해도 답변 보존 +- 답변이 webview 에 렌더된 직후 self-check footer streamChunk 로 append +- 사용자는 답변 즉시 보고, 1~6초 후 self-check 결과 한 줄이 아래에 추가됨 + +**평가 3차원 (LLM 출력 JSON):** +1. `answersQuestion` — yes/partial/no — 답변이 질문에 직접 답하나 +2. `grounded` — yes/partial/no/unknown — 답변이 제공된 출처에 근거하나 +3. `contradiction` — none/minor/major — 답변에 논리 모순 있나 + +**Footer 형식:** +``` +--- +_🔍 **Self-check**: 답함=✓ · 근거=○ · 모순=없음 — 답변은 직접적이나 일부 주장이 모델 일반 지식 기반 _(2.4s · gemma2:2b)__ +``` + +**신규 모듈: [postHocSelfCheck.ts](src/agent/postHocSelfCheck.ts)** +- `postHocSelfCheck(prompt, answer, sources, opts)` — LLM 호출 + JSON 파싱 + 안전 fallback +- `formatSelfCheckFooter(result, model)` — 마크다운 footer 한 줄 + +**안전망:** +- 응답 파싱 실패 / 타임아웃 / 오류 → 흐릿한 한 줄 footer ("⊘ failed: ...") +- 답변 자체는 영향 없음 (Devil Agent 와 동일 패턴) +- 짧은 timeout (기본 6초) +- 별도 빠른 모델 지정 가능 + +**Wiring:** +- [memoryContext.ts](src/lib/contextBuilders/memoryContext.ts) — 검색 후 `turnCtx.selfCheckSources` 에 상위 5개 chunk (title, excerpt) 저장 +- [agent.ts](src/agent.ts) — Devil rebuttal 옆에 `_maybePostHocSelfCheck` 호출 추가, 결과를 streamChunk 로 append + +**신규 설정 3개:** +- `g1nation.selfCheckEnabled` (boolean, **기본 false** — opt-in) +- `g1nation.selfCheckModel` (string, 비면 defaultModel) +- `g1nation.selfCheckTimeoutSec` (number 1~60, 기본 6) + +--- + +**ASTRA 검증 5종 완전체 (engine 변경 누적):** +| # | 블록 | 시점 | 모드 | +|---|---|---|---| +| INTENT (v2.2.190) | `[INTENT CLARIFICATION GUIDANCE]` | 답변 시작 전 | instructional | +| CONFLICT (v2.2.183) | `[CONFLICT WARNINGS]` | 답변 작성 중 | instructional | +| COVE (v2.2.184) | `[VERIFICATION CHECKLIST]` | 답변 작성 중 | instructional | +| CITATION (v2.2.190) | `[CITATION TRACE]` | 답변 끝 | instructional | +| **SELF-CHECK (v2.2.191)** | footer 한 줄 | **답변 완료 후** | **separate LLM call** | + +**활용 권장:** +- 기본 ON 4개 (INTENT/CONFLICT/COVE/CITATION) — 즉시 활용 +- `g1nation.selfCheckModel` 에 빠른 모델 설정 (예: gemma2:2b) +- `g1nation.selfCheckEnabled = true` — opt-in. footer 한 줄로 답변 품질 즉시 검증 + +**신규 패키징:** `astra-2.2.191.vsix`. + +--- + + + +## v2.2.190 (2026-05-29) +### 🎯 추론 신뢰도 — Intent Clarification + Citation Trace (엔진 변경) +사용자 제안 "Pre-inference Contextualization + Grounding" 중 ASTRA 에 *진짜 빈* 두 영역. ASTRA 검색 엔진 자체 변경 — 매 채팅 turn 자동 적용, 슬래시 명령 없음. + +**감사 결과 (분리한 이유):** +- 제안 #1 Intent Clarification → **갭 명확**: ASTRA 는 항상 답을 시도, *모호하면 묻기* 메커니즘 없음 +- 제안 #2 Citation Trace → CoVe Strict (v2.2.184) 와 부분 중복, *가벼운 always-on 형제* 로 추가 +- 제안 #3 Self-Correction Loop → CoVe 가 *instructional 형태* 로 이미 구현, *post-hoc 2-pass* 는 v2.2.191 에서 별도 빌드 + +--- + +**#1 Intent Clarification — 모호 질의 역질문 (기본 ON)** + +신규 모듈: [intentClarification.ts](src/retrieval/intentClarification.ts) + +- **5개 의도 차원 휴리스틱**: + - `환경` — 배포/롤백 trigger + dev/prod 명시 검사 + - `대상` — 고쳐/수정 trigger + 파일/모듈/`.ts` 검사 + - `범위` — 리팩토링/정리 trigger + 전체/일부 검사 + - `포맷` — 요약/보고서 trigger + 표/리스트/markdown 검사 + - `마감` — 빨리/급함 trigger + 오늘/내일 검사 +- **trigger 만 있고 specifier 없으면 missing** +- **Strictness 3-level**: low (2개+ missing), medium (1개+ missing, 기본), high (짧은 질의 추가 검사) +- **시스템 프롬프트 블록 `[INTENT CLARIFICATION GUIDANCE]`**: 모호 차원 + 권장 역질문 예시 + "사용자가 '추정해도 OK' 했으면 가정 명시 후 진행" 등 4개 규칙 + +**효과**: "배포해줘" → ASTRA 가 추측하지 않고 "dev 인가요 prod 인가요?" 한 줄 질문 먼저. + +--- + +**#2 Citation Trace — 답변 끝 출처 한 줄 (기본 ON)** + +신규 모듈: [citationTrace.ts](src/retrieval/citationTrace.ts) + +- **CoVe Strict 와 차이**: CoVe Strict = 모든 사실 주장 뒤 inline `[S1]` (verbose). Citation Trace = 답변 끝 한 줄 정리 (가벼움). +- **시스템 프롬프트 블록 `[CITATION TRACE]`**: "답변 끝에 *출처:* 한 줄, 사용한 출처만 나열, 일반 지식이면 그 사실 명시" 등 5개 규칙 +- **CoVe 와 시너지**: CoVe 가 `[S1]..[SN]` 라벨 제공, Citation 이 끝에 정리 + +**효과**: 답변 끝에 자동으로 `*출처:* file1.md · chunk-title2` 같은 한 줄 → 사용자 검증 가능, 할루시네이션 억제. + +--- + +**Wiring (둘 다):** +- [memoryContext.ts](src/lib/contextBuilders/memoryContext.ts) — 검색 직후 두 블록 계산 → `turnCtx.intentClarification` / `turnCtx.citationTrace` +- [agent.ts](src/agent.ts) — turn reset 시 비움, system prompt build 시 전달 +- [buildAstraModeSystemPrompt.ts](src/agent/handlePrompt/buildAstraModeSystemPrompt.ts) — `[CONTEXT]` 밖 주입. Intent block 은 다른 verification 블록보다 *앞* (모호하면 답변 자체를 안 만들어야 하므로). Citation 은 끝. + +**신규 설정 3개:** +- `g1nation.intentClarificationEnabled` (boolean, 기본 true) +- `g1nation.intentClarificationStrictness` (low/medium/high, 기본 medium) +- `g1nation.citationTraceEnabled` (boolean, 기본 true) + +**ASTRA 추론 엔진 검증 3종 (#2/#3/#1 시리즈와 함께):** +| 블록 | 시점 | 용도 | +|---|---|---| +| `[INTENT CLARIFICATION GUIDANCE]` | 답변 시작 전 | 모호하면 묻기 | +| `[CONFLICT WARNINGS]` (v2.2.183) | 답변 작성 중 | 충돌 출처 양측 명시 | +| `[VERIFICATION CHECKLIST]` (v2.2.184) | 답변 작성 중 | 근거 매핑 + 자기 점검 | +| `[CITATION TRACE]` | 답변 끝 | 사용 출처 한 줄 정리 | + +**다음 (v2.2.191):** +- **#3 Post-hoc Self-Check** — 답변 *완료 후* LLM 호출 1회로 검증 (semantic re-rank 와 유사 패턴, 기본 OFF / opt-in) + +**신규 패키징:** `astra-2.2.190.vsix`. + +--- + + + +## v2.2.189 (2026-05-29) +### 📅 4인 팀 운영 12단계 — /weekly 주간 리뷰 카드 (대표용) +일 단위 리듬은 `/morning` · `/evening` 으로 단조. 주 단위 리듬은 비어 있었음 (`/standup weekly` 는 팀 공유용, 대표 전략 시야 아님). 그 갭을 메움. + +**신규 `/weekly` 명령** (인자 없음, 매주 일요일/월요일 1회 실행 권장) + +**`/standup weekly` 와 차이:** +- `/standup weekly` → *팀에 공유* (멤버별 완료/진행/블로커, 슬랙 복붙) +- `/weekly` → *대표가 본다* (cross-data delta, 회고 프롬프트) + +**섹션 구성:** + +**1. ✅ 이번 주 진척** +- **작업**: 멤버별 카운트 + 지난 주 대비 ↑↓→ +- **📒 고객**: 신규/갱신/위험/이탈 이벤트 카운트 + 신규 MRR 합산 +- **🎯 채용**: 신규 후보/단계 이동/합격 이벤트 +- **💰 재무**: 주간 지출/수입/순 burn + 잔고 시작→끝 Δ +- **📋 결정**: 이번 주 Chronicle ADR (최대 5건) + +**2. 🔄 지난 주 대비 (ISO week 기반 월~일)** +- 작업 / 신규 고객 / 이탈 / 순 burn / 채용 이벤트 — 각각 ↑↓→ delta +- 화살표 + (이번 X ← 지난 Y) 형식 + +**3. 🌅 다음 주 준비** +- 14일 내 갱신 (D-7 🔴 / D-14 🟡) +- 다음 주 마감 작업 카운트 +- 정체 후보 (7일+ 미변동) top 3 + +**4. 💭 주간 회고 한 줄** +- 6개 회고 질문 풀, 주차 결정적 선택 (같은 주 = 같은 질문) +- 예: "이번 주 가장 중요한 결정은 무엇이었나? 다음 주에 어떤 결정을 미루면 안 되나?" + +**ISO Week 기반 윈도우:** +- 월요일 00:00 ~ 일요일 23:59:59 +- "YYYY-Wnn (M/D-M/D)" 라벨 + +**4인 팀 운영 누적 (1~12 — 일·주 리듬 완성):** + +| 시간 | 명령 | +|---|---| +| 🌅 아침 (일) | `/morning` | +| 🌞 낮 | `/blocked`, `/task`, `/onesie @멤버` | +| 🌙 저녁 (일) | `/evening` | +| 📊 팀 공유 | `/standup` | +| 📅 **주말 (대표용)** | **`/weekly`** ⭐ | +| 📈 분기 | `/cohort` | +| 💰 트래커 | `/runway`, `/customers`, `/hire` | +| 📋 의사결정 | `/decisions`, `/draft`, `/feedback` | + +**신규 패키징:** `astra-2.2.189.vsix`. + +--- + + + +## v2.2.188 (2026-05-29) +### 📈 4인 팀 운영 11단계 — /cohort MoM 추세 분석 +`/customers` + `/runway` 의 events 가 누적되니 *추세* 가 보임. 한 시점 스냅샷(`/morning` 같은) 이 아닌 *6~12개월 흐름* 으로 비즈니스 방향 검토. 회계 SaaS 의 board metric 의 미니 버전. + +**신규 `/cohort` 명령:** +- `/cohort` — 최근 **6개월** 추세 (기본) +- `/cohort yearly` — 최근 12개월 +- `/cohort ` — 최근 N개월 (1~24) + +**섹션 구성:** + +**1. 고객 & MRR 추이 (월별 테이블)** +| 월 | 신규 | 갱신 | 이탈 | MRR Δ | +- 누계: 신규 +N · 이탈 -M · 순 ±K +- MRR 순증 (추가 신규 MRR 만, 이탈 차감은 history 부재로 미반영) +- 월평균 신규/이탈 + 이탈/신규 비율 (단순 churn-rate 근사) + +**2. 재무 추이 (월별 테이블)** +| 월 | 지출 | 수입 | 순 burn | 월말 잔고 | +- 6개월 누계 +- 월평균 burn + +**3. 💡 인사이트 한 줄 (자동 도출)** +- 🟢 최근 3개월 신규 가속 (이전 3개월 대비 +20%↑) +- 🟡 신규 둔화 (-20%↓) +- 🔴 burn 가속 (+30%↑) +- 🟢 매출 커버리지 80%+ → 흑자 임박 +- 🟡 매출 커버리지 20% 미만 → 매출 기반 약함 + +**데이터 출처:** +- `/.astra/customers.jsonl` (add/renew/churn timestamp 그룹핑) +- `/.astra/runway.jsonl` (expense/revenue/snapshot 그룹핑) + +**현 시점 한계 (의도적 단순화):** +- Churn MRR 감소 = 직전 mrr 추적 안 함 — 보수적으로 0 계산 +- Update event 의 mrr 차이 = 0 (state 추적 없음) +- 향후 v2: customers store 에 monthly snapshot 추가하면 정확한 churn MRR 계산 가능 + +**4인 팀 운영 누적 (1~11단계):** +`/decisions` · `/onesie` · `/draft` · `/feedback` · `/blocked` · `/standup` · `/runway` · `/customers` · `/hire` · `/morning` · `/evening` · **`/cohort`** ⭐ + +**신규 패키징:** `astra-2.2.188.vsix`. + +--- + + + +## v2.2.187 (2026-05-29) +### 🔍 Retrieval 의도 매칭 — Hierarchical + LLM Semantic Re-rank (엔진 변경) +사용자 제안 "지식 발굴 로직 강화" 중 *진짜 새 가치* 2개 적용. (제안의 Re-ranking 항목들 — Recency / Actionability / Conflict Check — 은 이미 v2.2.183~185 에서 완료. 제안자가 빌드 모름.) + +**둘 다 ASTRA 검색 엔진 자체 변경 — 슬래시 명령 추가 없음, 매 채팅 turn 자동 적용.** + +--- + +**#2 Hierarchical Context Window — 추상도 매칭 (기본 ON)** + +신규 모듈: `src/retrieval/hierarchicalLevel.ts` + +- **3-level 분류** (LLM 호출 없음, 결정적 휴리스틱): + - `concrete` — 코드/로그/디버그/실행 (folder hints: code, log, debug; title hints: fix, bug, error) + - `operational` — 작업/일정/운영/회의 (기본값) + - `strategic` — 전략/비전/방향/의사결정 (folder hints: strategy, vision, decision; title hints: plan, direction) +- **질의 분류**: 키워드 우열 — `'전략 검토'` → strategic, `'버그 고쳐'` → concrete +- **재가중 정책**: + - 같은 레벨 → × 1.15 (boost) + - 인접 레벨 (concrete↔operational, operational↔strategic) → 변화 없음 + - 양 끝 mismatch (concrete↔strategic) → × 0.70 (penalty) +- **시점**: `RetrievalOrchestrator.retrieve()` 의 normalize 직후, 토큰 예산 직전 — 어떤 chunk 가 살아남는지에 영향 +- **위치**: [hierarchicalLevel.ts](src/retrieval/hierarchicalLevel.ts) + retrieve() 파이프라인 stage ③-c + +--- + +**#4 LLM Semantic Re-rank — 의도 매칭 (기본 OFF, 명시적 opt-in)** + +신규 모듈: `src/retrieval/semanticRerank.ts` + +- **로직**: 토큰 예산 통과한 `selectedChunks` 상위 K(기본 15) 를 LLM 한 번 호출로 의도 부합도 순 재정렬 +- **프롬프트**: `[C1]..[CK]` 라벨 + 발췌문 → LLM 이 JSON `{"ranking":[3,1,5,...]}` 한 줄 출력 +- **파싱 보호**: 응답 누락/중복/번호 오류 시 원순서 fallback. 타임아웃 시도 원순서 fallback. **재정렬 실패해도 검색 자체는 절대 실패 안 함.** +- **위험 관리**: + - 기본 OFF — `g1nation.semanticRerankEnabled` 명시 on 필요 + - 짧은 timeout (기본 8초) — 초과 시 원순서 + - 별도 빠른 모델 지정 가능 (`g1nation.semanticRerankModel`, 예: gemma2:2b) — 메인 모델 latency 보호 +- **시점**: `memoryContext.ts` 에서 retrieve() 결과의 selectedChunks 를 비동기 재정렬 후 context string 빌드 +- **효과**: LLM attention 은 컨텍스트 상단에 더 강함 → 의도 부합 chunk 가 상단 → 답변 품질↑ + +--- + +**신규 설정 5개:** +- `g1nation.hierarchicalReweightEnabled` (boolean, 기본 true) +- `g1nation.semanticRerankEnabled` (boolean, **기본 false** — opt-in) +- `g1nation.semanticRerankModel` (string, 비면 defaultModel) +- `g1nation.semanticRerankCandidateK` (number 2~30, 기본 15) +- `g1nation.semanticRerankTimeoutSec` (number 1~60, 기본 8) + +--- + +**감사 결과 — 스킵한 제안 항목:** +- #1 Graph-based Retrieval → 스킵 (마크다운 wiki brain 에 NER+그래프 인프라 비용 큼, 가치 불확실) +- #3 Re-ranking 항목들 → 이미 v2.2.183~185 에 완료: + - Recency 가중 = scoring.ts recencyBoost + - Actionability = actionabilityScoring.ts (v2.2.185) + - Conflict Check = conflictBlock.ts + scoring.ts conflictMultiplier (v2.2.183) + +**신규 패키징:** `astra-2.2.187.vsix`. + +--- + + + +## v2.2.186 (2026-05-29) +### 🧠 메모리 라이프사이클 — Temporal Markers + Distillation Loop +사용자 제안 "정보의 계층화 + 생성 주기 관리" 중 *실제로 빈 곳* 2개에 집중. 제안의 절반은 이미 5-layer 로 구현돼 있어 *그 위에 라이프사이클 관리만 추가*. + +**Part 1 — Temporal Markers (만료일)** +- `LongTermEntry` + `EpisodicEntry` 에 `expiresAt?: number` (epoch ms) 추가. +- 검색·context build 시 `expiresAt < now` 인 entry **자동 제외** — 만료 plan/decisions 가 stale 답변에 끼어드는 것 방지. +- `LongTermMemory.setExpiration(idOrPrefix, expiresAt)` API — 슬래시 명령에서 호출. +- 예: "Q3 2026 마케팅 계획" entry 에 `expiresAt = 2026-09-30` 설정 → 10월부터 검색 결과에서 자동 사라짐. + +**Part 2 — Distillation Loop (stale → digest)** +- 신규 모듈: `src/memory/distillation.ts` +- 30일+ 지난 episodes → LongTerm `'episode-digest'` 카테고리로 승급. 원본은 `promoted=true` 마킹되어 검색에서 제외 (중복 노출 방지). +- 신규 LongTermCategory: `'episode-digest'` 추가. +- **LLM-less 설계 (v1)**: 기존 EpisodicEntry 의 title/summary/keyDecisions/topics 를 구조적으로 결합 — 비용 0, 결정적, 재현 가능. +- **자동 트리거**: 세션 종료 시 (마지막 실행 후 N일 경과 시만, 기본 7일 간격). +- **수동 트리거**: `/memory distill [age_days]` +- **Archive 모드 2종**: + - `'mark-promoted'` (기본): 플래그만, 파일 보존 + - `'archive-file'`: 파일을 `memory/episodes/archive/` 로 이동 + +**신규 슬래시 명령 `/memory`** +- `/memory` — 현재 상태 (LongTerm/Episodic 카운트, 카테고리 분포, distillation 마지막 실행, 30일 내 만료 임박) +- `/memory distill [age_days]` — 수동 distillation (기본 30일 임계) +- `/memory expire ` — LongTerm entry 에 만료일 설정 +- `/memory list-expiring [days]` — N일 내 만료 entry 목록 (기본 30) +- `/memory list-promoted` — 승급된 episodes (digest 형태로 LongTerm 에 살아 있음) +- `/memory help` — 명령어 가이드 + +**신규 설정 4개:** +- `g1nation.distillationEnabled` (boolean, 기본 true) +- `g1nation.distillationAgeThresholdDays` (number 1~365, 기본 30) +- `g1nation.distillationIntervalDays` (number 1~90, 기본 7) +- `g1nation.distillationArchiveMode` (mark-promoted/archive-file, 기본 mark-promoted) + +**감사 결과 (스킵한 제안 항목):** +- L1 Working Memory → 이미 `ShortTermMemory.ts` 로 완료 +- L2 Episodic → 이미 `EpisodicMemory.ts` 로 완료 +- L3 unification (3 silo → 1 통합) → 스킵 (refactor 비용 > 가치, 작동 중) +- 자유 해시태그 시스템 → 스킵 (기존 카테고리 enum 으로 80% 커버) +- Conflict reconciliation 닫는 루프 → 추후 (UI 작업 + supersedeance 인프라 큼) + +**신규 패키징:** `astra-2.2.186.vsix`. + +--- + + + +## v2.2.185 (2026-05-29) +### 🎯 추론 품질 #1 — Actionability Scoring (맥락 가중치, 마무리) +사용자 피드백: "*단순히 키워드가 겹치는 정보가 아니라, 현재 작업의 상태와 직접적으로 연결된 지식에 더 높은 가중치를 부여*". 추론 품질 3단 시리즈의 마지막. #2 (Conflict Surface) + #3 (CoVe) 완성 위에 *검색 자체* 를 재가중하는 가장 깊은 변경. + +**v1 신호 (사용자 선택):** +1. **최근 슬래시 명령** — `slashRouter` 에 in-memory ring buffer 추가. 최근 10개 (중복 제거, 최신 우선). 명령 이름 (`/runway` → `runway`) 이 chunk 내용에 단어 경계 매치 시 boost. +2. **열린 파일 경로** — VS Code 활성 에디터에서 캡처. + - **Exact filePath 매치**: 가장 강한 boost (× 1.5) + - **파일명 매치**: `weights.openFileNameMatch` (기본 0.40) + - **부모 디렉터리 매치**: `weights.openFileParentDirMatch` (기본 0.20, 'src'/'lib' 제외) + - **확장자 의미 매치**: `weights.openFileExtMatch` (기본 0.10, e.g. `.ts` → 'typescript' 단어) + +**점수 결합 (보수적):** +- `finalScore = baseScore × (1 + actionabilityScore × combinedWeight)` +- `combinedWeight` 기본 0.30 → actionability=1.0 인 chunk 는 30% boost +- TF-IDF 가 여전히 dominant — actionability 는 *동점 깨기* 역할 + +**신규 모듈: `src/retrieval/actionabilityScoring.ts`** +- `captureWorkStateSignals(recentCmds)` — 신호 수집 +- `computeActionabilityScore(chunk, signals, weights)` — 0~1.0 +- `applyActionabilityBoost(chunks, signals, weights)` — 배열 in-place 재가중 + +**Wiring:** +- `slashRouter.ts` — 명령 실행 직전에 `pushRecentSlashCommand(head)` 호출, ring buffer 갱신 +- `getRecentSlashCommands()` export — memoryContext 가 읽음 +- `RetrievalOrchestrator.retrieve()` — `normalizeScores` 직후, `selectWithinBudget` 직전에 actionability 적용 (어떤 chunk 가 token 예산을 살아남는지에 영향) +- `RetrievalOptions.workStateSignals` 신규 필드 — undefined 면 legacy +- `fusionLog` 에 boosted 개수 + 시그널 요약 기록 (디버그 footer 노출 가능) + +**신규 설정 1개:** +- `g1nation.actionabilityEnabled` (boolean, 기본 true) — 마스터 스위치 + +**추론 품질 3단 시리즈 완성:** +| # | 로직 | 효과 | 위치 | +|---|---|---|---| +| **#2** | Conflict Surface | 충돌 출처 데이터 LLM 에 노출 | `conflictBlock.ts` | +| **#3** | CoVe | 답변 직전 자기 검증 체크리스트 | `coveBlock.ts` | +| **#1** | Actionability | 작업 상태 신호로 검색 재가중 | `actionabilityScoring.ts` | + +**시너지:** Actionability 가 *어떤 출처가 답변에 포함되나* 를 개선 → CoVe 가 *그 출처로 어떻게 그라운딩하나* 를 지시 → Conflict Surface 가 *충돌 출처를 어떻게 처리하나* 를 명시. 검색 → 추론 → 응답 3단계 모두 verification 적용. + +**향후 #1 v2 후보 (사용자 미선택, 추후 옵션):** +- 최근 7일 Chronicle ADR / decisions 매치 boost +- 최근 24시간 customers/hire/runway 이벤트 매치 boost + +**신규 패키징:** `astra-2.2.185.vsix`. + +--- + + + +## v2.2.184 (2026-05-29) +### 🧭 추론 품질 #3 — Chain-of-Verification (CoVe) +사용자 피드백: "*추론 결과가 나오기 직전, 이 결론이 확보된 지식(제2뇌)에만 근거하고 있는가? 를 스스로 질문하고 검증하는 로직*" — 할루시네이션 방지 + 그라운딩 명확화. + +**설계 결정 — Instructional CoVe (v1):** +원논문 CoVe 는 2-pass (draft → verify → revise). 그러나 ASTRA local-first 라 추가 LLM 호출 비용 큼 + 같은 모델 self-verify 효과 제한적. → v1 은 *instructional* — 시스템 프롬프트에 명시적 검증 체크리스트 주입해 모델이 한 패스 안에서 "답변 작성 전" 그라운딩 점검을 *내재화* 하도록. 향후 strict 모드 확장 가능 (knob 준비). + +**신규 모듈: `src/retrieval/coveBlock.ts`** +- `buildCoveChecklistBlock(chunks, userPrompt, options)` — [VERIFICATION CHECKLIST] 시스템 프롬프트 블록 생성. +- **체크리스트 구조:** + 1. **근거 매핑 (Grounding Inventory)** — 상위 N 출처를 [S1]..[SN] 으로 라벨, 각 출처의 score + conflictSeverity + 발췌문. + 2. **답변 직전 자기 질문 (Pre-Output Self-Check)** — 4개 질문 (그라운딩, 단정 톤 적정성, 구체적 액션 제시, [CONFLICT WARNINGS] 와 통합). + 3. **STRICT 모드 (옵션)** — 모든 사실 주장 뒤 `[S1]` 형식 inline 인용 강제 (기본 off). + +**Wiring:** +- `memoryContext.ts` — 검색 직후 CoVe 블록 계산 → `turnCtx.coveChecklist` 저장 +- `agent.ts` — turn reset 시 비움, system prompt build 시 전달 +- `buildAstraModeSystemPrompt.ts` — `[CONFLICT WARNINGS]` 다음, `[CONTEXT]` *밖* 에 주입 (token-truncation 보호) +- Casual conversation 모드 비활성 + +**신규 설정 3개:** +- `g1nation.coveEnabled` (boolean, 기본 true) +- `g1nation.coveTopSourcesCount` (number 1~15, 기본 5) +- `g1nation.coveStrictMode` (boolean, 기본 false) — inline citation 강제 + +**Conflict Surface 와의 시너지:** +- 둘이 보완: Conflict = "충돌 데이터 제공", CoVe = "그 데이터 어떻게 verify 할지 지시" +- 함께 켜면 (기본): 모델이 충돌 출처를 식별 → 양측 명시 → 출처 ID 표기 → 사용자 판단에 위임 (3-step 그라운딩) + +**남은 작업 (#1):** +- **Actionability Scoring** — 현재 작업 상태(active slash command / 열린 파일 / 최근 ADR) 신호로 검색 재랭킹. 가장 큰 신규 설계. + +**신규 패키징:** `astra-2.2.184.vsix`. + +--- + + + +## v2.2.183 (2026-05-29) +### 🔀 추론 품질 #2 — Conflict Surface (모순 검증 로직 1단계) +ASTRA 의 `scoring.ts` 가 검색된 문서별로 `conflictSeverity` (NONE/LOW/MEDIUM/HIGH) 를 *이미 계산*하고 있었음 — 그런데 LLM 에게 *어느 출처가 충돌인지* 전달하지 않아 v4 정책의 "[CONFLICT WARNING] 플래그" 가 무용한 상태. 이번 빌드가 그 갭을 메움. + +**신규 모듈: `src/retrieval/conflictBlock.ts`** +- `buildConflictWarningsBlock(chunks, options)` — [CONFLICT WARNINGS] 시스템 프롬프트 블록 생성. +- 두 종류 충돌 모두 surface: + 1. **자기-신호 (self-flag)** — 출처 내부에서 충돌 키워드(반대/논란/vs 등) 감지된 경우 + 2. **교차-문서 발산 (cross-divergence)** — 같은 주제 ≥2 chunks 의 본문 Jaccard < 0.30 인 잠재 모순 쌍 + +**Wiring:** +- `memoryContext.ts` — `result.selectedChunks` 가 retrieval 완료 직후 conflict 블록 계산 → `turnCtx.conflictWarnings` 에 저장 +- `agent.ts` — turn 시작 시 reset, system prompt build 시 인자로 전달 +- `buildAstraModeSystemPrompt.ts` — `[CONTEXT]` *밖* 에 주입 (token-truncation 보호) +- Casual conversation 모드는 RAG 컨텍스트 자체를 안 쓰므로 충돌 경고도 생략 + +**프롬프트 지침 (LLM 에 명시):** +1. 답변에 위 출처 중 하나라도 사용한다면, 충돌 가능성을 명시 +2. 어느 쪽이 옳다고 단정하지 말고, 사용자가 판단할 수 있도록 근거 분리 제시 +3. 충돌이 답변과 무관하면 무시 가능 — 다만 무관 판단 자체도 한 줄로 기록 + +**신규 설정 3개:** +- `g1nation.conflictHighlightingEnabled` (boolean, 기본 true) — 마스터 스위치 +- `g1nation.conflictSeverityThreshold` (low/medium/high, 기본 medium) — 자기-신호 최소 severity +- `g1nation.conflictCrossDocEnabled` (boolean, 기본 true) — 교차-문서 발산 감지 활성 + +**다음 (#3, #1 예정):** +- **#3 CoVe (Chain-of-Verification)** — 결론 직전 자기 검증 루프 (할루시네이션 방지) +- **#1 Actionability Scoring** — 검색 결과 재랭킹 시 현재 작업 상태 가중치 반영 (가장 큰 신규 설계) + +**신규 패키징:** `astra-2.2.183.vsix`. + +--- + + + +## v2.2.182 (2026-05-29) +### 🌙 4인 팀 운영 10단계 — /evening 하루 마무리 카드 +`/morning` 의 짝. 하루 시작과 끝을 한 명령씩으로 단조. *오늘 무엇이 진척됐는지* 보고 → *내일 무엇을 준비할지* 확인 → *짧은 회고 한 줄* 로 마무리. + +**신규 `/evening` 명령:** +- 단일 형태 — 인자 없음. 매일 저녁 한 번 실행. + +**섹션 구성:** + +**1. ✅ 오늘의 진척** +- **작업 완료** — 오늘 00:00 이후 completed 된 Google Tasks, 멤버별 그룹. +- **📒 고객 이벤트** — `.astra/customers.jsonl` 오늘 append (add/renew/risk/churn/note). +- **🎯 채용 이벤트** — `.astra/hire.jsonl` 오늘 append (stage 이동, offer, hire 등). +- **💰 재무 기록** — `.astra/runway.jsonl` 오늘 append (잔고/지출/수입). + +**2. 🌅 내일 준비** +- 내일 마감 작업 (멤버별). +- 7일 내 갱신 — D-3 🔴 / D-7 🟡. +- 정체 후보 (7일+ 미변동) top 3. + +**3. 🧭 회고 한 줄** +- 8개 회고 질문 중 *날짜 기반 결정적 선택* — 같은 날 재실행해도 동일 질문. +- 예: "오늘 가장 중요한 한 가지는 무엇이었나? 의도한 대로 됐나?" +- 답을 어디 적으면 좋은지 안내 (`/decisions` · `/feedback` · `/customers note` · `/hire note`). + +**Tasks OAuth 실패 시:** +- "기록된 진척 없음" + Tasks 에러 메시지 별도 표시 — 다른 섹션은 정상 표시. + +**4인 팀 운영 누적 (1~10단계 — 일과 사이클 완성):** +`/decisions` · `/onesie` · `/draft` · `/feedback` · `/blocked` · `/standup` · `/runway` · `/customers` · `/hire` · `/morning` · **`/evening`** ⭐ + +**일과 ritual:** +- 아침: `/morning` (긴급 + 액션 5) +- 낮: `/blocked` `/onesie @멤버` (필요 시) +- 저녁: `/evening` (진척 + 내일 + 회고) + +**신규 패키징:** `astra-2.2.182.vsix`. + +--- + + + +## v2.2.181 (2026-05-29) +### ☀️ 4인 팀 운영 9단계 — /morning 매일 아침 통합 대시보드 +1~8단계로 만든 모든 트래커(runway / customers / hire / tasks / decisions) 의 핵심을 *한 명령*으로. CEO 의 아침 ritual — 6개 명령 안 치고 `/morning` 하나. + +**신규 `/morning` 명령:** +- `/morning` — 전체 대시보드 (긴급 + 재무 + 고객 + 팀 + 채용 + 오늘의 액션) +- `/morning brief` — 짧은 버전 (긴급 + 액션 3개만, 30초 스캔용) + +**섹션 구성:** +1. **🚨 긴급** — 런웨이 < 6개월, 위험 고객, 7일 내 갱신, 지연 작업, 정체 후보 — 자동 집계 +2. **💰 재무** — 현금 / burn / 런웨이 신호등 +3. **📒 고객** — 총 MRR + 활성/위험/이탈 + 7일 내 갱신 top 5 +4. **👥 팀** — 지연/이번 주 작업 카운트 + 멤버별 지연 top 4 +5. **🎯 채용** — 단계별 진행 한 줄 요약 + 정체 후보 top 3 +6. **📋 오늘의 액션** — 위 데이터에서 자동 도출 top 5 (휴리스틱 우선순위) + +**액션 도출 휴리스틱:** +- 우선순위: 런웨이 위험 > 위험 고객(MRR 큰 순) > 갱신 임박 > 지연 작업 > 정체 후보 > inbox 누적 +- 각 액션은 *명시적 다음 행동* 포함 ("투자자 미팅", "갱신 의사 확인", "스크리닝 시간 확보" 등) + +**데이터 부족 시 우아한 처리:** +- 각 섹션이 독립적으로 "데이터 없음 — `/runway cash` 로 시작" 안내. +- Tasks OAuth 실패해도 나머지 섹션 계속 표시. + +**4인 팀 운영 누적 (1~9단계):** +`/decisions` · `/onesie` · `/draft` · `/feedback` · `/blocked` · `/standup` · `/runway` · `/customers` · `/hire` · **`/morning`** ⭐ + +**신규 패키징:** `astra-2.2.181.vsix`. + +--- + + + +## v2.2.180 (2026-05-29) +### 👥 4인 팀 운영 8단계 — /hire 채용 파이프라인 트래커 +4인 → 5인 이상 확장 시점. 후보자가 노션·스프레드시트·이메일에 흩어지지 않게 한 명령으로 본다. `/customers` 와 동일한 event-sourced 패턴. + +**신규 `/hire` 명령 (서브명령 11개):** +- `/hire` — 파이프라인 대시보드 (역할별/단계별 현황, 정체 알림) +- `/hire add <이름> <역할>` — inbox 단계 신규 후보 +- `/hire stage <이름> <새 단계>` — 단계 이동 +- `/hire note <이름> <텍스트>` — 자유 메모 +- `/hire offer <이름> <연봉> [입사일]` — 오퍼 발송 (단계=offer) +- `/hire hire <이름> [입사일]` — 입사 확정 (단계=hired) +- `/hire reject <이름> <사유>` — 거절 (회사 측) +- `/hire decline <이름> <사유>` — 후보 사양 +- `/hire show <이름>` — 상세 + 이력 (부분 매치 OK) +- `/hire list [active/closed/단계명/역할]` — 필터 목록 +- `/hire path` — 파일 위치 + +**기본 파이프라인:** +inbox → screened → interview → final → offer → accepted → hired +(터미널: rejected · declined) + +**대시보드 핵심:** +- 진행 중 / 합격 / 종료 카운트 +- **역할별 진행** (백엔드 N명 · 디자이너 M명) — 채용 균형 시야 +- **단계별 후보** + 각자 정체 일수 (`⏰ N일 정체` — 7일 이상 미변동 시) +- 최근 합격 5명 (입사일 순) + +**저장 — 로컬 only:** +- `/.astra/hire.jsonl` (append-only 이벤트) +- 민감 정보(이름, 연봉, 거절 사유) 외부 안 보냄. + +**4인 팀 운영 누적 (1~8단계):** +`/decisions` · `/onesie` · `/draft` · `/feedback` · `/blocked` · `/standup` · `/runway` · `/customers` · **`/hire`** ⭐ + +**신규 패키징:** `astra-2.2.180.vsix`. + +--- + + + +## v2.2.179 (2026-05-28) +### 📒 4인 팀 운영 7단계 — /customers 고객사/MRR/갱신/위험 트래커 +`/runway` 가 *지출 쪽* 시야라면 `/customers` 는 *수입 쪽*. Salesforce 없이도 4인 기업이 알아야 하는 모든 매출 정보 — MRR, 갱신 임박, 위험 고객 — 를 한 명령으로. + +**Event-sourced 설계 (CRM 아님):** +- 모든 변경(add/update/renew/risk/churn/note) = append-only 이벤트. +- 현재 상태 = 같은 customerId 의 이벤트를 시간순 재생. +- 손상·실수 복구 쉬움 — .jsonl 한 줄만 지우면 그 이벤트만 무효화. + +**신규 `/customers` 명령 (서브명령 10개):** +- `/customers` — 대시보드 (총 MRR, 활성/위험/이탈, 30일 내 갱신, 위험 MRR 비율) +- `/customers add <이름> [갱신일] [요금제]` — 신규 +- `/customers update <이름> mrr=… renewal=… plan=…` — 정보 수정 +- `/customers renew <이름> <새 갱신일> [새 MRR]` — 갱신 처리 (active 복귀) +- `/customers risk <이름> <사유>` — 위험 표시 +- `/customers churn <이름> <사유>` — 이탈 처리 (MRR 0 으로) +- `/customers note <이름> <텍스트>` — 자유 메모 +- `/customers show <이름>` — 상세 + 이벤트 히스토리 (부분 매치 OK) +- `/customers list [active/risk/churned]` — 필터 목록 +- `/customers path` — 파일 경로 + +**대시보드 핵심 지표:** +- 총 MRR + 연 환산 (자동) +- 활성/위험/이탈 카운트 +- **위험 MRR 비율** — 전체 MRR 의 몇 %가 위험에 노출됐는지 (이 숫자 보고 우선순위) +- **30일 내 갱신** — D-7 🔴 / D-14 🟡 / D-30 🟢 신호등 +- MRR 순 활성 고객 top 10 + +**금액 단위:** `200만` / `1.5억` / `300000` 모두 OK (한국식). +**갱신일:** `YYYY-MM-DD`. + +**저장 — 로컬 only:** +- `/.astra/customers.jsonl` (append-only 이벤트 로그) +- 민감 정보(고객사 이름, 매출) 외부 안 보냄. + +**4인 팀 운영 누적 (1~7단계):** +`/decisions` · `/onesie` · `/draft` · `/feedback` · `/blocked` · `/standup` · `/runway` · **`/customers`** ⭐ + +**신규 패키징:** `astra-2.2.179.vsix`. + +--- + + + +## v2.2.178 (2026-05-28) +### 💰 4인 팀 운영 6단계 — /runway 현금/소진율/런웨이 트래커 +4인 기업 CEO 가 매일·매주 확인하는 가장 중요한 숫자 — *통장에 얼마, 한 달에 얼마 나감, 몇 개월 버틸 수 있나*. 회계 SaaS 연동 없이 한 명령으로 한 화면. + +**신규 `/runway` 명령 (서브명령 7개):** +- `/runway` — 현재 상태 카드 (현금 / burn / 런웨이 + 위험 등급) +- `/runway cash <금액> [메모]` — 통장 잔고 스냅샷 +- `/runway expense <금액> [메모]` — 지출 기록 (자동 burn 계산에 반영) +- `/runway revenue <금액> [메모]` — 수입 기록 (burn 상쇄) +- `/runway burn <월 금액>` — 월 소진율 수동 설정 (자동 계산보다 우선) +- `/runway log [N]` — 최근 N건 (기본 20, 최신순) +- `/runway path` — .jsonl 파일 위치 + +**금액 파싱 — 한국식 단위 그대로:** +- `5000만` / `1.5억` / `300000` / `1,500,000` / `10k` / `5m` 모두 OK. + +**런웨이 위험 등급:** +- 🟢 6개월+ (안전) +- 🟡 3~6개월 (자금 계획 점검 권장) +- 🔴 3개월 미만 (즉시 조달/절감 필요) +- ♾️ 흑자 운영 (지출 ≤ 수입) + +**Burn 계산 우선순위:** +1. `/runway burn` 으로 수동 설정한 값 (예측 가능, 계절성 무시) +2. 최근 30일 실적 (expense − revenue, 30일 미만이면 일평균 × 30 환산) + +**저장 — 로컬 only:** +- `/.astra/runway.jsonl` (append-only, 사람 편집 가능) +- 민감 정보(현금 잔고) 외부 안 보냄 — ASTRA local-first. + +**4인 팀 운영 누적 (1~6단계):** +`/decisions` · `/onesie` · `/draft` · `/feedback` · `/blocked` · `/standup` · **`/runway`** ⭐ + +**신규 패키징:** `astra-2.2.178.vsix`. + +--- + + + +## v2.2.177 (2026-05-28) +### 📊 4인 팀 운영 5단계 — /standup 공유용 스탠드업 카드 +`/blocked` 는 *대표가 본다* (분석), `/standup` 은 *팀에 공유한다* (소통). 같은 Tasks 데이터지만 다른 포맷·관점. + +**신규 `/standup [daily/weekly/monthly]` 명령:** +- 기본: `weekly` (7일 윈도우). 옵션: `daily` (1일) · `monthly` (30일). +- **멤버별 그룹핑 + 3-row 표준 스탠드업 포맷**: + - ✅ 완료 (윈도우 내 완료, 최대 8건) + - 🎯 진행/예정 (다가오는 마감 + 마감 미정, 최대 6건) + - 🚧 블로커 (지연된 항목, 최대 5건) +- 멤버 정렬: 활동량(완료+진행+지연×2) 내림차순 — 가장 바쁜 사람 위. +- 부록: **이번 기간 결정 (Chronicle ADR)** 목록, 최대 10건. +- 출력: 단순 마크다운 — **슬랙·노션 그대로 복붙 가능**. + +**`/blocked` vs `/standup`:** +- `/blocked`: 대표 시야 확보용. 멤버별 압박 요약 + `/onesie` 안내. 매일 아침. +- `/standup`: 팀 공유용. 표준 standup 3-row. 매일/매주 게시. + +**4인 팀 운영 누적 (1~5단계):** +`/decisions` · `/onesie` · `/draft` · `/feedback` · `/blocked` · **`/standup`** ⭐ + +**신규 패키징:** `astra-2.2.177.vsix`. + +--- + + + +## v2.2.176 (2026-05-28) +### 🚨 4인 팀 운영 4단계 — /blocked 전사 across 지연·블로커 뷰 +대표의 *일일 아침 ritual* 도구. `/onesie` 가 멤버 단위라면 `/blocked` 는 전체 across 한 화면 — "오늘 어디 살펴봐야 하나" 즉시 잡힘. + +**신규 `/blocked [@멤버]` 명령:** +- Google Tasks 전체 조회 후 owner 자동 추출(제목 `[멤버]` prefix · notes `담당: 이름` 패턴 모두 인식 — `/task` 와 `/meet` 양쪽 호환). +- **3개 섹션 자동 분류:** + - 🔴 지연 (마감 < 오늘) — 가장 오래 지연된 것부터, 최대 20건 + - 🟡 이번 주 마감 (오늘~+7일) — 임박 순, 최대 15건 + - ⚪ 마감일 없음 — owner 별, 우선순위 합의 대상, 최대 10건 +- **멤버별 압박 요약** (전체 보기일 때) — 지연·이번주 가중치로 정렬 → 압박 큰 멤버부터 `/onesie @<멤버>` 안내. +- `@멤버` 인자 시 그 멤버만 (압축된 1인 뷰). + +**사용 예:** +- `/blocked` → 매일 아침 전체 점검 +- `/blocked @기획자` → 기획자만 압축 뷰 + +**4인 팀 운영 누적 (1~4단계 완성):** +- `/decisions` · `/onesie` · `/draft` · `/feedback` · **`/blocked`** ⭐ + +**신규 패키징:** `astra-2.2.176.vsix`. + +--- + + + +## v2.2.175 (2026-05-28) +### 📥 4인 팀 운영 3단계 — /feedback 고객 신호 누적 + 패턴 분석 +3단계: 슬랙·이메일·CS 채널에 흩어진 고객 피드백을 한 곳에 모으고 LLM이 자동 분류·패턴 분석. **ASTRA local-first 의 강점이 가장 살아나는 영역** — 민감 정보도 외부 전송 없이 로컬 처리. + +**신규 모듈 [feedbackStore.ts](src/features/feedback/feedbackStore.ts):** +- 저장 위치: `/.astra/customer-feedback.jsonl` (한 줄 = 한 항목, append-only, 사람이 직접 편집 가능). +- `FeedbackEntry` 타입: id / timestamp / text / source / categories / sentiment. + +**신규 `/feedback` 4-mode 명령:** +- `/feedback <텍스트>` — 저장 + **LLM 자동 카테고리 분류** (1~3개 카테고리 + sentiment positive/neutral/negative). LLM 분류 실패해도 원본은 보존. +- `/feedback list [카테고리]` — 누적 목록 (최신순, 최대 20건). 카테고리 필터 가능. +- `/feedback summary` — 3건 이상 누적 시 LLM 패턴 분석 리포트 (카테고리 분포 + 감정 분포 + 반복 패턴 Top 3 + 추천 액션). +- `/feedback path` — 저장 파일 경로 표시. + +**사용 예:** +- `/feedback 결제 흐름이 너무 복잡해서 중간에 포기했어요 - 김XX` +- `/feedback list 결제` → 결제 카테고리 누적 피드백만 +- `/feedback summary` → 전체 패턴 리포트 + +**다음 단계 후보** (별도): `/blocked` 전사 블로커 뷰, /feedback 자동 주간 리포트 (매주 월요일). + +**신규 패키징:** `astra-2.2.175.vsix`. + +--- + + + +## v2.2.174 (2026-05-27) +### 🏢 4인 팀 운영 2단계 — /onesie 1:1 카드 + /draft 외부 커뮤니케이션 초안 +2단계 묶음 (1단계는 v2.2.173 `@담당자` 태그 + `/decisions`). 단독 운영자(대표)의 주간 ritual 효율화. + +**신규 `/onesie [멤버]` — 1:1 미팅 준비 카드:** +- Google Tasks API 전체 조회 후 멤버 필터 (제목 `[멤버]` prefix · notes `@멤버` · notes `담당: 멤버` 패턴 모두 인식). +- 자동 카테고리: **최근 30일 완료 / 지연 / 진행 중·다가오는 / 마감일 없음**. +- Chronicle ADR 에서 해당 멤버 언급된 결정 최신 5건 동시 표시. +- **자동 대화 토픽 제안** — 상태 신호(지연 多 → 블로커, 완료 0 → 시간 사용 확인, 마감일 없음 多 → 우선순위 합의)에서 유도. +- 사용 예: `/onesie 기획자` · `/onesie @디자이너` · `/onesie 개발`. + +**신규 `/draft [유형] [요청]` — 외부 커뮤니케이션 초안:** +- 6개 유형: `email` · `slack` · `blog` · `newsletter` · `investor-update` · `proposal`. +- 각 유형별 분량/구조/톤 가이드 자동 적용. +- 신규 설정 **`g1nation.teamVoiceGuide`** (multilineText) — 팀 보이스 가이드를 한 번 저장하면 모든 초안에 자동 반영. +- 사용 예: `/draft email 김 대표님께 미팅 일정 조율 요청, 다음 주 가능 시간 3개 제안` + +**토대 — `listTasks` Google Tasks API 조회:** +- [tasksApi.ts](src/features/calendar/tasksApi.ts): `listTasks(context, {showCompleted, maxResults})` — 신규 `ListedTask` 타입 (id/title/status/due/completed/notes). +- /onesie 외 향후 `/blocked`, 진행률 리포트 등에 재활용 가능. + +**신규 패키징:** `astra-2.2.174.vsix`. + +--- + + + +## v2.2.173 (2026-05-27) +### 🏢 4인 팀 운영 — @담당자 태그 + /decisions 결정 검색 +4인 단독 운영 모드(대표가 모든 데이터 입력 + ASTRA가 조회·합성·재활용 담당)의 두 가지 토대 기능. + +**`/task` 에 `@담당자` 태그 추가:** +- 첫 토큰이 `@` 로 시작하면 owner 로 파싱 → 제목 앞에 `[담당자]` prefix 자동 추가. +- 사용 예: `/task @기획자 스펙 1차 정리 26/06/01 26/06/05` → Tasks/Calendar 양쪽에 `[기획자] 스펙 1차 정리` 로 표시. +- 노트에도 `담당: @기획자` 한 줄 자동 기록 → `/decisions` 가 grep 으로 찾을 수 있음. + +**신규 `/decisions [키워드] [@담당자]` — Chronicle ADR 검색:** +- 이미 chronicle 이 쌓고 있는 결정 기록(`/decisions/ADR-NNNN-*.md`)을 키워드 + 담당자로 검색. +- 모든 등록된 chronicle 프로젝트 일괄 스캔, 최신순, 최대 20건. +- 출력: 제목 + 날짜 + 프로젝트 + 키워드 주변 스니펫 + 파일 경로. +- 사용 예: `/decisions 환불 정책` · `/decisions @기획자` · `/decisions 결제 @개발` +- 인자 없이 실행 시 도움말 표시. + +**다음 단계 후보** (별도 작업): `/onesie [멤버]` — 1:1 미팅 준비 카드, `/draft [유형]` — 외부 커뮤니케이션 초안. + +**신규 패키징:** `astra-2.2.173.vsix`. + +--- + + + ## v2.2.172 (2026-05-27) ### 🎛️ /meet — 기본값을 Tasks 단독 등록으로 변경 (중복 방지) - **`g1nation.meetUsesCalendar` 기본값 `true` → `false`.** Tasks 도 Calendar 사이드바에 같이 보이므로 둘 다 켜져 있으면 동일 항목이 중복 노출(일정 + 할 일 각 1건씩)되던 문제 해결. diff --git a/docs/records/ConnectAI/chronicle.config.json b/docs/records/ConnectAI/chronicle.config.json index 420053c..d5271ae 100644 --- a/docs/records/ConnectAI/chronicle.config.json +++ b/docs/records/ConnectAI/chronicle.config.json @@ -7,5 +7,5 @@ "corePurpose": "", "detailLevel": "standard", "createdAt": "2026-05-20T09:42:40.003Z", - "updatedAt": "2026-05-26T08:10:03.968Z" + "updatedAt": "2026-05-29T06:58:44.615Z" } diff --git a/docs/records/ConnectAI/decisions/ADR-0026-질문이-있어-내가-당근이라는-중고-거래-사이트에서-8tb-hdd를-구매했어-안전거래-escrow-를-사용했어.md b/docs/records/ConnectAI/decisions/ADR-0026-질문이-있어-내가-당근이라는-중고-거래-사이트에서-8tb-hdd를-구매했어-안전거래-escrow-를-사용했어.md new file mode 100644 index 0000000..007631e --- /dev/null +++ b/docs/records/ConnectAI/decisions/ADR-0026-질문이-있어-내가-당근이라는-중고-거래-사이트에서-8tb-hdd를-구매했어-안전거래-escrow-를-사용했어.md @@ -0,0 +1,19 @@ +# ADR: 질문이 있어, 내가 당근이라는 중고 거래 사이트에서 8TB HDD를 구매했어. 안전거래 (Escrow)를 사용했어. 일단 물건을 어제 받았고, ... + +## Status +accepted + +## Context +질문이 있어, 내가 당근이라는 중고 거래 사이트에서 8TB HDD를 구매했어. 안전거래 (Escrow)를 사용했어. 일단 물건을 어제 받았고, 어제 설치했는데 하드가 사망한것 같아. 인지가 안되더라고. 그래서 일단 당근 마켓에 분쟁 신청을 했고, 판매자에게 채팅 메시지를 보냈어. 당근 내용을 보니 담당자가 나와 판매자간의 대화 내용을 데이터로 사용하기 위해 모니터링 한다고 하더라고. 그러곤 오늘 판매자가 통화 요청을 했어. 일단 통화는 어렵고, 당근 채팅 창에서만 대화를 해야 기록에 남을 것 같다고 함. 그러곤 당근에서 연락옴. 일단 배송업체에서 어떤 답변을 받는지 기다리고 답변을 받으면 다시 논의 하자고 답변을 줌. 내가 뭘해야해? + +## Decision +결론: 현재 상황에서 가장 중요한 것은 '추가적인 증거 확보'와 '당근 마켓 가이드라인에 따른 기록 유지'이며, 판매자에게는 '물건의 결함'을 입증할 객관적 자료를 제시해야 합니다. 지금 사용자가 처한 상황은 '물품 하자(사망)'로 인한 분쟁 중이며, 당근 측에서 모니터으로를 통해 대화 내용을 검토하고 있으므로, 감정적인 대응보다는 객상적인 데이터로 승부해야 하는 시점입니다. 다음의 단계별 행동 지침을 따르시기 바랍니다. 1. 객관적 결함 증거 확보 (가장 중요) 판매자가 '통화'를 요청했다는 것은 채팅 기록 외에 구두로 상황을 무마하거나, 사용자의 논리를 흐리려는 의도가 있을 수 있습니다. 따라서 반드시 '시각적/기술적 증거'를 먼저 만드세요. - 사진/영상 촬영: 하드가 컴퓨터에 연결된 상태에서 '인식되지 않는 화면(디스크 관리 창)'을 사진이나 영상으로 찍으세요. 8TB 용량이 표시되지 않거나, '알 수 없는 영역'으로 뜨는 것을 보여줘야 합니다. - S.M.A.T/로그 확인:... + +## Reason +Captured automatically because the conversation contained decision-oriented language. + +## Alternatives +Not captured yet. + +## Consequences +- Future prompts should treat this as project context unless the user changes direction. diff --git a/docs/records/ConnectAI/decisions/ADR-0027-connectai-프로젝트의-아스트라-대부분-답을-잘-줘-근대-간혹-오타가-발생하거나-상황에-맞지-않는-단어.md b/docs/records/ConnectAI/decisions/ADR-0027-connectai-프로젝트의-아스트라-대부분-답을-잘-줘-근대-간혹-오타가-발생하거나-상황에-맞지-않는-단어.md new file mode 100644 index 0000000..11c724b --- /dev/null +++ b/docs/records/ConnectAI/decisions/ADR-0027-connectai-프로젝트의-아스트라-대부분-답을-잘-줘-근대-간혹-오타가-발생하거나-상황에-맞지-않는-단어.md @@ -0,0 +1,19 @@ +# ADR: connectAI 프로젝트의 아스트라 대부분 답을 잘 줘. 근대 간혹 오타가 발생하거나 상황에 맞지 않는 단어를 간혹 사용해서 혼선을 줄때가 있... + +## Status +accepted + +## Context +connectAI 프로젝트의 아스트라 대부분 답을 잘 줘. 근대 간혹 오타가 발생하거나 상황에 맞지 않는 단어를 간혹 사용해서 혼선을 줄때가 있거든. 이 부분을 개선하려면 어떠한 부분을 개선하는게 좋을까? + +## Decision +결론: 아스트라의 답변에서 발생하는 오타나 부적절한 단어 선택(Hallucination/Typo)을 개선하기 위해서는, 모델 자체의 성능에 의존하기보다 '출력 전 검증 로직(Post-processing)'과 '프롬프트 내 제약 조건(Constraint)의 정밀화'를 강화해야 합니다. 현재 아스트라는 지식(Second Brain)을 활용하는 능력은 뛰어나지만, 생성된 텍스트의 '품질 관리(QA)' 단계가 부족한 상태입니다. 이를 해결하기 위한 3가지 기술적 개선 방안을 제안합니다. 1. 출력 검증 레이어 도입 (Self-Correction Loop) 모델이 답변을 생성한 직후, 사용자에게 전달하기 전에 스스로 오류를 찾아내는 단계를 추가해야 합니다. - 강화 방안: `[Post-generation Check]` 단계를 시스템 프롬프트에 명시합니다. - "작성된 답변에 프로젝트의 핵심 용어(예: `connectai`, `p-rein동`)가 올바른 철자로 작성되었는지 확인하라." - "문... + +## Reason +Captured automatically because the conversation contained decision-oriented language. + +## Alternatives +Not captured yet. + +## Consequences +- Future prompts should treat this as project context unless the user changes direction. diff --git a/docs/records/ConnectAI/decisions/ADR-0028-질문이-있어-내가-당근이라는-중고-거래-사이트에서-8tb-hdd를-구매했어-안전거래-escrow-를-사용했어.md b/docs/records/ConnectAI/decisions/ADR-0028-질문이-있어-내가-당근이라는-중고-거래-사이트에서-8tb-hdd를-구매했어-안전거래-escrow-를-사용했어.md new file mode 100644 index 0000000..2f5fc2d --- /dev/null +++ b/docs/records/ConnectAI/decisions/ADR-0028-질문이-있어-내가-당근이라는-중고-거래-사이트에서-8tb-hdd를-구매했어-안전거래-escrow-를-사용했어.md @@ -0,0 +1,19 @@ +# ADR: 질문이 있어, 내가 당근이라는 중고 거래 사이트에서 8TB HDD를 구매했어. 안전거래 (Escrow)를 사용했어. 일단 물건을 어제 받았고, ... + +## Status +accepted + +## Context +질문이 있어, 내가 당근이라는 중고 거래 사이트에서 8TB HDD를 구매했어. 안전거래 (Escrow)를 사용했어. 일단 물건을 어제 받았고, 어제 설치했는데 하드가 사망한것 같아. 인지가 안되더라고. 그래서 일단 당근 마켓에 분쟁 신청을 했고, 판매자에게 채팅 메시지를 보냈어. 당근 내용을 보니 담당자가 나와 판매자간의 대화 내용을 데이터로 사용하기 위해 모니터링 한다고 하더라고. 그러곤 오늘 판매자가 통화 요청을 했어. 일단 통화는 어렵고, 당근 채팅 창에서만 대화를 해야 기록에 남을 것 같다고 함. 그러곤 당근에서 연락옴. 일단 배송업체에서 어떤 답변을 받는지 기다리고 답변을 받으면 다시 논의 하자고 답변을 줌. 내가 뭘해야해? + +## Decision +결론: 현재 상황에서 가장 중요한 것은 '추가적인 증거 확보'와 '당근 마켓 가이드라인에 따른 기록 유지'이며, 판매자에게는 '물건의 결함'을 입증할 객관적 자료를 제시해야 합니다. 지금 사용자가 처한 상황은 '물품 하자(사망)'로 인한 분쟁 중이며, 당근 측에서 모니터링을 통해 대화 내용을 검토하고 있으므로, 감정적인 대응보다는 객관적인 데이터로 승부해야 하는 시점입니다. 다음의 단계별 행동 지침을 따르시기 바랍니다. 1. 객관적 결함 증거 확보 (가장 중요) 판매자가 '통화'를 요청했다는 것은 채팅 기록 외에 구두로 상황을 무마하거나, 사용자의 논리를 흐리려는 의도가 있을 수 있습니다. 따라서 반드시 '시각적/기술적 증거'를 먼저 만드세요. - 사진/영상 촬영: 하드가 컴퓨터에 연결된 상태에서 '인식되지 않는 화면(디스크 관리 창)'을 사진이나 영상으로 찍으세요. 8TB 용량이 표시되지 않거나, '알 수 없는 영역'으로 뜨는 것을 보여줘야 합니다. - S.M.A.T/로그 확인: ... + +## Reason +Captured automatically because the conversation contained decision-oriented language. + +## Alternatives +Not captured yet. + +## Consequences +- Future prompts should treat this as project context unless the user changes direction. diff --git a/docs/records/ConnectAI/development/2026-05-29_e-wiki-connectai-프로젝트에-대해서-이야기-하는거야_implementation.md b/docs/records/ConnectAI/development/2026-05-29_e-wiki-connectai-프로젝트에-대해서-이야기-하는거야_implementation.md new file mode 100644 index 0000000..e23ec2e --- /dev/null +++ b/docs/records/ConnectAI/development/2026-05-29_e-wiki-connectai-프로젝트에-대해서-이야기-하는거야_implementation.md @@ -0,0 +1,28 @@ +# Development Log: E:\Wiki\connectai 프로젝트에 대해서 이야기 하는거야. + +## Purpose +Record the implementation or verification outcome from the current conversation. + +## Implementation Summary +결론: `E:\Wiki\connectai` 프로젝트의 맥락에서, 방대한 지식 중 의미 있는 것을 발굴하는 것은 결국 '검색된 정보(Retrieved)와 현재 작업(Active Task) 사이의 논리적 간극을 어떻게 메울 것인가'의 문제입니다. 현재 아스트라는 `connectai` 프로젝트의 구조를 파악하고 있으며, 이 프로젝트의 핵심은 단순한 지식 저장이 아니라 '지능형 에이전트의 실행과 관리'에 있습니다. 따라서 지식을 발굴할 때 다음 세 가지 관점을 적용하면 훨씬 강력한 추론이 가능해집니다. 1. 프로젝트 의존성 기반의 확장 검색 (Dependency-aware Retrieval) - `connectai`는 VS Code Extension이며, `package.json`, `src/agent.ts`, `src/config.ts` 등 상호 연결된 모듈로 구성되어 있습니다. - 지식을 가져올 때 단순히 텍mplate 파일만 보는 것이 아니라, "이 설정(`config.ts`)이 변경... + +## Architecture +Captured automatically from the assistant response and active project context. + +## Changed Files +- package.json +- src/agent.ts +- src/config.ts +- config.ts +- agent-knowledge-map.json +- architecture.md +- skills/*.md + +## Dependency Notes +No new dependency note was captured automatically. + +## Bugs +No bugs recorded. + +## Lessons +- Automatic project records should be generated in the background when the turn contains durable project knowledge. diff --git a/docs/records/ConnectAI/development/2026-05-29_아스트라를-지금-보다-더-날카롭고-신뢰감이-높은-아이-그리고-나의-의도를-잘-파악하게-하기-위해서는-어떤부분_implementation.md b/docs/records/ConnectAI/development/2026-05-29_아스트라를-지금-보다-더-날카롭고-신뢰감이-높은-아이-그리고-나의-의도를-잘-파악하게-하기-위해서는-어떤부분_implementation.md new file mode 100644 index 0000000..2a3577e --- /dev/null +++ b/docs/records/ConnectAI/development/2026-05-29_아스트라를-지금-보다-더-날카롭고-신뢰감이-높은-아이-그리고-나의-의도를-잘-파악하게-하기-위해서는-어떤부분_implementation.md @@ -0,0 +1,22 @@ +# Development Log: 아스트라를 지금 보다 더 날카롭고 신뢰감이 높은 아이 그리고 나의 의도를 잘 파악하게 하기 위해서는 어떤부분을 강화하는게 좋을까? + +## Purpose +Record the implementation or verification outcome from the current conversation. + +## Implementation Summary +결론: 아스트라가 사용자의 의도를 정확히 꿰뚫고(Sharpness) 신뢰할 수 있는 답변(Reliability)을 내놓기 위해서는, '추론 전 단계의 맥락 정밀화(Pre-inference Contextualization)'와 '추론 후 검증 루프(Post-inference Verification)'를 강화해야 합니다. 단순히 모델의 성능에 의존하는 것이 아니라, 아스트라가 스스로 '생각의 구조'를 설계할 수 있는 장치를 만드는 것이 핵심입니다. 이를 위한 3가지 강화 전략을 제안합니다. 1. 사용자 의도 파악의 정밀화: Intent-to-Context Mapping 현재 아스트라는 사용자의 질문을 받으면 즉시 답변을 생성하려 합니다. 하지만 '날카로운' 파악을 위해서는 질문 뒤에 숨겨진 '실행 목적'을 먼저 정의해야 합니다. - 강화 방안: 질문이 들어오면 바로 추론하지 않고, [의도 분류 $\rightarrow$ 필요 컨텍스트 식별 $\rightarrow$ 누락된 정보 확인] 단계... + +## Architecture +Captured automatically from the assistant response and active project context. + +## Changed Files +- src/agent.ts + +## Dependency Notes +No new dependency note was captured automatically. + +## Bugs +No bugs recorded. + +## Lessons +- Automatic project records should be generated in the background when the turn contains durable project knowledge. diff --git a/docs/records/ConnectAI/timeline.md b/docs/records/ConnectAI/timeline.md index 0b27aaf..d48fab9 100644 --- a/docs/records/ConnectAI/timeline.md +++ b/docs/records/ConnectAI/timeline.md @@ -210,3 +210,18 @@ ## 2026-05-26 - Auto decision record created: decisions\ADR-0025-e-wiki-connectai-프로젝트에-대한-너의-평가-해줘.md + +## 2026-05-29 +- Auto development record created: development\2026-05-29_e-wiki-connectai-프로젝트에-대해서-이야기-하는거야_implementation.md + +## 2026-05-29 +- Auto development record created: development\2026-05-29_아스트라를-지금-보다-더-날카롭고-신뢰감이-높은-아이-그리고-나의-의도를-잘-파악하게-하기-위해서는-어떤부분_implementation.md + +## 2026-05-29 +- Auto decision record created: decisions\ADR-0026-질문이-있어-내가-당근이라는-중고-거래-사이트에서-8tb-hdd를-구매했어-안전거래-escrow-를-사용했어.md + +## 2026-05-29 +- Auto decision record created: decisions\ADR-0027-connectai-프로젝트의-아스트라-대부분-답을-잘-줘-근대-간혹-오타가-발생하거나-상황에-맞지-않는-단어.md + +## 2026-05-29 +- Auto decision record created: decisions\ADR-0028-질문이-있어-내가-당근이라는-중고-거래-사이트에서-8tb-hdd를-구매했어-안전거래-escrow-를-사용했어.md diff --git a/package-lock.json b/package-lock.json index 6e3125c..0861a7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "astra", - "version": "2.2.172", + "version": "2.2.193", "license": "MIT", "dependencies": { "@lmstudio/sdk": "^1.5.0", diff --git a/package.json b/package.json index e602fb2..87c2dfe 100644 --- a/package.json +++ b/package.json @@ -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.172", + "version": "2.2.193", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", @@ -252,6 +252,12 @@ "default": false, "markdownDescription": "`/meet` 액션 아이템을 **Google Calendar** 일정(all-day)으로도 등록할지 여부. **기본 `false`** — Tasks 단독 등록으로 중복 방지 (Tasks 도 캘린더 사이드바에 같이 보이므로 둘 다 켜면 중복). true 로 켜면 Tasks + Calendar 양쪽 모두 등록." }, + "g1nation.teamVoiceGuide": { + "type": "string", + "default": "", + "editPresentation": "multilineText", + "markdownDescription": "`/draft` 외부 커뮤니케이션 초안 작성 시 모든 생성에 적용되는 **팀 보이스 가이드**. 말투/금기 표현/자주 쓰는 표현/회사 약어 정의 등을 자유 형식으로. 예: '회사명은 항상 \"Astra\" 로 표기. 존댓말 기본. 이모지는 슬랙에서만. 약어 ASAP/FYI 사용 금지...' 비워두면 가이드 없이 일반 초안 생성." + }, "g1nation.memoryEnabled": { "type": "boolean", "default": true, @@ -550,6 +556,148 @@ "maximum": 1, "description": "Hybrid score blend: 0 = pure TF-IDF (sparse / keyword), 1 = pure embedding cosine (dense / semantic), 0.5 = balanced. Only used when g1nation.embeddingModel is set. Default 0.5." }, + "g1nation.conflictHighlightingEnabled": { + "type": "boolean", + "default": true, + "description": "Conflict Surface — 검색된 출처에서 충돌/논란 신호 감지 시 [CONFLICT WARNINGS] 블록을 시스템 프롬프트에 주입. LLM 이 상충되는 관점을 명시하고 사용자 판단에 위임하도록. 기본 켜짐." + }, + "g1nation.conflictSeverityThreshold": { + "type": "string", + "enum": ["low", "medium", "high"], + "default": "medium", + "description": "Conflict 자기-신호 surface 시 최소 severity 임계. low=가장 민감(노이즈 가능), medium=균형(기본), high=강한 충돌만." + }, + "g1nation.conflictCrossDocEnabled": { + "type": "boolean", + "default": true, + "description": "교차-문서 발산 감지 — 같은 주제 ≥2 chunks 의 본문 Jaccard < 0.30 인 잠재 모순 쌍을 [CONFLICT WARNINGS] 에 함께 표시. 기본 켜짐." + }, + "g1nation.coveEnabled": { + "type": "boolean", + "default": true, + "description": "Chain-of-Verification (CoVe) — 답변 *작성 전* 그라운딩 체크리스트를 시스템 프롬프트에 주입해 모델이 self-verify 하도록. 할루시네이션 방지 + 출처 명확화. 기본 켜짐." + }, + "g1nation.coveTopSourcesCount": { + "type": "number", + "default": 5, + "minimum": 1, + "maximum": 15, + "description": "CoVe 체크리스트에 나열할 상위 출처 개수. 너무 많으면 프롬프트 비대, 너무 적으면 그라운딩 부족. 기본 5." + }, + "g1nation.coveStrictMode": { + "type": "boolean", + "default": false, + "description": "CoVe Strict 모드 — 모든 사실 주장 뒤에 출처 ID [S1] 형식으로 inline 인용 강제. 답변이 학술적·verbose 해질 수 있어 기본 off." + }, + "g1nation.actionabilityEnabled": { + "type": "boolean", + "default": true, + "description": "Actionability Scoring — '현재 작업 상태' 신호(최근 슬래시 명령 + 열린 파일) 로 검색 결과를 재가중. 지금 작업 중인 컨텍스트와 직접 연결된 문서를 우선. 기본 켜짐." + }, + "g1nation.distillationEnabled": { + "type": "boolean", + "default": true, + "description": "Distillation Loop — stale Episodic Memory 를 LongTerm 'episode-digest' 로 승급해 검색 노이즈 방지. /memory distill 수동 + 세션 종료 시 자동 트리거. 기본 켜짐." + }, + "g1nation.distillationAgeThresholdDays": { + "type": "number", + "default": 30, + "minimum": 1, + "maximum": 365, + "description": "며칠 이상 지난 episode 를 distill 대상으로 할지. 기본 30일." + }, + "g1nation.distillationIntervalDays": { + "type": "number", + "default": 7, + "minimum": 1, + "maximum": 90, + "description": "자동 distillation 의 최소 간격 (일). 마지막 실행 후 이 일수가 지나야 재실행. 기본 7일." + }, + "g1nation.distillationArchiveMode": { + "type": "string", + "enum": ["mark-promoted", "archive-file"], + "default": "mark-promoted", + "description": "Distillation 후 원본 episode 처리: 'mark-promoted'=플래그만 (파일 보존, 기본), 'archive-file'=memory/episodes/archive/ 로 파일 이동." + }, + "g1nation.hierarchicalReweightEnabled": { + "type": "boolean", + "default": true, + "description": "Hierarchical Context Window — 질의·문서 추상도(concrete/operational/strategic) 매칭으로 검색 결과 재가중. 같은 레벨 +15%, 양 끝 mismatch -30%. LLM 호출 없음, 결정적. 기본 켜짐." + }, + "g1nation.semanticRerankEnabled": { + "type": "boolean", + "default": false, + "description": "Semantic Re-ranking — 검색된 selectedChunks 의 순서를 LLM 한 번 호출로 의도-부합도 순 재정렬. 매 turn 1회 추가 LLM 호출 (latency 비용). 기본 OFF — 명시적 on 해야 함." + }, + "g1nation.semanticRerankModel": { + "type": "string", + "default": "", + "description": "Semantic Re-ranking 전용 모델 ID. 비면 defaultModel 사용. 빠른 작은 모델(예: gemma2:2b) 권장 — latency 줄임." + }, + "g1nation.semanticRerankCandidateK": { + "type": "number", + "default": 15, + "minimum": 2, + "maximum": 30, + "description": "Re-rank 대상 상위 후보 개수. 많을수록 quality↑ latency↑ token↑. 기본 15." + }, + "g1nation.semanticRerankTimeoutSec": { + "type": "number", + "default": 8, + "minimum": 1, + "maximum": 60, + "description": "Re-rank LLM 호출 타임아웃 (초). 초과 시 원순서 그대로 — 검색 실패 안 됨. 기본 8." + }, + "g1nation.intentClarificationEnabled": { + "type": "boolean", + "default": true, + "description": "Intent Clarification — 모호 질의(환경/대상/범위/포맷/마감 누락) 감지 시 LLM 에게 추측 답변보다 *역질문 우선* 지시. 기본 켜짐." + }, + "g1nation.intentClarificationStrictness": { + "type": "string", + "enum": ["low", "medium", "high"], + "default": "medium", + "description": "모호 판정 임계. low=가장 덜 묻기(2개+ missing), medium=균형(1개+), high=가장 자주 묻기 (1개+ OR 짧은 질의+trigger)." + }, + "g1nation.citationTraceEnabled": { + "type": "boolean", + "default": true, + "description": "Citation Trace — 답변 끝에 사용된 출처를 *출처:* 한 줄로 정리 지시. CoVe Strict 의 가벼운 형제, 항상 ON 권장. 기본 켜짐." + }, + "g1nation.selfCheckEnabled": { + "type": "boolean", + "default": false, + "description": "Post-hoc Self-Check — 답변 완료 후 별도 LLM 호출 1회로 검증 (답변 직접도/그라운딩/논리 모순). 결과를 답변 아래 footer 한 줄로 표시. 매 turn 추가 LLM 호출 (latency 비용). 기본 OFF — 명시적 opt-in." + }, + "g1nation.selfCheckModel": { + "type": "string", + "default": "", + "description": "Self-check 전용 모델 ID. 비면 defaultModel 사용. 빠른 작은 모델 (예: gemma2:2b) 권장." + }, + "g1nation.selfCheckTimeoutSec": { + "type": "number", + "default": 6, + "minimum": 1, + "maximum": 60, + "description": "Self-check LLM 호출 타임아웃 (초). 초과 시 흐릿한 한 줄 footer 로 fallback. 기본 6." + }, + "g1nation.glossaryEnabled": { + "type": "boolean", + "default": true, + "description": "Terminology Dictionary — 사용자 편집 글로서리(.astra/glossary.md) 를 시스템 프롬프트에 주입. 표준 표기 강제 + 답변 직전 Term Check. 파일 없으면 자동 no-op. 기본 켜짐." + }, + "g1nation.glossaryPath": { + "type": "string", + "default": ".astra/glossary.md", + "description": "Glossary 파일 상대 경로 (workspace root 기준). 기본 '.astra/glossary.md'." + }, + "g1nation.glossaryMaxBodyLength": { + "type": "number", + "default": 4000, + "minimum": 500, + "maximum": 20000, + "description": "Glossary 본문 시스템 프롬프트 cap (chars). 너무 크면 토큰 비용↑. 초과분은 잘림. 기본 4000." + }, "g1nation.knowledgeMix.secondBrainWeight": { "type": "number", "default": 50, diff --git a/src/agent.ts b/src/agent.ts index e0ce675..bdaf6ba 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -164,6 +164,7 @@ import { buildAgentModeSystemPrompt } from './agent/handlePrompt/buildAgentModeS import { buildAstraModeSystemPrompt } from './agent/handlePrompt/buildAstraModeSystemPrompt'; import { computeBudgetedRequest } from './agent/handlePrompt/computeBudgetedRequest'; import { processFinalAnswer } from './agent/handlePrompt/processFinalAnswer'; +import { postHocSelfCheck, formatSelfCheckFooter, DEFAULT_SELF_CHECK_OPTIONS } from './agent/postHocSelfCheck'; import { applyAutoContinuation } from './agent/handlePrompt/applyAutoContinuation'; export interface ChatMessage { @@ -285,10 +286,28 @@ export class AgentExecutor { lessons: string[]; /** 이번 turn 에 결정된 Knowledge Mix — scope footer 표시용. */ knowledgeMix: ResolvedKnowledgeMix | null; + /** [CONFLICT WARNINGS] 시스템 프롬프트 블록 — 검색된 출처에서 충돌 신호 감지 시. */ + conflictWarnings: string; + /** [VERIFICATION CHECKLIST] Chain-of-Verification 블록 — 그라운딩 자기 검증 지시. */ + coveChecklist: string; + /** [INTENT CLARIFICATION GUIDANCE] — 모호 질의 감지 시 역질문 우선 지시. */ + intentClarification: string; + /** [CITATION TRACE] — 답변 끝에 사용 출처 한 줄 정리 지시. */ + citationTrace: string; + /** Self-check 용 — selected chunks 의 (title, content) 요약. memoryContext 가 채움. */ + selfCheckSources: Array<{ title: string; excerpt: string }>; + /** [TERMINOLOGY DICTIONARY] — 사용자 편집 글로서리 + Term Check 지침. */ + terminology: string; } = { retrieval: null, lessons: [], knowledgeMix: null, + conflictWarnings: '', + coveChecklist: '', + intentClarification: '', + citationTrace: '', + selfCheckSources: [], + terminology: '', }; /** Per-turn state 일괄 정리. turn 시작/abort/load session 시 호출. */ @@ -296,6 +315,12 @@ export class AgentExecutor { this._turnCtx.retrieval = null; this._turnCtx.lessons = []; this._turnCtx.knowledgeMix = null; + this._turnCtx.conflictWarnings = ''; + this._turnCtx.coveChecklist = ''; + this._turnCtx.intentClarification = ''; + this._turnCtx.citationTrace = ''; + this._turnCtx.selfCheckSources = []; + this._turnCtx.terminology = ''; } private readonly options: AgentExecutorOptions; @@ -647,6 +672,11 @@ export class AgentExecutor { isCasualConversation, localPathContext, knowledgeMix: this._turnCtx.knowledgeMix, + conflictWarningsCtx: this._turnCtx.conflictWarnings, + coveChecklistCtx: this._turnCtx.coveChecklist, + intentClarificationCtx: this._turnCtx.intentClarification, + citationTraceCtx: this._turnCtx.citationTrace, + terminologyCtx: this._turnCtx.terminology, }); // Context budget computation → src/agent/handlePrompt/computeBudgetedRequest.ts const imageCount = (reqMessages as any[]) @@ -1200,6 +1230,12 @@ export class AgentExecutor { contextLength: ctxLimits.contextLength, engine, }); + // ── Post-hoc Self-Check (v2.2.191) — 별도 LLM 호출 1회로 답변 사후 검증. ── + // 비동기 — Devil 과 동일 패턴. 결과를 footer 한 줄로 append. + void this._maybePostHocSelfCheck({ + userPrompt: prompt || '', + assistantAnswer: finalAssistantContent, + }); } else { this.webview.postMessage({ type: 'streamChunk', value: finalAssistantContent }); } @@ -1282,10 +1318,18 @@ export class AgentExecutor { const workspaceFolders = vscode.workspace.workspaceFolders; const workspacePath = workspaceFolders ? workspaceFolders[0].uri.fsPath : undefined; + const cfgNow = getConfig(); this.memoryManager.onSessionEnd( this.currentTaskId, this.chatHistory.filter((m) => !m.internal), - workspacePath + workspacePath, + cfgNow.localBrainPath ? { + enabled: cfgNow.distillationEnabled !== false, + ageThresholdDays: cfgNow.distillationAgeThresholdDays ?? 30, + intervalDays: cfgNow.distillationIntervalDays ?? 7, + archiveMode: (cfgNow.distillationArchiveMode || 'mark-promoted') as any, + brainPath: cfgNow.localBrainPath, + } : undefined, ); logInfo('Memory extraction completed for session end.', { taskId: this.currentTaskId }); recordTelemetry({ @@ -1366,6 +1410,38 @@ export class AgentExecutor { }, opts); } + /** + * Post-hoc Self-Check — 답변 *완료 후* LLM 1회 호출로 3가지 평가 + * (사용자 질의 직접 답함 / 출처 그라운딩 / 논리 모순). 비동기 — main turn 에 영향 없음. + * 기본 OFF (g1nation.selfCheckEnabled). 결과는 footer 한 줄로 streamChunk append. + */ + private async _maybePostHocSelfCheck(opts: { + userPrompt: string; + assistantAnswer: string; + }): Promise { + try { + const cfg = getConfig(); + if (!cfg.selfCheckEnabled) return; + if (!opts.userPrompt.trim() || !opts.assistantAnswer.trim()) return; + const model = (cfg.selfCheckModel || '').trim() || cfg.defaultModel; + if (!model || !cfg.ollamaUrl) return; + const sources = this._turnCtx.selfCheckSources || []; + + const result = await postHocSelfCheck(opts.userPrompt, opts.assistantAnswer, sources, { + ollamaUrl: cfg.ollamaUrl, + model, + timeoutMs: (cfg.selfCheckTimeoutSec ?? 6) * 1000, + excerptLength: DEFAULT_SELF_CHECK_OPTIONS.excerptLength, + maxSources: DEFAULT_SELF_CHECK_OPTIONS.maxSources, + }); + + // 성공 실패 모두 footer 표시 — 사용자가 self-check 가 *돌고 있는지* 알 수 있게. + // 실패 시 흐릿한 한 줄, 성공 시 평가 한 줄. + const footer = formatSelfCheckFooter(result, model); + this.webview?.postMessage({ type: 'streamChunk', value: footer }); + } catch { /* swallow — self-check never breaks the turn */ } + } + private async callNonStreaming(params: { baseUrl: string; modelName: string; diff --git a/src/agent/handlePrompt/buildAstraModeSystemPrompt.ts b/src/agent/handlePrompt/buildAstraModeSystemPrompt.ts index e139e5b..d90fc8a 100644 --- a/src/agent/handlePrompt/buildAstraModeSystemPrompt.ts +++ b/src/agent/handlePrompt/buildAstraModeSystemPrompt.ts @@ -22,6 +22,28 @@ export interface BuildAstraModeSystemPromptInput { localPathContext: string; /** From this._turnCtx.knowledgeMix — pass null when absent. */ knowledgeMix: any; + /** + * [CONFLICT WARNINGS] 블록 — buildConflictWarningsBlock 산출. 빈 문자열이면 충돌 없음 → 주입 안 함. + * v4 정책 텍스트의 "[CONFLICT WARNING] 플래그" 참조를 실제 데이터로 뒷받침. + */ + conflictWarningsCtx?: string; + /** + * [VERIFICATION CHECKLIST] CoVe 블록 — buildCoveChecklistBlock 산출. 답변 *작성 전* + * 그라운딩 체크리스트로 모델 self-verify 지시. 빈 문자열이면 비활성. + */ + coveChecklistCtx?: string; + /** + * [INTENT CLARIFICATION GUIDANCE] — 모호 질의 감지 시 *역질문 우선* 지시. 모호 아닐 때 빈 문자열. + */ + intentClarificationCtx?: string; + /** + * [CITATION TRACE] — 답변 끝에 사용 출처 한 줄 정리 지시. 검색 결과 있을 때 채워짐. + */ + citationTraceCtx?: string; + /** + * [TERMINOLOGY DICTIONARY] — 사용자 편집 글로서리 + Term Check 지침. 파일 있을 때만. + */ + terminologyCtx?: string; } export function buildAstraModeSystemPrompt(input: BuildAstraModeSystemPromptInput): string { @@ -40,6 +62,11 @@ export function buildAstraModeSystemPrompt(input: BuildAstraModeSystemPromptInpu isCasualConversation, localPathContext, knowledgeMix, + conflictWarningsCtx, + coveChecklistCtx, + intentClarificationCtx, + citationTraceCtx, + terminologyCtx, } = input; // 기존 Astra 모드 (에이전트 미선택) @@ -78,5 +105,29 @@ export function buildAstraModeSystemPrompt(input: BuildAstraModeSystemPromptInpu // priorConclusionCtx 는 modeBridgeCtx 와 같은 위치 (base systemPrompt 직후) — 모델이 // 자기 직전 결론을 anchor 로 잡고 사용자의 follow-up 을 그 결론에 대한 정정으로 해석하게. const priorConclusionBlock = priorConclusionCtx ? '\n\n' + priorConclusionCtx : ''; - return `${systemPrompt}${modeBridgeCtx ? '\n\n' + modeBridgeCtx : ''}${priorConclusionBlock}${designerCtx}${projectArchitectureCtx}${localProjectKnowledgeCtx}${thinkingPartnerCtx}${astraStanceCtx}${secondBrainTraceCtx}${v4PolicyCtx}${knowledgeMixCtx}${casualCtx}\n\n[CONTEXT]\n${memoryCtx}\n${knowledgeContextForPrompt}\n${contextBlock}\n[/CONTEXT]\n${negativeCtx}`; + // [CONFLICT WARNINGS] 는 [CONTEXT] 밖에 — token-truncation 시 보호. v4 정책이 + // 충돌 처리 *방법* 을 명시하고, 이 블록이 *어느 출처가 충돌* 인지 데이터 제공. + // Casual conversation 모드에서는 RAG context 자체를 안 쓰므로 충돌 경고도 무의미 — 생략. + const conflictWarningsBlock = (!isCasualConversation && conflictWarningsCtx && conflictWarningsCtx.trim()) + ? '\n\n' + conflictWarningsCtx + : ''; + // [VERIFICATION CHECKLIST] CoVe — 답변 작성 전 self-verify 지시. Conflict 와 마찬가지로 + // [CONTEXT] 밖, casual 모드 비활성. CoVe 가 강하면 단정적 답변이 줄고 근거 인용 늘어남. + const coveBlock = (!isCasualConversation && coveChecklistCtx && coveChecklistCtx.trim()) + ? '\n\n' + coveChecklistCtx + : ''; + // [INTENT CLARIFICATION GUIDANCE] — 모호 차원 감지 시 *역질문 우선*. Casual 모드는 제외. + // 위치: 다른 verification block 보다 *앞* — 모호하면 답변 자체를 안 만들어야 하므로. + const intentBlock = (!isCasualConversation && intentClarificationCtx && intentClarificationCtx.trim()) + ? '\n\n' + intentClarificationCtx + : ''; + // [CITATION TRACE] — 답변 끝에 출처 한 줄. CoVe 와 함께 동작 — CoVe 가 라벨, Citation 이 정리. + const citationBlock = (!isCasualConversation && citationTraceCtx && citationTraceCtx.trim()) + ? '\n\n' + citationTraceCtx + : ''; + // [TERMINOLOGY DICTIONARY] — 사용자 편집 글로서리. casual 모드 비활성 (greeting 에 용어 강제 의미 없음). + const terminologyBlock = (!isCasualConversation && terminologyCtx && terminologyCtx.trim()) + ? '\n\n' + terminologyCtx + : ''; + return `${systemPrompt}${modeBridgeCtx ? '\n\n' + modeBridgeCtx : ''}${priorConclusionBlock}${designerCtx}${projectArchitectureCtx}${localProjectKnowledgeCtx}${thinkingPartnerCtx}${astraStanceCtx}${secondBrainTraceCtx}${v4PolicyCtx}${knowledgeMixCtx}${casualCtx}${intentBlock}${terminologyBlock}${conflictWarningsBlock}${coveBlock}${citationBlock}\n\n[CONTEXT]\n${memoryCtx}\n${knowledgeContextForPrompt}\n${contextBlock}\n[/CONTEXT]\n${negativeCtx}`; } diff --git a/src/agent/postHocSelfCheck.ts b/src/agent/postHocSelfCheck.ts new file mode 100644 index 0000000..e4605b3 --- /dev/null +++ b/src/agent/postHocSelfCheck.ts @@ -0,0 +1,230 @@ +/** + * Post-hoc Self-Check — 답변 *완료 후* LLM 한 번 호출로 3가지 평가. + * + * 사용자 제안: "[Self-Check] 단계 — 이 답변이 사용자 질문에 직접 답하는가 / 규칙 + * 준수 / 논리 모순 없는가". + * + * 기존 CoVe (v2.2.184) 와 차이: + * - CoVe = *답변 작성 전* 모델에게 self-verify 지시 (instructional, 1 pass) + * - Self-Check = *답변 완료 후* 별도 LLM 호출로 검증 (post-hoc, 2 pass) + * + * 비용·위험: + * - 매 turn 추가 LLM 호출 1회 (latency 비용) + * - 기본 OFF — semantic re-rank 와 같은 opt-in 패턴 + * - 짧은 timeout (기본 6초). 실패해도 답변 자체엔 영향 없음 — 그냥 평가 못 함. + * - 빠른 작은 모델 권장 (예: gemma2:2b) + * + * 위치: 답변 streaming 완료 후, `usedScope` 메시지 전송 직전. 비동기 — 답변 + * 표시를 *블록 하지 않음*. 결과는 webview 에 별도 메시지로 push. + */ + +export interface SelfCheckOptions { + ollamaUrl: string; + model: string; + timeoutMs: number; + /** 출처 컨텍스트 미리보기 길이. 기본 180 chars. */ + excerptLength: number; + /** 컨텍스트로 넘길 최대 출처 개수. 기본 5. */ + maxSources: number; +} + +export const DEFAULT_SELF_CHECK_OPTIONS: Omit = { + timeoutMs: 6000, + excerptLength: 180, + maxSources: 5, +}; + +export type SelfCheckVerdict = 'yes' | 'partial' | 'no' | 'unknown'; +export type ContradictionLevel = 'none' | 'minor' | 'major' | 'unknown'; + +export interface SelfCheckResult { + success: boolean; + answersQuestion: SelfCheckVerdict; + grounded: SelfCheckVerdict; + contradiction: ContradictionLevel; + note: string; + durationMs: number; + /** 디버그·footer 표시용. */ + rawResponse?: string; +} + +const FAILURE_RESULT: Omit = { + success: false, + answersQuestion: 'unknown', + grounded: 'unknown', + contradiction: 'unknown', +}; + +function shortExcerpt(text: string, n: number): string { + if (!text) return ''; + const cleaned = text.replace(/\s+/g, ' ').trim(); + return cleaned.length <= n ? cleaned : cleaned.slice(0, n) + '…'; +} + +function buildPrompt( + userPrompt: string, + answer: string, + sources: { title: string; excerpt: string }[], + excerptLength: number, +): { system: string; user: string } { + const system = [ + '당신은 답변 검증기 (judge). 사용자 질문, 답변, 출처를 받아 3가지 평가:', + '', + '1. answersQuestion: 답변이 질문에 *직접* 답하는가? (yes/partial/no)', + '2. grounded: 답변이 *제공된 출처에 근거* 하는가? (출처 없으면 unknown 가능) (yes/partial/no/unknown)', + '3. contradiction: 답변에 *논리적 모순* 이 있나? (none/minor/major)', + '', + '[출력 형식 — 정확히 한 줄 JSON, 다른 텍스트 절대 금지]', + '{"answersQuestion":"yes","grounded":"partial","contradiction":"none","note":"답변은 직접적이나 일부 주장이 모델 일반 지식 기반"}', + '', + '[규칙]', + '- partial/minor 는 *진짜* 애매한 경우에만. 둘 중 하나로 단정 가능하면 단정.', + '- note 는 1문장, 80자 이내, 핵심 평가 근거.', + '- JSON 한 줄 외 텍스트 (서론·설명·코드블록) 절대 출력 금지.', + ].join('\n'); + + const srcLines = sources.length > 0 + ? sources.map((s, i) => `[S${i + 1}] ${s.title}\n ${shortExcerpt(s.excerpt, excerptLength)}`).join('\n') + : '(검색된 출처 없음 — grounded 는 unknown 또는 no 평가)'; + + const user = [ + '[사용자 질문]', + userPrompt, + '', + '[답변]', + answer, + '', + '[제공된 출처]', + srcLines, + '', + '위 평가 기준에 따라 JSON 한 줄 출력.', + ].join('\n'); + + return { system, user }; +} + +function parseResult(raw: string): Omit | null { + if (!raw) return null; + const match = raw.match(/\{[\s\S]*?\}/); + if (!match) return null; + try { + const parsed = JSON.parse(match[0]); + const aq = String(parsed?.answersQuestion || '').toLowerCase(); + const gr = String(parsed?.grounded || '').toLowerCase(); + const co = String(parsed?.contradiction || '').toLowerCase(); + const validVerdict = (v: string): v is SelfCheckVerdict => ['yes', 'partial', 'no', 'unknown'].includes(v); + const validCo = (v: string): v is ContradictionLevel => ['none', 'minor', 'major', 'unknown'].includes(v); + if (!validVerdict(aq) || !validVerdict(gr) || !validCo(co)) return null; + const note = typeof parsed?.note === 'string' ? parsed.note.slice(0, 120) : ''; + return { + success: true, + answersQuestion: aq, + grounded: gr, + contradiction: co, + note: note || '평가 노트 없음', + }; + } catch { + return null; + } +} + +export async function postHocSelfCheck( + userPrompt: string, + answer: string, + sources: { title: string; excerpt: string }[], + options: SelfCheckOptions, +): Promise { + const start = Date.now(); + if (!userPrompt.trim() || !answer.trim()) { + return { ...FAILURE_RESULT, note: 'empty input', durationMs: Date.now() - start }; + } + const sourcesCap = (sources || []).slice(0, options.maxSources); + const { system, user } = buildPrompt(userPrompt, answer, sourcesCap, options.excerptLength); + + const isOllama = options.ollamaUrl.includes(':11434') || options.ollamaUrl.includes('ollama'); + const endpoint = isOllama ? `${options.ollamaUrl}/api/chat` : `${options.ollamaUrl}/v1/chat/completions`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), options.timeoutMs); + let raw = ''; + try { + const body = isOllama + ? { + model: options.model, stream: false, + messages: [ + { role: 'system', content: system }, + { role: 'user', content: user }, + ], + options: { temperature: 0.0, num_predict: 200 }, + } + : { + model: options.model, stream: false, temperature: 0.0, max_tokens: 200, + messages: [ + { role: 'system', content: system }, + { role: 'user', content: user }, + ], + }; + const res = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: controller.signal, + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data: any = await res.json(); + raw = String( + data?.message?.content ?? + data?.choices?.[0]?.message?.content ?? + data?.choices?.[0]?.text ?? + data?.response ?? + '', + ); + } catch (e: any) { + clearTimeout(timer); + return { + ...FAILURE_RESULT, + note: `LLM call failed: ${e?.name || e?.message || 'unknown'}`, + durationMs: Date.now() - start, + rawResponse: '', + }; + } finally { + clearTimeout(timer); + } + + const parsed = parseResult(raw); + if (!parsed) { + return { + ...FAILURE_RESULT, + note: 'unparseable response', + durationMs: Date.now() - start, + rawResponse: raw.slice(0, 200), + }; + } + return { + ...parsed, + durationMs: Date.now() - start, + rawResponse: raw.slice(0, 200), + }; +} + +/** + * 결과를 markdown 한 줄 footer 로 포맷 — 사용자가 답변 아래에서 바로 봄. + * + * 형식: `\n\n---\n_🔍 Self-check_: 답함=✓ · 근거=○ · 모순=없음 _(2.4s · 모델: gemma2:2b)_` + * + * 실패면 흐릿한 한 줄. + */ +export function formatSelfCheckFooter(result: SelfCheckResult, model: string): string { + if (!result.success) { + return `\n\n---\n_🔍 Self-check: ⊘ ${result.note} (${(result.durationMs / 1000).toFixed(1)}s)_`; + } + const aq = result.answersQuestion === 'yes' ? '✓' + : result.answersQuestion === 'partial' ? '◐' + : result.answersQuestion === 'no' ? '✗' : '?'; + const gr = result.grounded === 'yes' ? '✓' + : result.grounded === 'partial' ? '◐' + : result.grounded === 'no' ? '✗' : '?'; + const co = result.contradiction === 'none' ? '없음' + : result.contradiction === 'minor' ? '경미' + : result.contradiction === 'major' ? '⚠️ 중대' : '?'; + return `\n\n---\n_🔍 **Self-check**: 답함=${aq} · 근거=${gr} · 모순=${co} — ${result.note} _(${(result.durationMs / 1000).toFixed(1)}s · ${model})__`; +} diff --git a/src/config.ts b/src/config.ts index cd09895..d28ddb8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -60,6 +60,113 @@ export interface IAgentConfig { * Default 0.5 = equal weight, a reasonable starting point. */ embeddingBlendAlpha: number; + /** + * Conflict Surface — 검색된 출처의 conflictSeverity 신호를 [CONFLICT WARNINGS] 블록 + * 으로 시스템 프롬프트에 노출. v4 정책 텍스트(buildAstraModeSystemPrompt) 가 이미 + * "[CONFLICT WARNING] 플래그" 를 참조하지만, 데이터를 LLM 에 전달하지 않아 무용했음. + * true(기본) → 충돌 감지 시 블록 주입, false → 비활성. + */ + conflictHighlightingEnabled: boolean; + /** + * Conflict 자기-신호 surface 시 최소 severity 임계. + * 'low' → LOW 이상 (가장 민감, 노이즈 가능) + * 'medium' → MEDIUM 이상 (기본, 균형) + * 'high' → HIGH 만 (가장 보수적, 강한 충돌만) + */ + conflictSeverityThreshold: 'low' | 'medium' | 'high'; + /** + * 교차-문서 발산 감지 (같은 주제 ≥2 chunks, 본문 Jaccard < 0.30 인 잠재 모순 쌍). + * 자기-신호와 합쳐 [CONFLICT WARNINGS] 블록에 표시. + */ + conflictCrossDocEnabled: boolean; + /** + * CoVe (Chain-of-Verification) — 답변 *작성 전* 그라운딩 체크리스트를 시스템 프롬프트에 + * 주입해 모델이 self-verify 하도록. 할루시네이션 방지 + 그라운딩 명확화. + * true(기본) → 검색된 출처 있을 때 [VERIFICATION CHECKLIST] 블록 주입. + */ + coveEnabled: boolean; + /** CoVe 체크리스트에 나열할 상위 출처 개수. 기본 5. */ + coveTopSourcesCount: number; + /** + * CoVe Strict 모드 — 모든 사실 주장 뒤에 출처 ID([S1] 등) inline 인용 강제. + * 답변이 좀 더 학술적·verbose 해질 수 있어 기본 off. + */ + coveStrictMode: boolean; + /** + * Actionability — "현재 작업 상태" 신호(최근 슬래시 명령 + 열린 파일) 로 검색 결과 + * 재가중. TF-IDF 매치 점수에 actionability boost 추가해 "지금 작업 중인 컨텍스트" 와 + * 직접 연결된 문서를 우선. 기본 true. + */ + actionabilityEnabled: boolean; + /** + * Memory Lifecycle — Distillation Loop: stale Episodic Memory 를 LongTerm 'episode-digest' + * 로 승급. 누적 epimemory 가 검색 노이즈가 되는 것 방지. + * true(기본) → /memory distill + 세션 종료 시 자동 트리거 (interval 기준). + */ + distillationEnabled: boolean; + /** 며칠 이상 지난 episode 를 distill 대상으로. 기본 30. */ + distillationAgeThresholdDays: number; + /** 자동 distillation 의 최소 간격 (일). 너무 자주 안 돌도록. 기본 7. */ + distillationIntervalDays: number; + /** + * Archive 모드: + * - 'mark-promoted' (기본): promoted=true 마킹만, 파일 보존 + * - 'archive-file': promoted 마킹 + 파일을 memory/episodes/archive/ 로 이동 + */ + distillationArchiveMode: 'mark-promoted' | 'archive-file'; + /** + * Hierarchical Context Window — 질의·문서 추상도(concrete/operational/strategic) 매칭으로 + * 검색 결과 재가중. 같은 레벨 boost (× 1.15), 양 끝 mismatch penalty (× 0.7). LLM 호출 없음. + */ + hierarchicalReweightEnabled: boolean; + /** + * Semantic Re-ranking — 토큰 예산 통과한 selectedChunks 의 *순서* 를 LLM 한 번 호출로 + * 재정렬. 의도 매치를 키워드 매치보다 우선. 매 turn 1회 추가 LLM 호출 (latency 비용). + * 기본 OFF — 명시적으로 on 해야 함. + */ + semanticRerankEnabled: boolean; + /** 재정렬 전용 모델 ID. 비면 defaultModel 사용. 빠른 작은 모델 권장. */ + semanticRerankModel: string; + /** Re-rank 대상 상위 후보 개수. 기본 15. */ + semanticRerankCandidateK: number; + /** Re-rank LLM 호출 타임아웃 (초). 기본 8. */ + semanticRerankTimeoutSec: number; + /** + * Intent Clarification — 모호 질의에서 *추측 답변 대신 역질문* 지시. + * 휴리스틱 차원(환경/대상/범위/포맷/마감) 별 trigger + specifier 매치. 기본 true. + */ + intentClarificationEnabled: boolean; + /** + * 모호 판정 임계: + * - 'low': 2개 이상 missing dimension 일 때만 ambiguous (가장 덜 묻기) + * - 'medium' (기본): 1개 이상 missing → ambiguous + * - 'high': 1개 이상 missing OR 짧은 prompt(<20자)+trigger 있으면 ambiguous (가장 자주 묻기) + */ + intentClarificationStrictness: 'low' | 'medium' | 'high'; + /** + * Citation Trace — 답변 끝에 "출처:" 한 줄 정리 지시. CoVe Strict 의 가벼운 형제. + * 검색 결과 있을 때만 동작. 기본 true. + */ + citationTraceEnabled: boolean; + /** + * Post-hoc Self-Check — 답변 *완료 후* LLM 1회 호출로 검증 (답변 직접도 / 그라운딩 / + * 논리 모순). 매 turn 추가 LLM 호출 (latency 비용). 기본 OFF — 명시적 opt-in. + * 결과는 답변 아래 footer 한 줄로 표시. + */ + selfCheckEnabled: boolean; + /** Self-check 전용 모델 ID. 비면 defaultModel. 빠른 작은 모델 권장. */ + selfCheckModel: string; + /** Self-check LLM 호출 타임아웃 (초). 기본 6. */ + selfCheckTimeoutSec: number; + /** + * Terminology Dictionary — 사용자 편집 글로서리 파일을 시스템 프롬프트에 주입, + * 표준 표기 강제 + 답변 직전 자기 점검(Term Check). 기본 true. 파일 없으면 자동 no-op. + */ + glossaryEnabled: boolean; + /** Glossary 파일 상대 경로 (workspace root 기준). 기본 '.astra/glossary.md'. */ + glossaryPath: string; + /** Glossary 본문 시스템 프롬프트 cap (chars). 너무 크면 토큰 비용↑. 기본 4000. */ + glossaryMaxBodyLength: number; /** * Global Knowledge Mix weight (0–100). Controls how much the assistant leans on * Second Brain evidence vs. model general knowledge when answering. @@ -307,6 +414,31 @@ export function getConfig(): IAgentConfig { finalOnlyRetryOnThoughtLeak: cfg.get('finalOnlyRetryOnThoughtLeak', true), embeddingModel: (cfg.get('embeddingModel', '') || '').trim(), embeddingBlendAlpha: Math.max(0, Math.min(1, cfg.get('embeddingBlendAlpha', 0.5))), + conflictHighlightingEnabled: cfg.get('conflictHighlightingEnabled', true), + conflictSeverityThreshold: (cfg.get('conflictSeverityThreshold', 'medium') as 'low' | 'medium' | 'high') || 'medium', + conflictCrossDocEnabled: cfg.get('conflictCrossDocEnabled', true), + coveEnabled: cfg.get('coveEnabled', true), + coveTopSourcesCount: Math.max(1, Math.min(15, cfg.get('coveTopSourcesCount', 5))), + coveStrictMode: cfg.get('coveStrictMode', false), + actionabilityEnabled: cfg.get('actionabilityEnabled', true), + distillationEnabled: cfg.get('distillationEnabled', true), + distillationAgeThresholdDays: Math.max(1, Math.min(365, cfg.get('distillationAgeThresholdDays', 30))), + distillationIntervalDays: Math.max(1, Math.min(90, cfg.get('distillationIntervalDays', 7))), + distillationArchiveMode: (cfg.get('distillationArchiveMode', 'mark-promoted') as 'mark-promoted' | 'archive-file') || 'mark-promoted', + hierarchicalReweightEnabled: cfg.get('hierarchicalReweightEnabled', true), + semanticRerankEnabled: cfg.get('semanticRerankEnabled', false), + semanticRerankModel: cfg.get('semanticRerankModel', '') || '', + semanticRerankCandidateK: Math.max(2, Math.min(30, cfg.get('semanticRerankCandidateK', 15))), + semanticRerankTimeoutSec: Math.max(1, Math.min(60, cfg.get('semanticRerankTimeoutSec', 8))), + intentClarificationEnabled: cfg.get('intentClarificationEnabled', true), + intentClarificationStrictness: (cfg.get('intentClarificationStrictness', 'medium') as 'low' | 'medium' | 'high') || 'medium', + citationTraceEnabled: cfg.get('citationTraceEnabled', true), + selfCheckEnabled: cfg.get('selfCheckEnabled', false), + selfCheckModel: cfg.get('selfCheckModel', '') || '', + selfCheckTimeoutSec: Math.max(1, Math.min(60, cfg.get('selfCheckTimeoutSec', 6))), + glossaryEnabled: cfg.get('glossaryEnabled', true), + glossaryPath: cfg.get('glossaryPath', '.astra/glossary.md') || '.astra/glossary.md', + glossaryMaxBodyLength: Math.max(500, Math.min(20000, cfg.get('glossaryMaxBodyLength', 4000))), knowledgeMixSecondBrainWeight: Math.max(0, Math.min(100, Math.round( cfg.get('knowledgeMix.secondBrainWeight', 50) ))), diff --git a/src/features/calendar/index.ts b/src/features/calendar/index.ts index 653298d..1d5c134 100644 --- a/src/features/calendar/index.ts +++ b/src/features/calendar/index.ts @@ -33,6 +33,8 @@ export { export { createTask, + listTasks, TaskInput, CreatedTask, + ListedTask, } from './tasksApi'; diff --git a/src/features/calendar/tasksApi.ts b/src/features/calendar/tasksApi.ts index 24c54e6..8c8e837 100644 --- a/src/features/calendar/tasksApi.ts +++ b/src/features/calendar/tasksApi.ts @@ -103,3 +103,67 @@ export async function createTask( return { ok: false, error: e?.message || String(e) }; } } + +export interface ListedTask { + id: string; + title: string; + status: 'needsAction' | 'completed'; + /** 'YYYY-MM-DD' 형식. due 가 없는 task 는 undefined. */ + due?: string; + /** 완료 시각 ISO timestamp. status 'completed' 일 때만 있음. */ + completed?: string; + notes?: string; +} + +/** + * Google Tasks 목록 조회 — /onesie 1:1 카드 등에서 멤버별 필터링용. + * + * 기본 default list 의 task 들을 가져온다 (완료 포함). 호출자가 클라이언트 측에서 + * 제목 prefix `[멤버]` 나 notes 의 `@멤버` / `담당: 멤버` 패턴으로 필터하면 됨. + */ +export async function listTasks( + context: vscode.ExtensionContext, + options: { taskListId?: string; showCompleted?: boolean; maxResults?: number } = {}, +): Promise<{ ok: true; tasks: ListedTask[] } | { ok: false; error: string }> { + const tokenResult = await getFreshAccessToken(context); + if (!tokenResult.ok) return { ok: false, error: tokenResult.error }; + + const taskListId = (options.taskListId || '@default').trim() || '@default'; + const params = new URLSearchParams({ + maxResults: String(options.maxResults ?? 100), + showCompleted: options.showCompleted !== false ? 'true' : 'false', + showHidden: 'true', // 완료 후 숨김 처리된 것도 포함 — 1:1 회고에 최근 완료가 중요. + }); + const url = `${API_BASE}/lists/${encodeURIComponent(taskListId)}/tasks?${params.toString()}`; + + try { + const res = await fetch(url, { + method: 'GET', + headers: { Authorization: `Bearer ${tokenResult.accessToken}` }, + signal: AbortSignal.timeout(15000), + }); + const json: any = await res.json().catch(() => ({})); + if (!res.ok) { + const msg: string = json?.error?.message || `HTTP ${res.status}`; + if (res.status === 401 || res.status === 403 || /insufficient|scope/i.test(msg)) { + return { + ok: false, + error: `Tasks API 권한 부족 — "Astra: Google Calendar OAuth 연결 (쓰기)" 명령 재실행 + Tasks 스코프 동의 필요. (원인: ${msg})`, + }; + } + return { ok: false, error: msg }; + } + const items: any[] = Array.isArray(json.items) ? json.items : []; + const tasks: ListedTask[] = items.map((item: any) => ({ + id: String(item.id || ''), + title: String(item.title || ''), + status: item.status === 'completed' ? 'completed' : 'needsAction', + due: typeof item.due === 'string' ? item.due.slice(0, 10) : undefined, + completed: typeof item.completed === 'string' ? item.completed : undefined, + notes: typeof item.notes === 'string' ? item.notes : undefined, + })); + return { ok: true, tasks }; + } catch (e: any) { + return { ok: false, error: e?.message || String(e) }; + } +} diff --git a/src/features/customers/customersStore.ts b/src/features/customers/customersStore.ts new file mode 100644 index 0000000..deed63a --- /dev/null +++ b/src/features/customers/customersStore.ts @@ -0,0 +1,176 @@ +/** + * 고객사 / MRR / 갱신 트래커. + * + * 4인 기업의 수입 쪽 — `/runway` 가 통장과 burn 을 본다면, 여기는 *어디서 돈이 들어오나*. + * Salesforce / HubSpot 같은 CRM 아닌 가벼운 event-sourced 로그. + * + * 저장 형식: JSON Lines (.jsonl) — append-only event log. 같은 customer 의 여러 이벤트를 + * 시간 순으로 재생하면 현재 상태 (MRR, 갱신일, 위험 등급) 가 나온다. + * + * 위치: `/.astra/customers.jsonl`. 사람이 직접 편집 가능, grep / 백업 친화. + * 민감 정보(고객사 이름, 매출) 포함되므로 외부로 안 보냄 — 로컬 only. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +const STORE_REL_PATH = '.astra/customers.jsonl'; + +export type CustomerEventType = 'add' | 'note' | 'risk' | 'churn' | 'renew' | 'update'; + +export interface CustomerEvent { + /** unique id — timestamp 기반. */ + id: string; + /** ISO timestamp. */ + timestamp: string; + /** 고객 식별자 — 소문자·trim 한 name 의 slug. 같은 customer 의 이벤트끼리 그룹. */ + customerId: string; + /** 표시용 원본 이름 (가장 최근 이벤트의 이름 우선). */ + customerName: string; + /** 이벤트 종류. */ + type: CustomerEventType; + /** 월 매출 — add/renew/update 에서 사용. */ + mrr?: number; + /** 갱신일 (YYYY-MM-DD) — add/renew/update. */ + renewalAt?: string; + /** 요금제명 — 'pro' / 'enterprise' / 'starter' 등. */ + plan?: string; + /** 자유 텍스트 — note / risk / churn 의 사유. */ + memo?: string; +} + +export type CustomerStatus = 'active' | 'at-risk' | 'churned'; + +export interface CustomerState { + customerId: string; + customerName: string; + mrr: number; + plan?: string; + renewalAt?: string; + status: CustomerStatus; + startedAt: string; + lastEventAt: string; + eventCount: number; + notes: { timestamp: string; type: CustomerEventType; memo: string }[]; +} + +export function getCustomersFilePath(): string | null { + const folders = vscode.workspace.workspaceFolders; + if (!folders || folders.length === 0) return null; + return path.join(folders[0].uri.fsPath, STORE_REL_PATH); +} + +export function customerIdFromName(name: string): string { + return name.toLowerCase().trim().replace(/\s+/g, '-').replace(/[^\w가-힣-]/g, ''); +} + +export function readEvents(): CustomerEvent[] { + const filePath = getCustomersFilePath(); + if (!filePath || !fs.existsSync(filePath)) return []; + const out: CustomerEvent[] = []; + let content = ''; + try { content = fs.readFileSync(filePath, 'utf-8'); } catch { return []; } + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const e = JSON.parse(trimmed); + if (e && typeof e.id === 'string' && typeof e.customerId === 'string' && typeof e.type === 'string') { + out.push(e as CustomerEvent); + } + } catch { /* skip malformed */ } + } + return out; +} + +export function appendEvent(event: CustomerEvent): { ok: true; filePath: string } | { ok: false; error: string } { + const filePath = getCustomersFilePath(); + if (!filePath) return { ok: false, error: '워크스페이스 폴더가 없어 저장 불가.' }; + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.appendFileSync(filePath, JSON.stringify(event) + '\n', 'utf-8'); + return { ok: true, filePath }; + } catch (e: any) { + return { ok: false, error: e?.message || String(e) }; + } +} + +/** + * 이벤트 로그를 재생해 customerId 별 현재 상태 도출. + * + * - add: 신규 생성 (status=active, startedAt 설정) + * - update / renew: mrr / renewalAt / plan 갱신, status=active 로 복귀 (renew 시) + * - risk: status=at-risk, memo 누적 + * - churn: status=churned, mrr=0 + * - note: 노트 누적만, 상태 무변경 + */ +export function computeCustomerStates(): Map { + const events = readEvents().slice().sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || '')); + const states = new Map(); + for (const e of events) { + let s = states.get(e.customerId); + if (!s) { + if (e.type !== 'add') { + // add 이벤트 없이 다른 이벤트가 먼저 와도 묵시적 생성 — 데이터 손상 방어. + s = { + customerId: e.customerId, + customerName: e.customerName, + mrr: 0, + status: 'active', + startedAt: e.timestamp, + lastEventAt: e.timestamp, + eventCount: 0, + notes: [], + }; + states.set(e.customerId, s); + } else { + s = { + customerId: e.customerId, + customerName: e.customerName, + mrr: e.mrr ?? 0, + plan: e.plan, + renewalAt: e.renewalAt, + status: 'active', + startedAt: e.timestamp, + lastEventAt: e.timestamp, + eventCount: 0, + notes: [], + }; + states.set(e.customerId, s); + } + } + s.customerName = e.customerName || s.customerName; + s.lastEventAt = e.timestamp; + s.eventCount += 1; + + switch (e.type) { + case 'add': + // 위에서 이미 처리 (첫 진입 분기) — 중복 add 면 update 처럼. + if (e.mrr !== undefined) s.mrr = e.mrr; + if (e.renewalAt) s.renewalAt = e.renewalAt; + if (e.plan) s.plan = e.plan; + break; + case 'update': + case 'renew': + if (e.mrr !== undefined) s.mrr = e.mrr; + if (e.renewalAt) s.renewalAt = e.renewalAt; + if (e.plan) s.plan = e.plan; + if (e.type === 'renew' && s.status !== 'churned') s.status = 'active'; + break; + case 'risk': + if (s.status !== 'churned') s.status = 'at-risk'; + if (e.memo) s.notes.push({ timestamp: e.timestamp, type: 'risk', memo: e.memo }); + break; + case 'churn': + s.status = 'churned'; + s.mrr = 0; + if (e.memo) s.notes.push({ timestamp: e.timestamp, type: 'churn', memo: e.memo }); + break; + case 'note': + if (e.memo) s.notes.push({ timestamp: e.timestamp, type: 'note', memo: e.memo }); + break; + } + } + return states; +} diff --git a/src/features/datacollect/slashRouter.ts b/src/features/datacollect/slashRouter.ts index b358d66..4858b36 100644 --- a/src/features/datacollect/slashRouter.ts +++ b/src/features/datacollect/slashRouter.ts @@ -1,8 +1,24 @@ import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as path from 'path'; import { promises as fsp } from 'fs'; import { logInfo } from '../../utils'; import { bridgeFetch, getBridgeBaseUrl, BRIDGE_API } from './bridgeClient'; -import { createCalendarEvent, createTask, readCalendarConfig, _addDaysDate } from '../calendar'; +import { createCalendarEvent, createTask, listTasks, readCalendarConfig, _addDaysDate } from '../calendar'; +import { ChronicleProjectStore } from '../../sidebar/managers/chronicleProjectStore'; +import { + MemoryManager, + distillStaleEpisodes, + getLastDistillationRun, + recordDistillationRun, + type DistillationArchiveMode, +} from '../../memory'; +import { getConfig } from '../../config'; +import { + getGlossaryFilePath, + GLOSSARY_TEMPLATE, + clearGlossaryCache, +} from '../../retrieval/terminologyBlock'; /** * Datacollect "라디오" slash 명령 라우터. @@ -67,6 +83,31 @@ export function listSlashCommands(): ReadonlyArray { return Array.from(REGISTRY.values()); } +/** + * 최근 실행된 슬래시 명령 ring buffer (in-memory, process-scoped). + * + * 용도: Actionability scoring (`src/retrieval/actionabilityScoring.ts`) — 사용자가 + * 막 실행한 명령의 키워드를 검색 결과 재가중에 활용. 예: `/runway` 직후 chat 입력 + * 하면 runway/재무 관련 문서가 같은 키워드 매치 점수여도 위로 정렬. + * + * Persistence: 의도적으로 in-memory. 세션 사이 보존은 불필요 — actionability 는 + * "지금 작업 중인 컨텍스트" 신호이므로 process restart 시 reset 자연스러움. + */ +const RECENT_SLASH_RING: string[] = []; +const RECENT_SLASH_RING_MAX = 10; + +export function getRecentSlashCommands(): string[] { + return RECENT_SLASH_RING.slice(); +} + +function pushRecentSlashCommand(name: string): void { + // 중복 제거 — 같은 명령 연속 실행해도 ring 에는 한 번만 (최신 위치로 이동) + const idx = RECENT_SLASH_RING.indexOf(name); + if (idx >= 0) RECENT_SLASH_RING.splice(idx, 1); + RECENT_SLASH_RING.unshift(name); + if (RECENT_SLASH_RING.length > RECENT_SLASH_RING_MAX) RECENT_SLASH_RING.length = RECENT_SLASH_RING_MAX; +} + export function isSlashCommand(input: string): boolean { const trimmed = input.trim(); if (!trimmed.startsWith('/')) return false; @@ -105,6 +146,10 @@ export async function handleSlashCommand( return false; } + // Actionability ring — 명령 이름을 ring buffer 에 push. 다음 일반 채팅 turn 의 + // 검색 재가중에 사용 (src/retrieval/actionabilityScoring.ts). + pushRecentSlashCommand(head); + logInfo(`[ASTRA-DEBUG] slashRouter handleSlashCommand head=${head} arg=${arg.slice(0, 40)}`); logInfo(`[SLASH] handleSlashCommand start head=${head} arg="${arg.slice(0, 60)}" bridge=${getBridgeBaseUrl()}`); void vscode.window.showInformationMessage(`Datacollect Radio: ${head} 진입`); @@ -1166,17 +1211,18 @@ async function runTask(arg: string, view: any, context?: vscode.ExtensionContext '\n📋 **/task — Google Tasks + Calendar 동시 등록**', '', '사용법:', - ' `/task <제목> <시작일> <완료일>` — 기간 작업 (Calendar 다일 일정 + Tasks 마감일)', - ' `/task <제목> <날짜>` — 하루짜리 작업 (시작=완료=같은 날)', + ' `/task [@담당자] <제목> <시작일> <완료일>` — 기간 작업', + ' `/task [@담당자] <제목> <날짜>` — 하루짜리 작업', '', '날짜 형식: `YY/MM/DD` (예: `26/05/27`) · `YYYY-MM-DD` · `YYYY/MM/DD`', + '담당자: `@` 접두사로 첫 토큰에 (예: `@기획자`). 생략 가능 — 있으면 제목 앞 `[담당자]` 로 prefix 됨.', '', '예시:', - ' `/task Apple 계정 생성 요청 26/05/27 26/06/28`', - ' `/task 분기 보고서 작성 2026-07-01 2026-07-15`', - ' `/task 약값 결제 26/06/01`', + ' `/task @기획자 Apple 계정 생성 요청 26/05/27 26/06/28`', + ' `/task @디자이너 메인 화면 시안 2026-07-01 2026-07-15`', + ' `/task 약값 결제 26/06/01` (담당자 없음)', '', - 'Tasks API + Calendar API 양쪽에 등록되며, Tasks 는 마감일만(시작일은 노트), Calendar 는 기간 all-day 일정으로 표시됩니다.', + 'Tasks API + Calendar API 양쪽 등록. Tasks 는 마감일만(시작일은 노트), Calendar 는 기간 all-day 일정.', '', ].join('\n')); return true; @@ -1185,6 +1231,15 @@ async function runTask(arg: string, view: any, context?: vscode.ExtensionContext const tokens = arg.trim().split(/\s+/); if (tokens.length < 2) { chunk(view, '\n❌ 인자 부족 — 최소 제목 + 날짜 1개 필요.\n'); return true; } + // 선택적 @담당자 토큰 — 첫 토큰이 @ 로 시작하면 owner 로 떼어내고 나머지로 진행. + // 멀티-멤버 팀 운영에 owner 가시화 (Tasks/Calendar 제목 prefix 로 자동 표시). + let owner: string | undefined; + if (tokens[0]?.startsWith('@') && tokens[0].length > 1) { + owner = tokens[0].slice(1); + tokens.shift(); + if (tokens.length < 1) { chunk(view, '\n❌ 제목·날짜 누락 (담당자만 입력됨).\n'); return true; } + } + // 끝에서부터 날짜 매칭. 마지막 2개가 모두 날짜면 range, 마지막 1개만 날짜면 단일일. const lastDate = parseFlexibleDate(tokens[tokens.length - 1]); const secondLastDate = tokens.length >= 3 ? parseFlexibleDate(tokens[tokens.length - 2]) : null; @@ -1201,15 +1256,18 @@ async function runTask(arg: string, view: any, context?: vscode.ExtensionContext chunk(view, `\n❌ 마지막 토큰이 날짜 형식이 아닙니다 ("${tokens[tokens.length - 1]}"). 사용 가능 형식: YY/MM/DD · YYYY-MM-DD · YYYY/MM/DD.\n사용법: \`/task\` (인자 없이) 실행해 도움말 보기.\n`); return true; } - const title = titleTokens.join(' ').trim(); - if (!title) { chunk(view, '\n❌ 제목 누락.\n'); return true; } + const baseTitle = titleTokens.join(' ').trim(); + if (!baseTitle) { chunk(view, '\n❌ 제목 누락.\n'); return true; } if (startYmd > endYmd) { chunk(view, `\n❌ 시작일(${startYmd})이 완료일(${endYmd})보다 늦습니다.\n`); return true; } + // owner 가 있으면 제목 앞에 `[담당자]` prefix — Tasks·Calendar UI 양쪽에서 한눈에 보임. + const title = owner ? `[${owner}] ${baseTitle}` : baseTitle; + const isRange = startYmd !== endYmd; const periodLabel = isRange ? `${startYmd} ~ ${endYmd}` : startYmd; - chunk(view, `\n📝 **Google Tasks + Calendar 등록**: ${title}\n · 기간: ${periodLabel}\n`); + chunk(view, `\n📝 **Google Tasks + Calendar 등록**: ${title}\n · 기간: ${periodLabel}${owner ? ` · 담당: @${owner}` : ''}\n`); - const notes = `Astra /task 직접 등록\n기간: ${periodLabel}`; + const notes = `${owner ? `담당: @${owner}\n` : ''}Astra /task 직접 등록\n기간: ${periodLabel}`; const successes: string[] = []; const failures: string[] = []; let calLink: string | undefined; @@ -1246,6 +1304,2835 @@ async function runTask(arg: string, view: any, context?: vscode.ExtensionContext return true; } +// ─── /decisions — Chronicle ADR (결정 기록) 검색 ───────────────────────── +// "어제 우리가 어떻게 정했지?" 의 대답을 즉시 제공. ProjectChronicleManager 가 +// 이미 ADR 을 `/decisions/ADR-NNNN-.md` 형태로 쌓고 있으므로 +// 검색만 얹으면 됨. 키워드 검색 + `@담당자` 필터 (markdown 내용에서 grep). +// 4인 팀 단독 운영자 단계의 "결정 망각" 통증을 가장 작은 코드로 해결. + +async function runDecisions(arg: string, view: any, context?: vscode.ExtensionContext): Promise { + if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /decisions 실행 불가.\n'); return true; } + + // 인자 파싱 — `@담당자` 토큰은 owner 필터, 나머지는 키워드. + const tokens = arg.trim().split(/\s+/).filter(Boolean); + let ownerFilter: string | undefined; + const keywordParts: string[] = []; + for (const t of tokens) { + if (t.startsWith('@') && t.length > 1) ownerFilter = t.slice(1); + else keywordParts.push(t); + } + const keyword = keywordParts.join(' ').toLowerCase().trim(); + + if (!keyword && !ownerFilter) { + chunk(view, [ + '\n📋 **/decisions — Chronicle 결정 기록 검색**', + '', + '사용법:', + ' `/decisions <키워드>` — 키워드로 ADR 검색', + ' `/decisions @<담당자>` — 담당자 언급 결정만', + ' `/decisions <키워드> @<담당자>` — 둘 다 만족', + '', + '예시:', + ' `/decisions 환불 정책`', + ' `/decisions @기획자`', + ' `/decisions 결제 흐름 @개발`', + '', + 'Chronicle ADR 파일 (`/decisions/ADR-NNNN-*.md`) 을 스캔합니다. 최신순 정렬, 최대 20건.', + '', + ].join('\n')); + return true; + } + + // 모든 chronicle 프로젝트의 decisions 디렉터리를 스캔. (single-operator 단계에선 + // 보통 1개 프로젝트지만, 다중 프로젝트도 한 번에 검색.) + const store = new ChronicleProjectStore(context); + const profiles = store.getAll(); + if (profiles.length === 0) { + chunk(view, '\n❌ Chronicle 프로젝트가 없습니다. workspace 폴더를 열고 사이드바에서 chronicle 활성화하세요.\n'); + return true; + } + + interface Hit { project: string; file: string; filePath: string; mtime: number; title: string; snippet: string; } + const hits: Hit[] = []; + + for (const profile of profiles) { + const decisionsDir = path.join(profile.recordRoot, 'decisions'); + if (!fs.existsSync(decisionsDir)) continue; + let fileNames: string[] = []; + try { fileNames = fs.readdirSync(decisionsDir); } catch { continue; } + for (const fileName of fileNames) { + if (!fileName.endsWith('.md') || !fileName.startsWith('ADR-')) continue; + const filePath = path.join(decisionsDir, fileName); + let content = ''; + try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; } + const lower = content.toLowerCase(); + + // 필터: 키워드는 내용 어디에든, owner 는 원문(대소문자 살림) 부분문자열. + if (keyword && !lower.includes(keyword)) continue; + if (ownerFilter && !content.includes(ownerFilter)) continue; + + // 제목 = 첫 번째 # 헤딩. + const titleMatch = content.match(/^#\s+(.+)$/m); + const title = titleMatch ? titleMatch[1].trim() : fileName.replace(/\.md$/, ''); + + // 스니펫 — 키워드 주변 컨텍스트 (없으면 첫 본문 단락). + let snippet = ''; + if (keyword) { + const idx = lower.indexOf(keyword); + if (idx >= 0) { + const start = Math.max(0, idx - 60); + const end = Math.min(content.length, idx + 180); + snippet = content.slice(start, end).replace(/\s+/g, ' ').trim(); + } + } else { + const paragraphs = content.split(/\n\s*\n/).map(p => p.trim()).filter(p => p && !p.startsWith('#') && !p.startsWith('>')); + snippet = (paragraphs[0] || '').slice(0, 220).replace(/\s+/g, ' ').trim(); + } + + let mtime = 0; + try { mtime = fs.statSync(filePath).mtimeMs; } catch { /* keep 0 */ } + hits.push({ project: profile.projectName, file: fileName, filePath, mtime, title, snippet }); + } + } + + if (hits.length === 0) { + const filterDesc = [keyword && `키워드 "${keyword}"`, ownerFilter && `@${ownerFilter}`].filter(Boolean).join(' + '); + chunk(view, `\nℹ️ ${filterDesc} 에 매치되는 결정 기록 없음. (검색 대상: ${profiles.length}개 프로젝트)\n`); + return true; + } + + hits.sort((a, b) => b.mtime - a.mtime); + const filterDesc = [keyword && `키워드: ${keyword}`, ownerFilter && `담당: @${ownerFilter}`].filter(Boolean).join(' · '); + chunk(view, `\n📋 **결정 검색 결과 ${hits.length}건** (${filterDesc})\n\n`); + const MAX_SHOW = 20; + for (const h of hits.slice(0, MAX_SHOW)) { + const date = h.mtime ? new Date(h.mtime).toISOString().slice(0, 10) : '날짜 미상'; + chunk(view, `### ${h.title}\n`); + chunk(view, `- 📅 ${date} · 📁 ${h.project} · \`${h.file}\`\n`); + if (h.snippet) chunk(view, `- 💬 …${h.snippet}…\n`); + chunk(view, `- 🔗 \`${h.filePath}\`\n\n`); + } + if (hits.length > MAX_SHOW) chunk(view, `_…+${hits.length - MAX_SHOW}건 더 (필터를 좁히면 줄어듭니다)_\n`); + return true; +} + +// ─── /onesie [멤버] — 1:1 미팅 준비 카드 ─────────────────────────────── +// 단독 운영자(대표)가 멤버별 주간 1:1 을 5분에 끝낼 수 있게 자동 합성: +// 1. Google Tasks API 에서 모든 task 가져와 멤버 필터 (제목 prefix `[멤버]`, +// notes 의 `@멤버` 또는 `담당: 멤버` 패턴) +// 2. 카테고리: 최근 30일 완료 / 지연 / 다가오는 / 마감일 없음 +// 3. Chronicle ADR 에서 해당 멤버 언급된 결정 최신순 +// 4. 자동 대화 토픽 제안 (지연 많으면 블로커, 완료 0이면 시간 사용 등) + +async function runOnesie(arg: string, view: any, context?: vscode.ExtensionContext): Promise { + if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /onesie 실행 불가.\n'); return true; } + const memberRaw = arg.trim().split(/\s+/)[0] || ''; + const member = memberRaw.replace(/^@/, '').trim(); + if (!member) { + chunk(view, [ + '\n📋 **/onesie [멤버] — 1:1 미팅 준비 카드**', + '', + '사용법: `/onesie <담당자>` 또는 `/onesie @<담당자>`', + '예: `/onesie 기획자` · `/onesie @디자이너` · `/onesie 개발`', + '', + '대상자의 Tasks 진행 상황(완료/지연/다가오는)과 최근 Chronicle 결정 기록을 모아 1:1 준비 카드 생성. 자동 대화 토픽 제안 포함.', + '', + '※ `/task @<멤버> ...` 로 task 를 등록해 두면 자동으로 잡힙니다. `/meet` 액션 아이템도 owner 가 있으면 잡힘.', + '', + ].join('\n')); + return true; + } + + chunk(view, `\n📋 **1:1 준비 카드 — @${member}**\n`); + + // 1. Google Tasks 전체 조회 후 멤버 필터. + chunk(view, '\n📥 Tasks 가져오는 중...\n'); + const taskResult = await listTasks(context, { showCompleted: true, maxResults: 200 }); + const allTasks = taskResult.ok ? taskResult.tasks : []; + if (!taskResult.ok) chunk(view, `\n⚠️ Tasks 조회 실패: ${taskResult.error}\n (Chronicle 검색은 계속 진행)\n`); + + const memberPrefix = `[${member}]`; + const memberTasks = allTasks.filter((t) => + t.title.includes(memberPrefix) + || (t.notes || '').includes(`@${member}`) + || (t.notes || '').includes(`담당: ${member}`), + ); + + // 2. 카테고리화. + const today = new Date().toISOString().slice(0, 10); + const thirtyDaysAgoIso = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); + const completedRecent = memberTasks + .filter((t) => t.status === 'completed' && (t.completed || '') >= thirtyDaysAgoIso) + .sort((a, b) => (b.completed || '').localeCompare(a.completed || '')); + const overdue = memberTasks + .filter((t) => t.status === 'needsAction' && t.due && t.due < today) + .sort((a, b) => (a.due || '').localeCompare(b.due || '')); + const upcoming = memberTasks + .filter((t) => t.status === 'needsAction' && t.due && t.due >= today) + .sort((a, b) => (a.due || '').localeCompare(b.due || '')); + const noDate = memberTasks.filter((t) => t.status === 'needsAction' && !t.due); + + // 3. Chronicle ADR 에서 멤버 언급된 결정 (최신 5건). + const store = new ChronicleProjectStore(context); + const profiles = store.getAll(); + interface AdrHit { date: string; mtime: number; title: string; file: string; project: string; } + const adrHits: AdrHit[] = []; + for (const profile of profiles) { + const decisionsDir = path.join(profile.recordRoot, 'decisions'); + if (!fs.existsSync(decisionsDir)) continue; + let names: string[] = []; + try { names = fs.readdirSync(decisionsDir); } catch { continue; } + for (const fn of names) { + if (!fn.endsWith('.md') || !fn.startsWith('ADR-')) continue; + const fp = path.join(decisionsDir, fn); + let content = ''; + try { content = fs.readFileSync(fp, 'utf-8'); } catch { continue; } + if (!content.includes(member)) continue; + const titleMatch = content.match(/^#\s+(.+)$/m); + const title = titleMatch ? titleMatch[1].trim() : fn.replace(/\.md$/, ''); + let mtime = 0; + try { mtime = fs.statSync(fp).mtimeMs; } catch { /* keep 0 */ } + const date = mtime ? new Date(mtime).toISOString().slice(0, 10) : ''; + adrHits.push({ date, mtime, title, file: fn, project: profile.projectName }); + } + } + adrHits.sort((a, b) => b.mtime - a.mtime); + + // 4. 렌더. + chunk(view, `\n## 최근 30일 완료 (${completedRecent.length}건)\n`); + if (completedRecent.length === 0) chunk(view, '_없음_\n'); + else for (const t of completedRecent.slice(0, 10)) { + const d = (t.completed || '').slice(0, 10); + chunk(view, `- ✅ ${d} — ${t.title}\n`); + } + if (completedRecent.length > 10) chunk(view, `_…+${completedRecent.length - 10}건 더_\n`); + + chunk(view, `\n## 지연 (${overdue.length}건)\n`); + if (overdue.length === 0) chunk(view, '_없음_\n'); + else for (const t of overdue) chunk(view, `- 🔴 ${t.due} (마감 지남) — ${t.title}\n`); + + chunk(view, `\n## 진행 중 / 다가오는 (${upcoming.length}건)\n`); + if (upcoming.length === 0) chunk(view, '_없음_\n'); + else for (const t of upcoming.slice(0, 10)) chunk(view, `- 🟡 ${t.due} — ${t.title}\n`); + if (upcoming.length > 10) chunk(view, `_…+${upcoming.length - 10}건 더_\n`); + + if (noDate.length > 0) { + chunk(view, `\n## 마감일 없음 (${noDate.length}건)\n`); + for (const t of noDate.slice(0, 5)) chunk(view, `- ⚪ ${t.title}\n`); + if (noDate.length > 5) chunk(view, `_…+${noDate.length - 5}건 더_\n`); + } + + chunk(view, `\n## 최근 결정 — @${member} 언급 (${adrHits.length}건)\n`); + if (adrHits.length === 0) chunk(view, '_없음_\n'); + else for (const h of adrHits.slice(0, 5)) { + chunk(view, `- 📋 ${h.date} — ${h.title} (\`${h.file}\`)\n`); + } + + // 5. 자동 대화 토픽 제안 — 상태 신호로부터 유도. + const topics: string[] = []; + if (overdue.length > 0) topics.push(`🔴 지연 ${overdue.length}건 블로커 확인 — 무엇이 막혔나, 도와줄 일은`); + if (upcoming.length > 5) topics.push(`🟡 다가오는 마감 ${upcoming.length}건 — 우선순위 합의·과부하 여부`); + if (completedRecent.length > 5) topics.push(`✅ 최근 완료 ${completedRecent.length}건 많음 — 회고 / 잘 된 점 / 패턴`); + else if (completedRecent.length === 0 && memberTasks.length > 0) topics.push('⚠️ 최근 30일 완료 0건 — 어떤 일에 시간을 쓰고 있는지 확인'); + else if (memberTasks.length === 0) topics.push('⚠️ 등록된 Task 자체가 0 — 일하는 게 안 보임. owner 태깅 시작 필요'); + if (noDate.length > 3) topics.push(`⚪ 마감일 없는 작업 ${noDate.length}건 — 우선순위 합의로 마감 부여`); + if (adrHits.length > 0) topics.push(`📋 최근 결정 (${adrHits[0].title.slice(0, 40)}…) — 이해·실행 상황 확인`); + + chunk(view, `\n## 💬 1:1 대화 토픽 제안\n`); + if (topics.length === 0) chunk(view, '_특이사항 없음 — 일반 안부 + 다음 주 우선순위 정도_\n'); + else for (const t of topics) chunk(view, `- ${t}\n`); + return true; +} + +// ─── /draft [유형] [요청] — 외부 커뮤니케이션 초안 ─────────────────────── +// 단독 운영자가 매일 가장 많이 쓰는 텍스트 카테고리들의 초안 자동 생성. +// 팀 보이스 가이드(`g1nation.teamVoiceGuide`)가 있으면 모든 초안에 자동 반영. + +const DRAFT_TYPES: Record = { + email: { + label: '이메일', + systemPrompt: '한국어 비즈니스 이메일 초안. 격식 있되 과도하게 딱딱하지 않게. 인사 / 본문(목적·요청·맥락) / 맺음말 구조. 100~300자.', + }, + slack: { + label: '슬랙/메신저 메시지', + systemPrompt: '슬랙·메신저용 짧고 명확한 한국어 메시지. 캐주얼하지만 프로페셔널. 50~150자 내. 필요하면 불릿 사용.', + }, + blog: { + label: '블로그 포스트', + systemPrompt: '블로그 포스트 초안 한국어. 후크가 있는 도입부 + 본문 3~5개 섹션 + 결론. 800~2000자. 마크다운 헤더(##) 사용.', + }, + newsletter: { + label: '뉴스레터', + systemPrompt: '뉴스레터용 한국어. 친근하면서 정보성. 헤드라인 + 본문 + 다음 액션 권유. 300~600자.', + }, + 'investor-update': { + label: '투자자 월간 업데이트', + systemPrompt: '투자자/이해관계자용 월간 업데이트 한국어. 구조: ① 핵심 지표 ② 이번 달 성과 ③ 과제·이슈 ④ 다음 우선순위 ⑤ ask (필요한 도움). 격식, 정량 지표 우선.', + }, + proposal: { + label: '비즈니스 제안서', + systemPrompt: '비즈니스 제안서 초안 한국어. 구조: 배경 / 제안 내용 / 기대 효과 / 일정 / (가능하면) 비용. 격식, 명확.', + }, +}; + +async function runDraft(arg: string, view: any, _context?: vscode.ExtensionContext): Promise { + const tokens = arg.trim().split(/\s+/); + if (!arg.trim() || tokens.length < 2) { + const typeList = Object.entries(DRAFT_TYPES).map(([k, v]) => ` \`${k}\` — ${v.label}`).join('\n'); + chunk(view, [ + '\n📋 **/draft [유형] [요청] — 외부 커뮤니케이션 초안**', + '', + '사용법: `/draft <유형> <요청 내용>`', + '', + '유형 목록:', + typeList, + '', + '예시:', + ' `/draft email 김 대표님께 미팅 일정 조율 요청, 다음 주 가능 시간 3개 제안`', + ' `/draft slack 디자이너에게 메인 시안 1차 컨펌 요청, 금요일까지 회신 부탁`', + ' `/draft blog v2.2 릴리즈 노트 — Tasks 통합 및 4인 팀 운영 기능 소개`', + ' `/draft investor-update 5월 월간 — MAU 30% 성장, 결제 흐름 개선 완료, 다음 달 신규 출시`', + '', + '※ Settings 의 `g1nation.teamVoiceGuide` 에 팀 보이스 가이드(말투/금기어/자주 쓰는 표현)를 저장하면 모든 초안에 자동 반영.', + '', + ].join('\n')); + return true; + } + + const typeKey = tokens[0].toLowerCase(); + const typeDef = DRAFT_TYPES[typeKey]; + if (!typeDef) { + chunk(view, `\n❌ 알 수 없는 유형: \`${typeKey}\`. 사용 가능: ${Object.keys(DRAFT_TYPES).join(' · ')}\n`); + return true; + } + const request = tokens.slice(1).join(' ').trim(); + if (!request) { chunk(view, '\n❌ 요청 내용 없음.\n'); return true; } + + const voiceGuide = (vscode.workspace.getConfiguration('g1nation').get('teamVoiceGuide', '') || '').trim(); + + chunk(view, `\n📝 **${typeDef.label} 초안 작성 중**\n · 요청: ${request}\n · ${voiceGuide ? '팀 보이스 가이드 적용 (' + voiceGuide.length + '자)' : '팀 보이스 가이드 없음 (g1nation.teamVoiceGuide 설정 시 자동 반영)'}\n`); + + const systemPrompt = [ + typeDef.systemPrompt, + '', + voiceGuide ? `[팀 보이스 가이드 — 반드시 준수]\n${voiceGuide}` : '', + '', + '출력 형식: 초안 본문만. "네, 알겠습니다" 같은 인사·메타 설명 금지. 사용자가 그대로 복사해 보낼 수 있는 형태.', + ].filter(Boolean).join('\n'); + + try { + const draft = await callLmSynthesis(request, systemPrompt); + if (!draft || !draft.trim()) { + chunk(view, '\n❌ 초안 생성 실패 (LLM 빈 응답).\n'); + return true; + } + chunk(view, `\n---\n${draft.trim()}\n---\n`); + } catch (e: any) { + chunk(view, `\n❌ 초안 생성 실패: ${e?.message ?? String(e)}\n`); + } + return true; +} + +// ─── /feedback — 고객 피드백 누적 + 패턴 분석 ─────────────────────────── +// 슬랙·이메일·CS 채널에 흩어진 고객 피드백을 한 곳에 모으고 LLM 으로 카테고리 +// 자동 분류 + 일정 건수 누적되면 `/feedback summary` 로 패턴 리포트 생성. +// 로컬 .jsonl 저장이라 민감 정보도 외부로 안 보냄 — ASTRA local-first 의 강점. + +import { appendFeedback, readFeedback, getFeedbackFilePath, countFeedback, type FeedbackEntry } from '../feedback/feedbackStore'; +import { appendRunway, readRunway, getRunwayFilePath, computeRunwayStatus, type RunwayEntry, type RunwayEntryType } from '../runway/runwayStore'; +import { appendEvent as appendCustomerEvent, readEvents as readCustomerEvents, getCustomersFilePath, customerIdFromName, computeCustomerStates, type CustomerEvent, type CustomerEventType, type CustomerState } from '../customers/customersStore'; +import { appendHireEvent, readHireEvents, getHireFilePath, candidateIdFromName, computeCandidateStates, KNOWN_STAGES, type HireEvent, type HireEventType, type CandidateState } from '../hire/hireStore'; + +const FEEDBACK_CATEGORIZE_PROMPT = [ + '당신은 고객 피드백 분류기.', + '', + '[입력] 사용자가 제공하는 고객 피드백 텍스트 한 건.', + '', + '[출력 형식 — 정확히 이 JSON 한 줄, 다른 텍스트/설명 절대 금지]', + '{"categories":["..."],"sentiment":"positive|neutral|negative"}', + '', + '[규칙]', + '- categories: 1~3개. 짧은 한국어 단어. 일관된 분류 (예: UX, 결제, 성능, 안정성, 가격, 신뢰, 기능 요청, 버그, 사용성, 디자인, 고객지원). 명확하지 않으면 "기타".', + '- sentiment: 긍정 호평 = positive, 단순 질문/중립 = neutral, 불만/버그/요청 = negative.', + '- JSON 외 어떤 문자도 출력하지 마시오. 마크다운 코드블록도 금지.', +].join('\n'); + +const FEEDBACK_SUMMARY_PROMPT = [ + '당신은 고객 피드백 분석가. 사용자가 제공한 누적 피드백 데이터(JSON Lines)를 보고', + '*패턴 분석 리포트* 를 한국어 마크다운으로 작성한다. 외부 정보 추측 금지 — 주어진 데이터에서만.', + '', + '[출력 형식 — 정확히 이 구조]', + '', + '## 카테고리 분포', + '- 카테고리명 (N건, X%): 핵심 패턴 한 줄', + '- ...', + '', + '## 감정 분포', + '- 부정: N건 (X%)', + '- 중립: N건 (X%)', + '- 긍정: N건 (X%)', + '', + '## 반복 패턴 Top 3', + '구체적 인용 1-2개씩 포함. "여러 명이 X 에 대해 Y 하다고 언급" 형태.', + '1. ...', + '2. ...', + '3. ...', + '', + '## 추천 액션 (대표 의사결정 참고용)', + '데이터에서 *명확하게* 보이는 신호만. 단정적 단어("반드시" 등) 금지, "검토 권장" 톤.', + '- ...', +].join('\n'); + +async function feedbackSave(text: string, view: any): Promise { + if (!text.trim()) { chunk(view, '\n❌ 피드백 텍스트가 비어 있습니다.\n'); return; } + + // 1. 즉시 저장 (LLM 분류 실패해도 원본은 보존). + const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const initialEntry: FeedbackEntry = { + id, + timestamp: new Date().toISOString(), + text: text.trim(), + }; + const saveResult = appendFeedback(initialEntry); + if (!saveResult.ok) { chunk(view, `\n❌ 저장 실패: ${saveResult.error}\n`); return; } + chunk(view, `\n📥 **피드백 저장됨** (id: \`${id.slice(0, 13)}\`)\n · 누적 ${countFeedback()}건\n`); + + // 2. LLM 자동 분류 (실패해도 entry 는 이미 저장됨). + chunk(view, '\n🤖 카테고리 자동 분류 중...\n'); + try { + const llmOut = await callLmSynthesis(text.trim(), FEEDBACK_CATEGORIZE_PROMPT); + // LLM 응답에서 JSON 추출 — 코드블록·잡 텍스트 둘러쌌어도 첫 { 부터 마지막 } 까지. + const jsonMatch = llmOut.match(/\{[\s\S]*\}/); + if (!jsonMatch) { chunk(view, ' ⚠️ 분류 실패 (LLM 응답에 JSON 없음). 원본은 저장됨, 수동으로 분류 추가 가능.\n'); return; } + const parsed = JSON.parse(jsonMatch[0]); + const categories: string[] = Array.isArray(parsed.categories) ? parsed.categories.map(String).slice(0, 3) : []; + const sentiment = ['positive', 'neutral', 'negative'].includes(parsed.sentiment) ? parsed.sentiment : undefined; + + // 분류 결과를 추가 entry 로 append (기존 entry 는 그대로). 단순화 — 마지막 entry 가 가장 풍부. + const enriched: FeedbackEntry = { ...initialEntry, categories, sentiment }; + // 마지막 줄을 교체 — append-only 정책 깨지 않게, 그냥 enriched 를 한 번 더 append 하고 원본 entry 는 두는 방식도 있음. + // 깔끔하게: 별도 ID 로 새 entry append (categories 만 있는 경량 enrichment). + // 더 깔끔하게는 파일 rewrite — 여기서는 rewrite 채택. .jsonl 작아서 비용 OK. + const all = readFeedback().map((e) => (e.id === id ? enriched : e)); + const filePath = getFeedbackFilePath(); + if (filePath) { + fs.writeFileSync(filePath, all.map((e) => JSON.stringify(e)).join('\n') + '\n', 'utf-8'); + } + chunk(view, ` · 카테고리: ${categories.length > 0 ? categories.join(', ') : '(없음)'}\n · 감정: ${sentiment ?? '(미분류)'}\n`); + } catch (e: any) { + chunk(view, ` ⚠️ 분류 실패: ${e?.message || String(e)} (원본은 저장됨)\n`); + } +} + +function feedbackList(filterCategory: string | undefined, view: any): void { + const all = readFeedback(); + const filtered = filterCategory + ? all.filter((e) => (e.categories || []).some((c) => c === filterCategory || c.toLowerCase() === filterCategory.toLowerCase())) + : all; + if (filtered.length === 0) { + chunk(view, filterCategory ? `\nℹ️ 카테고리 "${filterCategory}" 매치 0건.\n` : '\nℹ️ 누적 피드백 없음. `/feedback <텍스트>` 로 첫 항목 추가.\n'); + return; + } + chunk(view, `\n📋 **피드백 목록 (${filtered.length}건${filterCategory ? `, 카테고리 "${filterCategory}"` : ''})**\n\n`); + // 최신 → 과거 순, 최대 20건. + const sorted = filtered.slice().sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || '')).slice(0, 20); + for (const e of sorted) { + const date = (e.timestamp || '').slice(0, 10); + const cats = (e.categories || []).join(', ') || '미분류'; + const sentEmoji = e.sentiment === 'positive' ? '🟢' : e.sentiment === 'negative' ? '🔴' : e.sentiment === 'neutral' ? '⚪' : '❓'; + chunk(view, `- ${sentEmoji} \`${date}\` [${cats}] ${e.text.slice(0, 120)}${e.text.length > 120 ? '…' : ''}\n`); + } + if (filtered.length > 20) chunk(view, `\n_…+${filtered.length - 20}건 더 (필터링하거나 \`/feedback path\` 로 직접 파일 열기)_\n`); +} + +async function feedbackSummary(view: any): Promise { + const all = readFeedback(); + if (all.length < 3) { + chunk(view, `\nℹ️ 누적 ${all.length}건 — 패턴 분석엔 최소 3건 필요. \`/feedback <텍스트>\` 로 더 모아 주세요.\n`); + return; + } + chunk(view, `\n📊 **패턴 분석 시작** (누적 ${all.length}건)\n · LLM 호출 중...\n`); + // LLM 입력: 각 entry 의 categories + sentiment + 텍스트 (300자 cap) 만. 토큰 절약. + const summaryInput = all.map((e) => JSON.stringify({ + timestamp: (e.timestamp || '').slice(0, 10), + categories: e.categories || [], + sentiment: e.sentiment || 'unknown', + text: e.text.slice(0, 300), + })).join('\n'); + try { + const report = await callLmSynthesis(`[누적 피드백 ${all.length}건]\n\n${summaryInput}`, FEEDBACK_SUMMARY_PROMPT); + if (!report || !report.trim()) { chunk(view, '\n❌ LLM 빈 응답.\n'); return; } + chunk(view, `\n${report.trim()}\n`); + } catch (e: any) { + chunk(view, `\n❌ 분석 실패: ${e?.message || String(e)}\n`); + } +} + +function feedbackPath(view: any): void { + const p = getFeedbackFilePath(); + if (!p) { chunk(view, '\n⚠️ 워크스페이스 폴더 없음 — 저장 위치 결정 불가.\n'); return; } + chunk(view, `\n📂 \`${p}\`\n · 누적 ${countFeedback()}건 (.jsonl 형식, 한 줄=한 항목)\n · 사람이 직접 편집 가능 — 카테고리 수정·삭제 등.\n`); +} + +async function runFeedback(arg: string, view: any, _context?: vscode.ExtensionContext): Promise { + const trimmed = arg.trim(); + if (!trimmed) { + chunk(view, [ + '\n📋 **/feedback — 고객 피드백 누적 + 패턴 분석**', + '', + '사용법:', + ' `/feedback <텍스트>` — 피드백 저장 (LLM 자동 카테고리 분류)', + ' `/feedback list [카테고리]` — 누적 목록 (최신순, 최대 20건)', + ' `/feedback summary` — 누적 데이터 패턴 분석 리포트 (LLM)', + ' `/feedback path` — 저장 파일 경로 표시', + '', + '예시:', + ' `/feedback 결제 흐름이 너무 복잡해서 중간에 포기했어요 - 김XX`', + ' `/feedback list 결제`', + ' `/feedback summary` (3건+ 누적 시)', + '', + '저장 위치: `/.astra/customer-feedback.jsonl` — 로컬 only, 외부 전송 없음.', + '', + ].join('\n')); + return true; + } + + // sub-command 라우팅 — 첫 토큰이 list/summary/path 면 sub, 아니면 전체를 텍스트로 저장. + const firstSpace = trimmed.search(/\s/); + const head = (firstSpace < 0 ? trimmed : trimmed.slice(0, firstSpace)).toLowerCase(); + const rest = firstSpace < 0 ? '' : trimmed.slice(firstSpace + 1).trim(); + + switch (head) { + case 'list': + feedbackList(rest || undefined, view); + return true; + case 'summary': case 'analyze': case 'report': + await feedbackSummary(view); + return true; + case 'path': + feedbackPath(view); + return true; + default: + // sub-command 아닌 모든 입력은 피드백 텍스트로 저장. + await feedbackSave(trimmed, view); + return true; + } +} + +// ─── /blocked — 전사 across 지연·블로커 한 화면 ───────────────────────── +// `/onesie` 는 멤버 단위; `/blocked` 는 *전체 across*. 매일 아침 1번 보면 +// "오늘 어디 살펴봐야 하나" 가 즉시 잡힘. 단독 운영자(대표) 의 daily ritual. +// +// 카테고리: +// 🔴 지연 (overdue, 마감 < 오늘) — 가장 위, 가장 오래 지연된 것부터 +// 🟡 이번 주 마감 (오늘~+7일) — 다가오는 위험 +// ⚪ 마감일 없음 — 우선순위 합의 대상 +// 옵션: `/blocked @멤버` → 그 멤버만 (overlap with /onesie 하지만 더 압축된 뷰) + +interface ParsedTaskOwner { owner: string | undefined; displayTitle: string; } + +function parseTaskOwner(title: string, notes?: string): ParsedTaskOwner { + // 우선 제목 prefix `[owner]` — /task 와 /meet 양쪽이 이렇게 등록. + const titlePrefix = title.match(/^\[([^\]]+)\]\s*(.+)$/); + if (titlePrefix) return { owner: titlePrefix[1].trim(), displayTitle: titlePrefix[2].trim() }; + // 노트 fallback — `담당: 이름` 또는 `담당: @이름` (구버전 /meet entry 호환). + const notesMatch = (notes || '').match(/담당:\s*(?:@)?([\S]+)/); + if (notesMatch) return { owner: notesMatch[1].trim(), displayTitle: title }; + return { owner: undefined, displayTitle: title }; +} + +async function runBlocked(arg: string, view: any, context?: vscode.ExtensionContext): Promise { + if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /blocked 실행 불가.\n'); return true; } + + // 선택적 @멤버 필터. + const memberFilter = (arg.trim().split(/\s+/)[0] || '').replace(/^@/, '').trim() || undefined; + + chunk(view, `\n🚨 **전사 블로커·지연 뷰**${memberFilter ? ` — @${memberFilter}` : ''}\n`); + chunk(view, '\n📥 Tasks 가져오는 중...\n'); + + const result = await listTasks(context, { showCompleted: false, maxResults: 300 }); + if (!result.ok) { + chunk(view, `\n❌ Tasks 조회 실패: ${result.error}\n`); + return true; + } + const tasks = result.tasks; + + interface Row { due?: string; owner?: string; title: string; } + const today = new Date().toISOString().slice(0, 10); + const weekLater = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + + const overdue: Row[] = []; + const thisWeek: Row[] = []; + const noDate: Row[] = []; + + for (const t of tasks) { + const { owner, displayTitle } = parseTaskOwner(t.title, t.notes); + if (memberFilter && (owner || '').toLowerCase() !== memberFilter.toLowerCase()) continue; + const row: Row = { due: t.due, owner, title: displayTitle }; + if (!t.due) noDate.push(row); + else if (t.due < today) overdue.push(row); + else if (t.due <= weekLater) thisWeek.push(row); + // 그 이후(>7일) 는 /blocked 에서 제외 — 멀리 있는 건 압박 아님. + } + overdue.sort((a, b) => (a.due || '').localeCompare(b.due || '')); // 오래된 것 먼저 + thisWeek.sort((a, b) => (a.due || '').localeCompare(b.due || '')); // 임박한 것 먼저 + noDate.sort((a, b) => (a.owner || 'zzz').localeCompare(b.owner || 'zzz')); + + const fmtRow = (r: Row): string => { + const o = r.owner ? `@${r.owner}` : '(owner 없음)'; + return `- 📅 \`${r.due || '----------'}\` · **${o}** — ${r.title}`; + }; + + const totalShown = overdue.length + thisWeek.length + noDate.length; + if (totalShown === 0) { + chunk(view, `\n✅ 지연·임박 항목 없음${memberFilter ? ` (@${memberFilter})` : ''}. 진행 상황 양호.\n`); + return true; + } + + if (overdue.length > 0) { + chunk(view, `\n## 🔴 지연 ${overdue.length}건 — 즉시 확인 필요\n`); + const MAX_OVERDUE = 20; + for (const r of overdue.slice(0, MAX_OVERDUE)) chunk(view, fmtRow(r) + '\n'); + if (overdue.length > MAX_OVERDUE) chunk(view, `_…+${overdue.length - MAX_OVERDUE}건 더_\n`); + } + if (thisWeek.length > 0) { + chunk(view, `\n## 🟡 이번 주 마감 ${thisWeek.length}건 (~${weekLater})\n`); + const MAX_WEEK = 15; + for (const r of thisWeek.slice(0, MAX_WEEK)) chunk(view, fmtRow(r) + '\n'); + if (thisWeek.length > MAX_WEEK) chunk(view, `_…+${thisWeek.length - MAX_WEEK}건 더_\n`); + } + if (noDate.length > 0) { + chunk(view, `\n## ⚪ 마감일 없음 ${noDate.length}건 — 우선순위 합의 필요\n`); + const MAX_NODATE = 10; + for (const r of noDate.slice(0, MAX_NODATE)) chunk(view, fmtRow(r) + '\n'); + if (noDate.length > MAX_NODATE) chunk(view, `_…+${noDate.length - MAX_NODATE}건 더_\n`); + } + + // 멤버별 압박 요약 (전체 보기일 때만, 의미 있을 때만) + if (!memberFilter && (overdue.length + thisWeek.length) > 0) { + const counts = new Map(); + for (const r of overdue) { + const k = r.owner || '(없음)'; + const c = counts.get(k) || { overdue: 0, week: 0 }; + c.overdue++; + counts.set(k, c); + } + for (const r of thisWeek) { + const k = r.owner || '(없음)'; + const c = counts.get(k) || { overdue: 0, week: 0 }; + c.week++; + counts.set(k, c); + } + const ranked = [...counts.entries()] + .sort((a, b) => (b[1].overdue * 2 + b[1].week) - (a[1].overdue * 2 + a[1].week)); + chunk(view, `\n## 📊 멤버별 압박 ${ranked.length}명\n`); + for (const [member, c] of ranked) { + chunk(view, `- **@${member}** — 지연 ${c.overdue}건${c.week ? ` · 이번 주 ${c.week}건` : ''}\n`); + } + chunk(view, '\n💡 압박 큰 멤버부터 `/onesie @<멤버>` 로 1:1 카드 확인 권장.\n'); + } + return true; +} + +// ─── /standup — 멤버별 공유용 스탠드업 카드 ───────────────────────────── +// `/blocked` 는 *대표가 본다* 용도(분석적), `/standup` 은 *팀에 공유한다* 용도 +// (소통적). 같은 데이터 소스(Google Tasks)지만 다른 포맷·관점: +// - 멤버 단위 그룹핑 (멤버별 한 섹션) +// - "완료 / 진행 / 블로커" 3-row 표준 스탠드업 패턴 +// - 마크다운 단순 — 슬랙·노션에 그대로 복붙 가능 +// - 이번 주 결정(Chronicle ADR) 부록 +// 단독 운영자(대표)가 데이터 다 가지고 있으니 매주/매일 실행해 팀 채널에 게시. + +async function runStandup(arg: string, view: any, context?: vscode.ExtensionContext): Promise { + if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /standup 실행 불가.\n'); return true; } + + // 인자 파싱 — `daily` (1일 윈도우) / `weekly` (7일, 기본) / `monthly` (30일). + const mode = (arg.trim().split(/\s+/)[0] || 'weekly').toLowerCase(); + const windowDays = mode === 'daily' ? 1 : mode === 'monthly' ? 30 : 7; + const modeLabel = mode === 'daily' ? '일일' : mode === 'monthly' ? '월간' : '주간'; + + if (arg.trim() && !['daily', 'weekly', 'monthly', ''].includes(mode)) { + chunk(view, [ + '\n📋 **/standup [daily/weekly/monthly] — 팀 스탠드업 카드**', + '', + '사용법:', + ' `/standup` — 주간 (기본, 7일 윈도우)', + ' `/standup daily` — 일일 (1일 윈도우)', + ' `/standup weekly` — 주간 (7일)', + ' `/standup monthly` — 월간 (30일)', + '', + '멤버별로 완료 / 진행·예정 / 블로커 3-row + 이번 기간 결정 목록을 슬랙·노션에 복붙 가능한 마크다운으로 출력.', + '', + ].join('\n')); + return true; + } + + chunk(view, `\n📊 **팀 스탠드업 — ${modeLabel} (${windowDays}일 윈도우)**\n · ${new Date().toISOString().slice(0, 10)} 기준\n`); + chunk(view, '\n📥 Tasks 가져오는 중...\n'); + + const result = await listTasks(context, { showCompleted: true, maxResults: 300 }); + if (!result.ok) { chunk(view, `\n❌ Tasks 조회 실패: ${result.error}\n`); return true; } + + const today = new Date().toISOString().slice(0, 10); + const windowAgoIso = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000).toISOString(); + const upcomingEnd = new Date(Date.now() + windowDays * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + + // 멤버별 그룹핑. + interface MemberSlot { completed: { date: string; title: string }[]; upcoming: { due: string; title: string }[]; overdue: { due: string; title: string }[]; noDate: { title: string }[]; } + const byMember = new Map(); + const ensure = (k: string): MemberSlot => { + let s = byMember.get(k); + if (!s) { s = { completed: [], upcoming: [], overdue: [], noDate: [] }; byMember.set(k, s); } + return s; + }; + + for (const t of result.tasks) { + const { owner, displayTitle } = parseTaskOwner(t.title, t.notes); + const member = owner || '(owner 없음)'; + const slot = ensure(member); + if (t.status === 'completed') { + if ((t.completed || '') >= windowAgoIso) { + slot.completed.push({ date: (t.completed || '').slice(0, 10), title: displayTitle }); + } + } else { + if (!t.due) slot.noDate.push({ title: displayTitle }); + else if (t.due < today) slot.overdue.push({ due: t.due, title: displayTitle }); + else if (t.due <= upcomingEnd) slot.upcoming.push({ due: t.due, title: displayTitle }); + } + } + // 각 카테고리 내부 정렬. + for (const s of byMember.values()) { + s.completed.sort((a, b) => b.date.localeCompare(a.date)); + s.upcoming.sort((a, b) => a.due.localeCompare(b.due)); + s.overdue.sort((a, b) => a.due.localeCompare(b.due)); + } + + // 멤버 0 명이면 안내. + if (byMember.size === 0) { + chunk(view, '\nℹ️ 이 기간에 활동이 있는 task 가 없습니다. `/task @<멤버> ...` 로 등록 시작.\n'); + return true; + } + + // Chronicle ADR — 이번 윈도우 내 결정. + const store = new ChronicleProjectStore(context); + const profiles = store.getAll(); + interface RecentAdr { date: string; title: string; file: string; mtime: number; } + const recentAdrs: RecentAdr[] = []; + const windowAgoMs = Date.now() - windowDays * 24 * 60 * 60 * 1000; + for (const profile of profiles) { + const decisionsDir = path.join(profile.recordRoot, 'decisions'); + if (!fs.existsSync(decisionsDir)) continue; + let names: string[] = []; + try { names = fs.readdirSync(decisionsDir); } catch { continue; } + for (const fn of names) { + if (!fn.endsWith('.md') || !fn.startsWith('ADR-')) continue; + const fp = path.join(decisionsDir, fn); + let mtime = 0; + try { mtime = fs.statSync(fp).mtimeMs; } catch { continue; } + if (mtime < windowAgoMs) continue; + let content = ''; + try { content = fs.readFileSync(fp, 'utf-8'); } catch { continue; } + const titleMatch = content.match(/^#\s+(.+)$/m); + const title = titleMatch ? titleMatch[1].trim() : fn.replace(/\.md$/, ''); + recentAdrs.push({ date: new Date(mtime).toISOString().slice(0, 10), title, file: fn, mtime }); + } + } + recentAdrs.sort((a, b) => b.mtime - a.mtime); + + // 멤버 정렬 — 활동량(완료+진행+지연) 내림차순. + const memberRanked = [...byMember.entries()].sort((a, b) => { + const score = (s: MemberSlot) => s.completed.length + s.upcoming.length + s.overdue.length * 2; + return score(b[1]) - score(a[1]); + }); + + // 출력 — 슬랙/노션 복붙 친화 단순 마크다운. 헤딩 ## 1회 + 멤버별 ### 그룹. + chunk(view, '\n---\n'); + chunk(view, `\n## 📊 팀 스탠드업 — ${modeLabel}\n`); + chunk(view, `*${windowDays}일 윈도우 · 기준일 ${today}*\n`); + + for (const [member, s] of memberRanked) { + chunk(view, `\n### @${member}\n`); + + // 완료 + if (s.completed.length === 0) { + chunk(view, `**✅ 완료**: _없음_\n`); + } else { + chunk(view, `**✅ 완료 (${s.completed.length})**:\n`); + const MAX = 8; + for (const c of s.completed.slice(0, MAX)) chunk(view, `- ${c.date} — ${c.title}\n`); + if (s.completed.length > MAX) chunk(view, `- _…+${s.completed.length - MAX}건_\n`); + } + + // 진행·예정 + if (s.upcoming.length === 0 && s.noDate.length === 0) { + chunk(view, `**🎯 진행/예정**: _없음_\n`); + } else { + chunk(view, `**🎯 진행/예정 (${s.upcoming.length + s.noDate.length})**:\n`); + const MAX = 6; + for (const u of s.upcoming.slice(0, MAX)) chunk(view, `- ${u.due} — ${u.title}\n`); + for (const n of s.noDate.slice(0, Math.max(0, MAX - s.upcoming.length))) chunk(view, `- (마감 미정) — ${n.title}\n`); + const total = s.upcoming.length + s.noDate.length; + if (total > MAX) chunk(view, `- _…+${total - MAX}건_\n`); + } + + // 블로커 + if (s.overdue.length === 0) { + chunk(view, `**🚧 블로커**: _없음_\n`); + } else { + chunk(view, `**🚧 블로커 (${s.overdue.length}건 지연)**:\n`); + for (const o of s.overdue.slice(0, 5)) chunk(view, `- 🔴 ${o.due} (지남) — ${o.title}\n`); + if (s.overdue.length > 5) chunk(view, `- _…+${s.overdue.length - 5}건_\n`); + } + } + + // 결정 사항 부록. + if (recentAdrs.length > 0) { + chunk(view, `\n---\n\n## 📋 이번 ${modeLabel} 결정 (${recentAdrs.length}건)\n`); + for (const a of recentAdrs.slice(0, 10)) chunk(view, `- ${a.date} — ${a.title}\n`); + if (recentAdrs.length > 10) chunk(view, `- _…+${recentAdrs.length - 10}건_\n`); + } + + chunk(view, '\n---\n\n💡 위 마크다운을 그대로 슬랙·노션에 복붙하세요. 멤버 활동 순 정렬.\n'); + return true; +} + +// ─── /runway — 현금 / 월 소진율 / 남은 개월수 ───────────────────────────── +// 4인 기업 CEO 가 매일·매주 확인하는 가장 중요한 숫자. 회계 시스템 연동 아닌 +// 가벼운 트래커 — 통장 잔고 입력 + 큰 지출/수입 기록 + 자동 계산. 로컬 .jsonl. + +function _fmtKrw(n: number): string { + const sign = n < 0 ? '-' : ''; + const abs = Math.abs(n); + if (abs >= 100_000_000) return `${sign}${(abs / 100_000_000).toFixed(2)}억`; + if (abs >= 10_000) return `${sign}${(abs / 10_000).toFixed(0)}만`; + return `${sign}${abs.toLocaleString('ko-KR')}`; +} + +function _parseAmount(token: string): number | null { + if (!token) return null; + let s = token.replace(/[,_]/g, '').trim(); + let mul = 1; + const m = s.match(/^(-?[\d.]+)\s*(억|만|k|m|b)?$/i); + if (!m) return null; + const base = parseFloat(m[1]); + if (!Number.isFinite(base)) return null; + const unit = (m[2] || '').toLowerCase(); + if (unit === '억') mul = 100_000_000; + else if (unit === '만') mul = 10_000; + else if (unit === 'k') mul = 1_000; + else if (unit === 'm') mul = 1_000_000; + else if (unit === 'b') mul = 1_000_000_000; + return base * mul; +} + +function _runwayShowStatus(view: any): void { + const s = computeRunwayStatus(); + chunk(view, '\n💰 **/runway — 현금 / 월 소진율 / 런웨이**\n'); + if (s.latestCash === null) { + chunk(view, '\nℹ️ 잔고 기록 없음. 시작: `/runway cash 5000만` (현재 통장 잔고 입력)\n'); + return; + } + const cashDate = (s.latestCashAt || '').slice(0, 10); + chunk(view, `\n## 현재 현금\n- **${_fmtKrw(s.latestCash)}원** _(기준: ${cashDate})_\n`); + + chunk(view, '\n## 월 소진율 (burn)\n'); + if (s.explicitBurn !== null) { + chunk(view, `- **${_fmtKrw(s.explicitBurn)}원/월** _(수동 설정)_\n`); + } else if (s.computedBurn !== null) { + const ann = s.last30Days < 30 ? ` _(${s.last30Days}일 데이터 → 30일 환산)_` : ' _(최근 30일 실적)_'; + chunk(view, `- **${_fmtKrw(s.computedBurn)}원/월**${ann}\n`); + chunk(view, ` · 지출 ${_fmtKrw(s.last30Expense)}원 − 수입 ${_fmtKrw(s.last30Revenue)}원\n`); + } else { + chunk(view, '- _데이터 부족_ — `/runway burn 1500만` 또는 `/runway expense 300만 급여` 로 기록\n'); + } + + chunk(view, '\n## 런웨이\n'); + if (s.runwayMonths === null) { + chunk(view, '- _계산 불가_ (잔고 또는 burn 미정)\n'); + } else if (!Number.isFinite(s.runwayMonths)) { + chunk(view, '- ♾️ **흑자 운영** (지출 ≤ 수입)\n'); + } else { + const m = s.runwayMonths; + const emoji = m < 3 ? '🔴' : m < 6 ? '🟡' : '🟢'; + const months = m.toFixed(1); + const exitDate = new Date(Date.now() + m * 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + chunk(view, `- ${emoji} **${months}개월** _(예상 소진: ${exitDate})_\n`); + if (m < 3) chunk(view, ' · ⚠️ **3개월 미만** — 즉시 자금 조달 또는 비용 절감 필요\n'); + else if (m < 6) chunk(view, ' · ⚠️ **6개월 미만** — 자금 계획 점검 권장\n'); + } + + chunk(view, `\n_누적 ${s.totalEntries}건 기록. \`/runway log\` 로 전체 보기, \`/runway path\` 로 파일 위치._\n`); +} + +function _runwayLog(view: any, limit: number): void { + const all = readRunway().slice().sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || '')).slice(0, limit); + if (all.length === 0) { chunk(view, '\nℹ️ 기록 없음. `/runway cash 5000만` 으로 시작.\n'); return; } + chunk(view, `\n📒 **최근 ${all.length}건** (최신순)\n\n`); + const emoji: Record = { + snapshot: '💰', expense: '💸', revenue: '💵', burn: '🔥', + }; + for (const e of all) { + const date = (e.timestamp || '').slice(0, 10); + const cat = e.category ? ` [${e.category}]` : ''; + const memo = e.memo ? ` — ${e.memo}` : ''; + const typeLabel = e.type === 'snapshot' ? '잔고' : e.type === 'expense' ? '지출' : e.type === 'revenue' ? '수입' : '월 burn'; + chunk(view, `- ${emoji[e.type]} \`${date}\` ${typeLabel}: ${_fmtKrw(e.amount)}원${cat}${memo}\n`); + } +} + +async function runRunway(arg: string, view: any, _context?: vscode.ExtensionContext): Promise { + const trimmed = arg.trim(); + if (!trimmed) { _runwayShowStatus(view); return true; } + + const parts = trimmed.split(/\s+/); + const sub = parts[0].toLowerCase(); + + if (sub === 'help' || sub === '?') { + chunk(view, [ + '\n💰 **/runway — 현금 / 월 소진율 / 런웨이**', + '', + '사용법:', + ' `/runway` — 현재 상태 카드 (현금 / burn / 남은 개월수)', + ' `/runway cash <금액> [메모]` — 통장 잔고 스냅샷 기록', + ' `/runway expense <금액> [메모]` — 지출 기록 (월 burn 자동 계산에 반영)', + ' `/runway revenue <금액> [메모]` — 수입 기록 (burn 상쇄)', + ' `/runway burn <월 금액>` — 월 소진율 수동 설정 (자동 계산보다 우선)', + ' `/runway log [N]` — 최근 N건 기록 (기본 20)', + ' `/runway path` — .jsonl 파일 경로', + '', + '금액 단위: `5000만` / `1.5억` / `300000` 모두 OK. 소수점·콤마 허용.', + '저장 위치: `/.astra/runway.jsonl` (로컬 only, 외부 안 보냄).\n', + ].join('\n')); + return true; + } + + if (sub === 'path') { + const p = getRunwayFilePath(); + if (!p) { chunk(view, '\n⚠️ 워크스페이스 폴더 없음 — 저장 위치 결정 불가.\n'); return true; } + const count = readRunway().length; + chunk(view, `\n📂 \`${p}\`\n · 누적 ${count}건 (.jsonl 형식, 한 줄=한 항목)\n · 사람이 직접 편집 가능.\n`); + return true; + } + + if (sub === 'log') { + const n = parts[1] ? parseInt(parts[1], 10) : 20; + _runwayLog(view, Number.isFinite(n) && n > 0 ? n : 20); + return true; + } + + if (sub === 'cash' || sub === 'expense' || sub === 'revenue' || sub === 'burn') { + const amount = _parseAmount(parts[1] || ''); + if (amount === null) { + chunk(view, `\n❌ 금액 파싱 실패: "${parts[1] || ''}". 예: \`5000만\` / \`1.5억\` / \`300000\`\n`); + return true; + } + const memo = parts.slice(2).join(' ').trim() || undefined; + const typeMap: Record = { cash: 'snapshot', expense: 'expense', revenue: 'revenue', burn: 'burn' }; + const entry: RunwayEntry = { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + timestamp: new Date().toISOString(), + type: typeMap[sub], + amount, + currency: 'KRW', + memo, + }; + const res = appendRunway(entry); + if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; } + const labels: Record = { cash: '잔고 스냅샷', expense: '지출', revenue: '수입', burn: '월 burn 설정' }; + chunk(view, `\n✅ ${labels[sub]} 기록: **${_fmtKrw(amount)}원**${memo ? ` — ${memo}` : ''}\n`); + // 상태 카드 자동 표시 — 변화 직후 확인이 자연스러움. + _runwayShowStatus(view); + return true; + } + + chunk(view, `\n❌ 알 수 없는 서브명령: "${sub}". \`/runway help\` 참조.\n`); + return true; +} + +// ─── /customers — 고객사 / MRR / 갱신 / 위험 ────────────────────────────── +// 4인 기업 매출 시야. event-sourced 로그 — 같은 customerId 의 이벤트를 시간순 +// 재생해 현재 상태 (MRR / 갱신일 / status) 도출. CRM 아닌 가벼운 트래커. + +function _daysUntil(isoDate: string | undefined, now: Date = new Date()): number | null { + if (!isoDate) return null; + const t = Date.parse(isoDate); + if (!Number.isFinite(t)) return null; + return Math.ceil((t - now.getTime()) / (24 * 60 * 60 * 1000)); +} + +function _customersDashboard(view: any): void { + const states = computeCustomerStates(); + const all = Array.from(states.values()); + if (all.length === 0) { + chunk(view, '\n📒 **/customers — 고객사 / MRR / 갱신**\n\nℹ️ 등록된 고객 없음. 시작: `/customers add <이름> [갱신일] [요금제]`\n예: `/customers add 큐브앤코 200만 2026-12-01 enterprise`\n'); + return; + } + + const active = all.filter((c) => c.status === 'active'); + const atRisk = all.filter((c) => c.status === 'at-risk'); + const churned = all.filter((c) => c.status === 'churned'); + const totalMrr = active.reduce((sum, c) => sum + c.mrr, 0) + atRisk.reduce((sum, c) => sum + c.mrr, 0); + const riskMrr = atRisk.reduce((sum, c) => sum + c.mrr, 0); + + chunk(view, '\n📒 **/customers — 고객사 대시보드**\n'); + chunk(view, '\n## 요약\n'); + chunk(view, `- **MRR**: ${_fmtKrw(totalMrr)}원/월 _(연 ${_fmtKrw(totalMrr * 12)}원)_\n`); + chunk(view, `- 활성 ${active.length}곳 · 위험 ${atRisk.length}곳 · 이탈 ${churned.length}곳\n`); + if (riskMrr > 0) chunk(view, `- ⚠️ **위험 MRR**: ${_fmtKrw(riskMrr)}원/월 _(전체의 ${((riskMrr / totalMrr) * 100).toFixed(0)}%)_\n`); + + // 갱신 임박 — 30일 이내, 활성 또는 위험. + const upcoming = [...active, ...atRisk] + .map((c) => ({ c, days: _daysUntil(c.renewalAt) })) + .filter((x) => x.days !== null && x.days >= 0 && x.days <= 30) + .sort((a, b) => (a.days as number) - (b.days as number)); + if (upcoming.length > 0) { + chunk(view, '\n## 🔔 30일 내 갱신\n'); + for (const { c, days } of upcoming) { + const emoji = c.status === 'at-risk' ? '⚠️' : (days as number) <= 7 ? '🔴' : (days as number) <= 14 ? '🟡' : '🟢'; + chunk(view, `- ${emoji} **${c.customerName}** — ${c.renewalAt} _(D-${days})_ · ${_fmtKrw(c.mrr)}원/월\n`); + } + } + + if (atRisk.length > 0) { + chunk(view, '\n## ⚠️ 위험 고객\n'); + for (const c of atRisk.sort((a, b) => b.mrr - a.mrr)) { + const lastRisk = c.notes.slice().reverse().find((n) => n.type === 'risk'); + chunk(view, `- **${c.customerName}** — ${_fmtKrw(c.mrr)}원/월${lastRisk ? ` · ${lastRisk.memo.slice(0, 60)}` : ''}\n`); + } + } + + // 활성 — MRR 순 top 10. + if (active.length > 0) { + chunk(view, '\n## 활성 고객 (MRR 순)\n'); + const top = active.slice().sort((a, b) => b.mrr - a.mrr).slice(0, 10); + for (const c of top) { + const renewalNote = c.renewalAt ? ` · 갱신 ${c.renewalAt}` : ''; + chunk(view, `- ${c.customerName} — ${_fmtKrw(c.mrr)}원/월${renewalNote}\n`); + } + if (active.length > 10) chunk(view, `- _…+${active.length - 10}곳_\n`); + } + + chunk(view, `\n_누적 이벤트 ${readCustomerEvents().length}건. \`/customers help\` 로 명령어._\n`); +} + +function _customersList(view: any, filter: string | undefined): void { + const states = computeCustomerStates(); + let all = Array.from(states.values()); + if (filter === 'active' || filter === 'at-risk' || filter === 'risk' || filter === 'churned') { + const target = filter === 'risk' ? 'at-risk' : filter; + all = all.filter((c) => c.status === target); + } + if (all.length === 0) { chunk(view, `\nℹ️ 매치 없음${filter ? ` (필터: ${filter})` : ''}.\n`); return; } + chunk(view, `\n📋 **고객 목록 (${all.length}곳${filter ? `, ${filter}` : ''})**\n\n`); + const sorted = all.slice().sort((a, b) => b.mrr - a.mrr); + for (const c of sorted) { + const emoji = c.status === 'active' ? '🟢' : c.status === 'at-risk' ? '🟡' : '🔴'; + const renewalNote = c.renewalAt ? ` · 갱신 ${c.renewalAt}` : ''; + const planNote = c.plan ? ` · ${c.plan}` : ''; + chunk(view, `- ${emoji} **${c.customerName}** — ${_fmtKrw(c.mrr)}원/월${planNote}${renewalNote}\n`); + } +} + +function _customersShow(view: any, name: string): void { + const states = computeCustomerStates(); + const cid = customerIdFromName(name); + const c = states.get(cid); + if (!c) { + // partial match fallback + const candidates = Array.from(states.values()).filter((x) => x.customerName.toLowerCase().includes(name.toLowerCase())); + if (candidates.length === 0) { chunk(view, `\n❌ "${name}" 매치 0건.\n`); return; } + if (candidates.length > 1) { + chunk(view, `\nℹ️ "${name}" 부분 매치 ${candidates.length}곳:\n`); + for (const x of candidates) chunk(view, `- ${x.customerName}\n`); + return; + } + return _customersShow(view, candidates[0].customerName); + } + + const emoji = c.status === 'active' ? '🟢' : c.status === 'at-risk' ? '🟡' : '🔴'; + chunk(view, `\n${emoji} **${c.customerName}** _(${c.status})_\n`); + chunk(view, `\n- MRR: **${_fmtKrw(c.mrr)}원/월** _(연 ${_fmtKrw(c.mrr * 12)}원)_\n`); + if (c.plan) chunk(view, `- 요금제: ${c.plan}\n`); + if (c.renewalAt) { + const d = _daysUntil(c.renewalAt); + const dn = d !== null ? (d >= 0 ? `D-${d}` : `${-d}일 지남`) : ''; + chunk(view, `- 갱신일: ${c.renewalAt} _(${dn})_\n`); + } + chunk(view, `- 시작: ${(c.startedAt || '').slice(0, 10)} · 누적 이벤트 ${c.eventCount}건\n`); + + if (c.notes.length > 0) { + chunk(view, `\n## 메모·이벤트 (${c.notes.length}건, 최신순)\n`); + const recent = c.notes.slice().reverse().slice(0, 10); + for (const n of recent) { + const date = (n.timestamp || '').slice(0, 10); + const tagEmoji = n.type === 'risk' ? '⚠️' : n.type === 'churn' ? '💀' : '📝'; + chunk(view, `- ${tagEmoji} \`${date}\` ${n.memo}\n`); + } + if (c.notes.length > 10) chunk(view, `- _…+${c.notes.length - 10}건_\n`); + } +} + +async function runCustomers(arg: string, view: any, _context?: vscode.ExtensionContext): Promise { + const trimmed = arg.trim(); + if (!trimmed) { _customersDashboard(view); return true; } + + const parts = trimmed.split(/\s+/); + const sub = parts[0].toLowerCase(); + + if (sub === 'help' || sub === '?') { + chunk(view, [ + '\n📒 **/customers — 고객사 / MRR / 갱신 트래커**', + '', + '사용법:', + ' `/customers` — 대시보드 (MRR, 위험, 갱신 임박)', + ' `/customers add <이름> [갱신일] [요금제]` — 신규 등록', + ' `/customers update <이름> mrr=<금액> renewal=<날짜>`— 정보 수정', + ' `/customers renew <이름> <새 갱신일> [새 MRR]` — 갱신 처리 (active 로 복귀)', + ' `/customers risk <이름> <사유>` — 위험 표시', + ' `/customers churn <이름> <사유>` — 이탈 처리 (MRR=0)', + ' `/customers note <이름> <텍스트>` — 자유 메모', + ' `/customers show <이름>` — 상세 (부분 매치 OK)', + ' `/customers list [active/risk/churned]` — 필터 목록', + ' `/customers path` — .jsonl 파일 경로', + '', + 'MRR 금액 단위: `200만` / `1.5억` / `300000` 모두 OK.', + '갱신일: `YYYY-MM-DD` (예: `2026-12-01`).', + '저장: `/.astra/customers.jsonl` (로컬 only, 외부 안 보냄).\n', + ].join('\n')); + return true; + } + + if (sub === 'path') { + const p = getCustomersFilePath(); + if (!p) { chunk(view, '\n⚠️ 워크스페이스 폴더 없음.\n'); return true; } + chunk(view, `\n📂 \`${p}\`\n · 누적 이벤트 ${readCustomerEvents().length}건 (.jsonl).\n`); + return true; + } + + if (sub === 'list') { _customersList(view, parts[1]?.toLowerCase()); return true; } + + if (sub === 'show') { + const name = parts.slice(1).join(' ').trim(); + if (!name) { chunk(view, '\n❌ 사용법: `/customers show <이름>`\n'); return true; } + _customersShow(view, name); + return true; + } + + // 이벤트 기록 서브명령들 — add / update / renew / risk / churn / note + if (sub === 'add') { + const name = parts[1]; + const mrrToken = parts[2]; + const renewalToken = parts[3]; + const planToken = parts[4]; + if (!name || !mrrToken) { + chunk(view, '\n❌ 사용법: `/customers add <이름> [갱신일] [요금제]`\n예: `/customers add 큐브앤코 200만 2026-12-01 enterprise`\n'); + return true; + } + const mrr = _parseAmount(mrrToken); + if (mrr === null) { chunk(view, `\n❌ MRR 파싱 실패: "${mrrToken}"\n`); return true; } + const event: CustomerEvent = { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + timestamp: new Date().toISOString(), + customerId: customerIdFromName(name), + customerName: name, + type: 'add', + mrr, + renewalAt: renewalToken && /^\d{4}-\d{2}-\d{2}$/.test(renewalToken) ? renewalToken : undefined, + plan: planToken, + }; + const res = appendCustomerEvent(event); + if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; } + chunk(view, `\n✅ **${name}** 등록 — MRR ${_fmtKrw(mrr)}원/월${event.renewalAt ? ` · 갱신 ${event.renewalAt}` : ''}${event.plan ? ` · ${event.plan}` : ''}\n`); + return true; + } + + if (sub === 'renew') { + const name = parts[1]; + const newRenewal = parts[2]; + const newMrrToken = parts[3]; + if (!name || !newRenewal) { chunk(view, '\n❌ 사용법: `/customers renew <이름> <새 갱신일> [새 MRR]`\n'); return true; } + if (!/^\d{4}-\d{2}-\d{2}$/.test(newRenewal)) { chunk(view, `\n❌ 갱신일 형식: YYYY-MM-DD (입력: "${newRenewal}")\n`); return true; } + const newMrr = newMrrToken ? _parseAmount(newMrrToken) : null; + const event: CustomerEvent = { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + timestamp: new Date().toISOString(), + customerId: customerIdFromName(name), + customerName: name, + type: 'renew', + renewalAt: newRenewal, + mrr: newMrr ?? undefined, + }; + const res = appendCustomerEvent(event); + if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; } + chunk(view, `\n🔄 **${name}** 갱신 — ${newRenewal}${newMrr !== null ? ` · MRR ${_fmtKrw(newMrr)}원/월` : ''}\n`); + return true; + } + + if (sub === 'risk' || sub === 'churn' || sub === 'note') { + const name = parts[1]; + const memo = parts.slice(2).join(' ').trim(); + if (!name || !memo) { + chunk(view, `\n❌ 사용법: \`/customers ${sub} <이름> <${sub === 'note' ? '텍스트' : '사유'}>\`\n`); + return true; + } + const event: CustomerEvent = { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + timestamp: new Date().toISOString(), + customerId: customerIdFromName(name), + customerName: name, + type: sub, + memo, + }; + const res = appendCustomerEvent(event); + if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; } + const emoji = sub === 'risk' ? '⚠️' : sub === 'churn' ? '💀' : '📝'; + const label = sub === 'risk' ? '위험 표시' : sub === 'churn' ? '이탈 처리' : '메모 추가'; + chunk(view, `\n${emoji} **${name}** ${label}: ${memo}\n`); + return true; + } + + if (sub === 'update') { + const name = parts[1]; + if (!name) { chunk(view, '\n❌ 사용법: `/customers update <이름> mrr=<금액> renewal= plan=<요금제>`\n'); return true; } + const rest = parts.slice(2); + const event: CustomerEvent = { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + timestamp: new Date().toISOString(), + customerId: customerIdFromName(name), + customerName: name, + type: 'update', + }; + let touched = false; + for (const kv of rest) { + const m = kv.match(/^(\w+)=(.+)$/); + if (!m) continue; + const k = m[1].toLowerCase(); + const v = m[2]; + if (k === 'mrr') { + const n = _parseAmount(v); + if (n !== null) { event.mrr = n; touched = true; } + } else if (k === 'renewal' || k === 'renewalat') { + if (/^\d{4}-\d{2}-\d{2}$/.test(v)) { event.renewalAt = v; touched = true; } + } else if (k === 'plan') { + event.plan = v; touched = true; + } + } + if (!touched) { chunk(view, '\n❌ 변경할 필드 없음. 예: `/customers update 큐브앤코 mrr=300만 renewal=2026-12-15`\n'); return true; } + const res = appendCustomerEvent(event); + if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; } + const changes: string[] = []; + if (event.mrr !== undefined) changes.push(`MRR=${_fmtKrw(event.mrr)}원`); + if (event.renewalAt) changes.push(`갱신=${event.renewalAt}`); + if (event.plan) changes.push(`요금제=${event.plan}`); + chunk(view, `\n✏️ **${name}** 업데이트: ${changes.join(' · ')}\n`); + return true; + } + + chunk(view, `\n❌ 알 수 없는 서브명령: "${sub}". \`/customers help\` 참조.\n`); + return true; +} + +// ─── /hire — 채용 파이프라인 ────────────────────────────────────────────── +// event-sourced. 후보자별 단계 + 노트 누적. 4인 → 확장 시점의 시야 확보. + +const STAGE_ORDER: Record = { + inbox: 1, screened: 2, interview: 3, final: 4, offer: 5, + accepted: 6, hired: 7, rejected: 99, declined: 99, +}; +const TERMINAL_STAGES = new Set(['hired', 'rejected', 'declined']); + +function _stageEmoji(stage: string): string { + switch (stage) { + case 'inbox': return '📥'; + case 'screened': return '🔍'; + case 'interview': return '💬'; + case 'final': return '🎯'; + case 'offer': return '📨'; + case 'accepted': return '🤝'; + case 'hired': return '🎉'; + case 'rejected': return '❌'; + case 'declined': return '🚪'; + default: return '•'; + } +} + +function _hireDashboard(view: any): void { + const states = computeCandidateStates(); + const all = Array.from(states.values()); + if (all.length === 0) { + chunk(view, '\n👥 **/hire — 채용 파이프라인**\n\nℹ️ 등록된 후보 없음. 시작: `/hire add <이름> <역할>`\n예: `/hire add 김개발 백엔드`\n'); + return; + } + + const active = all.filter((c) => !TERMINAL_STAGES.has(c.stage)); + const hired = all.filter((c) => c.stage === 'hired'); + const rejected = all.filter((c) => c.stage === 'rejected' || c.stage === 'declined'); + + chunk(view, '\n👥 **/hire — 채용 파이프라인**\n'); + chunk(view, '\n## 요약\n'); + chunk(view, `- 진행 중 **${active.length}명** · 합격 ${hired.length}명 · 종료 ${rejected.length}명\n`); + + // 역할별 그룹 + const byRole = new Map(); + for (const c of active) { + const role = c.role || '미지정'; + if (!byRole.has(role)) byRole.set(role, []); + byRole.get(role)!.push(c); + } + if (byRole.size > 0) { + chunk(view, '\n## 역할별 진행\n'); + for (const [role, cs] of byRole) { + chunk(view, `- **${role}**: ${cs.length}명\n`); + } + } + + // 단계별 후보 — 진행 중만 + const byStage = new Map(); + for (const c of active) { + if (!byStage.has(c.stage)) byStage.set(c.stage, []); + byStage.get(c.stage)!.push(c); + } + const sortedStages = Array.from(byStage.keys()).sort((a, b) => (STAGE_ORDER[a] ?? 50) - (STAGE_ORDER[b] ?? 50)); + if (sortedStages.length > 0) { + chunk(view, '\n## 단계별\n'); + for (const stage of sortedStages) { + const list = byStage.get(stage)!; + chunk(view, `\n### ${_stageEmoji(stage)} ${stage} (${list.length})\n`); + for (const c of list.slice().sort((a, b) => (b.lastEventAt || '').localeCompare(a.lastEventAt || ''))) { + const daysIn = Math.floor((Date.now() - Date.parse(c.lastEventAt)) / (24 * 60 * 60 * 1000)); + const stale = daysIn > 7 ? ` ⏰ ${daysIn}일 정체` : ''; + const salary = c.salary !== undefined ? ` · ${_fmtKrw(c.salary)}원` : ''; + chunk(view, `- ${c.candidateName} _(${c.role || '미지정'})_${salary}${stale}\n`); + } + } + } + + // 최근 합격 + if (hired.length > 0) { + chunk(view, `\n## 🎉 최근 합격 (${hired.length}명)\n`); + const recent = hired.slice().sort((a, b) => (b.lastEventAt || '').localeCompare(a.lastEventAt || '')).slice(0, 5); + for (const c of recent) { + chunk(view, `- ${c.candidateName} _(${c.role})_ — ${(c.lastEventAt || '').slice(0, 10)}\n`); + } + } + + chunk(view, `\n_누적 이벤트 ${readHireEvents().length}건. \`/hire help\` 로 명령어._\n`); +} + +function _hireList(view: any, filter: string | undefined): void { + const states = computeCandidateStates(); + let all = Array.from(states.values()); + if (filter) { + const f = filter.toLowerCase(); + if (f === 'active') all = all.filter((c) => !TERMINAL_STAGES.has(c.stage)); + else if (f === 'closed' || f === 'terminal') all = all.filter((c) => TERMINAL_STAGES.has(c.stage)); + else all = all.filter((c) => c.stage === f || (c.role || '').toLowerCase() === f); + } + if (all.length === 0) { chunk(view, `\nℹ️ 매치 없음${filter ? ` (필터: ${filter})` : ''}.\n`); return; } + chunk(view, `\n📋 **후보 목록 (${all.length}명${filter ? `, ${filter}` : ''})**\n\n`); + const sorted = all.slice().sort((a, b) => (STAGE_ORDER[a.stage] ?? 50) - (STAGE_ORDER[b.stage] ?? 50)); + for (const c of sorted) { + const daysIn = Math.floor((Date.now() - Date.parse(c.lastEventAt)) / (24 * 60 * 60 * 1000)); + chunk(view, `- ${_stageEmoji(c.stage)} **${c.candidateName}** _(${c.role || '미지정'})_ · ${c.stage} · ${daysIn}일 전\n`); + } +} + +function _hireShow(view: any, name: string): void { + const states = computeCandidateStates(); + const cid = candidateIdFromName(name); + let c = states.get(cid); + if (!c) { + const candidates = Array.from(states.values()).filter((x) => x.candidateName.toLowerCase().includes(name.toLowerCase())); + if (candidates.length === 0) { chunk(view, `\n❌ "${name}" 매치 0건.\n`); return; } + if (candidates.length > 1) { + chunk(view, `\nℹ️ "${name}" 부분 매치 ${candidates.length}명:\n`); + for (const x of candidates) chunk(view, `- ${x.candidateName} (${x.role})\n`); + return; + } + c = candidates[0]; + } + + chunk(view, `\n${_stageEmoji(c.stage)} **${c.candidateName}** _(${c.role || '미지정'})_\n`); + chunk(view, `\n- 단계: **${c.stage}**\n`); + if (c.salary !== undefined) chunk(view, `- 제안 연봉: ${_fmtKrw(c.salary)}원\n`); + chunk(view, `- 시작: ${(c.addedAt || '').slice(0, 10)} · 최근 변경: ${(c.lastEventAt || '').slice(0, 10)} · 이벤트 ${c.eventCount}건\n`); + + if (c.notes.length > 0) { + chunk(view, `\n## 메모·이벤트 (${c.notes.length}건)\n`); + const recent = c.notes.slice().reverse().slice(0, 10); + for (const n of recent) { + const date = (n.timestamp || '').slice(0, 10); + chunk(view, `- \`${date}\` ${n.memo}\n`); + } + if (c.notes.length > 10) chunk(view, `- _…+${c.notes.length - 10}건_\n`); + } +} + +async function runHire(arg: string, view: any, _context?: vscode.ExtensionContext): Promise { + const trimmed = arg.trim(); + if (!trimmed) { _hireDashboard(view); return true; } + + const parts = trimmed.split(/\s+/); + const sub = parts[0].toLowerCase(); + + if (sub === 'help' || sub === '?') { + chunk(view, [ + '\n👥 **/hire — 채용 파이프라인**', + '', + '사용법:', + ' `/hire` — 파이프라인 대시보드', + ' `/hire add <이름> <역할>` — 신규 후보 (inbox 단계)', + ' `/hire stage <이름> <새 단계>` — 단계 이동', + ' `/hire note <이름> <텍스트>` — 자유 메모', + ' `/hire offer <이름> <연봉> [입사일]` — 오퍼 발송 (단계=offer)', + ' `/hire hire <이름> [입사일]` — 입사 확정 (단계=hired)', + ' `/hire reject <이름> <사유>` — 거절 (회사 측)', + ' `/hire decline <이름> <사유>` — 후보 사양', + ' `/hire show <이름>` — 상세 + 이력', + ' `/hire list [active/closed/단계명/역할]` — 필터 목록', + ' `/hire path` — 파일 위치', + '', + '단계 (기본 파이프라인): inbox → screened → interview → final → offer → accepted → hired', + '터미널: rejected · declined', + '', + '연봉 단위: `4500만` / `1억` / `45000000` 모두 OK.', + '입사일: `YYYY-MM-DD`.', + '저장: `/.astra/hire.jsonl` (로컬 only).\n', + ].join('\n')); + return true; + } + + if (sub === 'path') { + const p = getHireFilePath(); + if (!p) { chunk(view, '\n⚠️ 워크스페이스 폴더 없음.\n'); return true; } + chunk(view, `\n📂 \`${p}\`\n · 누적 이벤트 ${readHireEvents().length}건 (.jsonl).\n`); + return true; + } + + if (sub === 'list') { _hireList(view, parts[1]); return true; } + if (sub === 'show') { + const name = parts.slice(1).join(' ').trim(); + if (!name) { chunk(view, '\n❌ 사용법: `/hire show <이름>`\n'); return true; } + _hireShow(view, name); + return true; + } + + if (sub === 'add') { + const name = parts[1]; + const role = parts.slice(2).join(' ').trim(); + if (!name || !role) { chunk(view, '\n❌ 사용법: `/hire add <이름> <역할>`\n예: `/hire add 김개발 백엔드 시니어`\n'); return true; } + const event: HireEvent = { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + timestamp: new Date().toISOString(), + candidateId: candidateIdFromName(name), + candidateName: name, + role, + type: 'add', + stage: 'inbox', + }; + const res = appendHireEvent(event); + if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; } + chunk(view, `\n📥 **${name}** 등록 — ${role} (inbox)\n`); + return true; + } + + if (sub === 'stage') { + const name = parts[1]; + const newStage = parts[2]?.toLowerCase(); + if (!name || !newStage) { chunk(view, '\n❌ 사용법: `/hire stage <이름> <단계>`\n'); return true; } + const existing = computeCandidateStates().get(candidateIdFromName(name)); + if (!existing) { chunk(view, `\n❌ "${name}" 등록되지 않음. \`/hire add\` 먼저.\n`); return true; } + const event: HireEvent = { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + timestamp: new Date().toISOString(), + candidateId: existing.candidateId, + candidateName: existing.candidateName, + role: existing.role, + type: 'stage', + stage: newStage, + }; + const res = appendHireEvent(event); + if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; } + chunk(view, `\n${_stageEmoji(newStage)} **${existing.candidateName}**: ${existing.stage} → ${newStage}\n`); + return true; + } + + if (sub === 'offer') { + const name = parts[1]; + const salaryToken = parts[2]; + const startDate = parts[3]; + if (!name || !salaryToken) { chunk(view, '\n❌ 사용법: `/hire offer <이름> <연봉> [입사일]`\n예: `/hire offer 김개발 6000만 2026-07-01`\n'); return true; } + const existing = computeCandidateStates().get(candidateIdFromName(name)); + if (!existing) { chunk(view, `\n❌ "${name}" 등록되지 않음.\n`); return true; } + const salary = _parseAmount(salaryToken); + if (salary === null) { chunk(view, `\n❌ 연봉 파싱 실패: "${salaryToken}"\n`); return true; } + const event: HireEvent = { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + timestamp: new Date().toISOString(), + candidateId: existing.candidateId, + candidateName: existing.candidateName, + role: existing.role, + type: 'offer', + stage: 'offer', + salary, + memo: startDate ? `오퍼 발송 (입사 예정: ${startDate})` : '오퍼 발송', + }; + const res = appendHireEvent(event); + if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; } + chunk(view, `\n📨 **${existing.candidateName}** 오퍼 — ${_fmtKrw(salary)}원${startDate ? ` · 입사 ${startDate}` : ''}\n`); + return true; + } + + if (sub === 'hire') { + const name = parts[1]; + const startDate = parts[2]; + if (!name) { chunk(view, '\n❌ 사용법: `/hire hire <이름> [입사일]`\n'); return true; } + const existing = computeCandidateStates().get(candidateIdFromName(name)); + if (!existing) { chunk(view, `\n❌ "${name}" 등록되지 않음.\n`); return true; } + const event: HireEvent = { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + timestamp: new Date().toISOString(), + candidateId: existing.candidateId, + candidateName: existing.candidateName, + role: existing.role, + type: 'hire', + stage: 'hired', + memo: startDate ? `입사 확정 (시작: ${startDate})` : '입사 확정', + }; + const res = appendHireEvent(event); + if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; } + chunk(view, `\n🎉 **${existing.candidateName}** 입사 확정 — ${existing.role}${startDate ? ` (${startDate})` : ''}\n`); + return true; + } + + if (sub === 'reject' || sub === 'decline' || sub === 'note') { + const name = parts[1]; + const memo = parts.slice(2).join(' ').trim(); + if (!name || !memo) { chunk(view, `\n❌ 사용법: \`/hire ${sub} <이름> <${sub === 'note' ? '텍스트' : '사유'}>\`\n`); return true; } + const existing = computeCandidateStates().get(candidateIdFromName(name)); + if (!existing) { chunk(view, `\n❌ "${name}" 등록되지 않음.\n`); return true; } + const event: HireEvent = { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + timestamp: new Date().toISOString(), + candidateId: existing.candidateId, + candidateName: existing.candidateName, + role: existing.role, + type: sub, + stage: sub === 'note' ? undefined : sub === 'reject' ? 'rejected' : 'declined', + memo, + }; + const res = appendHireEvent(event); + if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; } + const labels: Record = { reject: '❌ 거절', decline: '🚪 사양', note: '📝 메모' }; + chunk(view, `\n${labels[sub]} **${existing.candidateName}**: ${memo}\n`); + return true; + } + + chunk(view, `\n❌ 알 수 없는 서브명령: "${sub}". \`/hire help\` 참조.\n`); + return true; +} + +// ─── /morning — 매일 아침 통합 대시보드 ────────────────────────────────── +// CEO 의 첫 명령. 지금까지 만든 모든 트래커의 핵심을 한 화면에: +// 1. 🚨 긴급 (runway < 3개월, 위험 고객, 지연 작업, 정체 후보) +// 2. 💰 재무 (현금 / burn / 런웨이) +// 3. 📒 고객 (MRR / 갱신 임박 / 위험) +// 4. 👥 팀 (지연/임박 작업 멤버별 카운트) +// 5. 🎯 채용 (단계별 + 정체) +// 6. 📋 오늘의 액션 (위 데이터에서 자동 도출) + +async function runMorning(arg: string, view: any, context?: vscode.ExtensionContext): Promise { + const mode = (arg.trim().split(/\s+/)[0] || '').toLowerCase(); + const brief = mode === 'brief' || mode === 'short'; + + const today = new Date().toISOString().slice(0, 10); + chunk(view, `\n☀️ **오늘 (${today}) — 통합 대시보드**\n`); + + // ─── 1. 데이터 수집 ────────────────────────────────────────── + const runway = computeRunwayStatus(); + const customerStates = computeCustomerStates(); + const customers = Array.from(customerStates.values()); + const candidateStates = computeCandidateStates(); + const candidates = Array.from(candidateStates.values()); + + // Tasks 는 OAuth 필요 — 실패해도 다른 섹션은 계속 보여줘야 함. + let tasks: any[] = []; + let tasksError: string | undefined; + if (context) { + try { + const res = await listTasks(context, { showCompleted: false, maxResults: 300 }); + if (res.ok) tasks = res.tasks; + else tasksError = res.error; + } catch (e: any) { tasksError = e?.message || String(e); } + } + + // ─── 2. 🚨 긴급 알림 ───────────────────────────────────────── + const urgent: string[] = []; + + // runway < 3개월 + if (runway.runwayMonths !== null && Number.isFinite(runway.runwayMonths) && runway.runwayMonths < 3) { + urgent.push(`🔴 **런웨이 ${runway.runwayMonths.toFixed(1)}개월** — 즉시 자금 조달/절감 필요`); + } else if (runway.runwayMonths !== null && Number.isFinite(runway.runwayMonths) && runway.runwayMonths < 6) { + urgent.push(`🟡 런웨이 ${runway.runwayMonths.toFixed(1)}개월 — 자금 계획 점검 권장`); + } + + // 위험 고객 + const atRiskCustomers = customers.filter((c) => c.status === 'at-risk'); + const atRiskMrr = atRiskCustomers.reduce((s, c) => s + c.mrr, 0); + if (atRiskCustomers.length > 0) { + urgent.push(`⚠️ 위험 고객 **${atRiskCustomers.length}곳** (MRR ${_fmtKrw(atRiskMrr)}원/월 노출)`); + } + + // 7일 내 갱신 + const upcomingRenewals = [...customers].filter((c) => c.status !== 'churned') + .map((c) => ({ c, days: _daysUntil(c.renewalAt) })) + .filter((x) => x.days !== null && x.days >= 0 && x.days <= 7); + if (upcomingRenewals.length > 0) { + urgent.push(`🔔 7일 내 갱신 **${upcomingRenewals.length}건**`); + } + + // 지연 작업 카운트 + const overdueTasks = tasks.filter((t) => t.due && t.due < today); + if (overdueTasks.length > 0) { + urgent.push(`🚧 지연 작업 **${overdueTasks.length}건**`); + } + + // 정체 후보 (7일+) + const activeCandidate = candidates.filter((c) => !TERMINAL_STAGES.has(c.stage)); + const stalled = activeCandidate.filter((c) => (Date.now() - Date.parse(c.lastEventAt)) > 7 * 24 * 60 * 60 * 1000); + if (stalled.length > 0) { + urgent.push(`⏰ 정체 후보 **${stalled.length}명** (7일+ 미변동)`); + } + + if (urgent.length === 0) { + chunk(view, '\n## ✅ 긴급 알림 없음\n'); + } else { + chunk(view, `\n## 🚨 긴급 (${urgent.length}건)\n`); + for (const u of urgent) chunk(view, `- ${u}\n`); + } + + if (brief) { + // brief 모드는 액션 3개만 도출하고 종료. + chunk(view, '\n## 📋 오늘의 액션 (top 3)\n'); + const actions = _morningActions(runway, customers, upcomingRenewals, overdueTasks, stalled, candidates); + for (const a of actions.slice(0, 3)) chunk(view, `- ${a}\n`); + return true; + } + + // ─── 3. 💰 재무 ────────────────────────────────────────────── + chunk(view, '\n## 💰 재무\n'); + if (runway.latestCash === null) { + chunk(view, '- _데이터 없음_ — `/runway cash <금액>` 으로 시작\n'); + } else { + chunk(view, `- 현금 **${_fmtKrw(runway.latestCash)}원** _(${(runway.latestCashAt || '').slice(0, 10)})_\n`); + if (runway.effectiveBurn !== null) { + chunk(view, `- 월 burn ${_fmtKrw(runway.effectiveBurn)}원 ${runway.explicitBurn !== null ? '_(수동)_' : '_(30일 실적)_'}\n`); + } + if (runway.runwayMonths !== null && Number.isFinite(runway.runwayMonths)) { + const emoji = runway.runwayMonths < 3 ? '🔴' : runway.runwayMonths < 6 ? '🟡' : '🟢'; + chunk(view, `- 런웨이 ${emoji} **${runway.runwayMonths.toFixed(1)}개월**\n`); + } else if (runway.runwayMonths !== null) { + chunk(view, '- 런웨이 ♾️ 흑자 운영\n'); + } + } + + // ─── 4. 📒 고객 ────────────────────────────────────────────── + chunk(view, '\n## 📒 고객\n'); + if (customers.length === 0) { + chunk(view, '- _데이터 없음_ — `/customers add` 로 시작\n'); + } else { + const active = customers.filter((c) => c.status === 'active'); + const totalMrr = [...active, ...atRiskCustomers].reduce((s, c) => s + c.mrr, 0); + chunk(view, `- 총 MRR **${_fmtKrw(totalMrr)}원/월** _(연 ${_fmtKrw(totalMrr * 12)}원)_\n`); + chunk(view, `- 활성 ${active.length} · 위험 ${atRiskCustomers.length} · 이탈 ${customers.length - active.length - atRiskCustomers.length}\n`); + if (upcomingRenewals.length > 0) { + for (const { c, days } of upcomingRenewals.slice(0, 5)) { + const emoji = (days as number) <= 3 ? '🔴' : '🟡'; + chunk(view, ` - ${emoji} ${c.customerName} — D-${days} · ${_fmtKrw(c.mrr)}원/월\n`); + } + } + } + + // ─── 5. 👥 팀 ──────────────────────────────────────────────── + chunk(view, '\n## 👥 팀\n'); + if (tasksError) { + chunk(view, `- _Tasks 조회 실패: ${tasksError}_\n`); + } else if (tasks.length === 0) { + chunk(view, '- _Tasks 없음_ — `/task` 로 등록 시작\n'); + } else { + const memberOverdue = new Map(); + const weekLater = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + let weekCount = 0; + for (const t of tasks) { + const { owner } = parseTaskOwner(t.title, t.notes); + const k = owner || '(미지정)'; + if (t.due && t.due < today) memberOverdue.set(k, (memberOverdue.get(k) || 0) + 1); + if (t.due && t.due >= today && t.due <= weekLater) weekCount++; + } + chunk(view, `- 지연 ${overdueTasks.length}건 · 이번 주 ${weekCount}건 (전체 ${tasks.length})\n`); + if (memberOverdue.size > 0) { + const ranked = [...memberOverdue.entries()].sort((a, b) => b[1] - a[1]).slice(0, 4); + for (const [member, n] of ranked) { + chunk(view, ` - **@${member}** 지연 ${n}건\n`); + } + } + } + + // ─── 6. 🎯 채용 ────────────────────────────────────────────── + chunk(view, '\n## 🎯 채용\n'); + if (candidates.length === 0) { + chunk(view, '- _데이터 없음_\n'); + } else { + const hired = candidates.filter((c) => c.stage === 'hired').length; + chunk(view, `- 진행 중 ${activeCandidate.length}명 · 합격 ${hired}\n`); + // 단계별 카운트 (진행 중만) + const stageCount = new Map(); + for (const c of activeCandidate) stageCount.set(c.stage, (stageCount.get(c.stage) || 0) + 1); + const stages = [...stageCount.entries()].sort((a, b) => (STAGE_ORDER[a[0]] ?? 50) - (STAGE_ORDER[b[0]] ?? 50)); + if (stages.length > 0) { + const parts = stages.map(([s, n]) => `${_stageEmoji(s)} ${s} ${n}`); + chunk(view, ` - ${parts.join(' · ')}\n`); + } + if (stalled.length > 0) { + chunk(view, `- ⏰ 정체 ${stalled.length}명:\n`); + for (const c of stalled.slice(0, 3)) { + const days = Math.floor((Date.now() - Date.parse(c.lastEventAt)) / (24 * 60 * 60 * 1000)); + chunk(view, ` - ${c.candidateName} _(${c.role})_ · ${c.stage} · ${days}일 정체\n`); + } + } + } + + // ─── 7. 📋 오늘의 액션 ────────────────────────────────────── + chunk(view, '\n## 📋 오늘의 액션\n'); + const actions = _morningActions(runway, customers, upcomingRenewals, overdueTasks, stalled, candidates); + if (actions.length === 0) { + chunk(view, '- ✨ 특별한 조치 필요 없음. 깊은 작업 시간 확보 권장.\n'); + } else { + for (const a of actions.slice(0, 5)) chunk(view, `- ${a}\n`); + } + + return true; +} + +/** + * 데이터에서 자동으로 액션 3~5개 도출 — 우선순위 순. + * 단순 휴리스틱: runway 위험 > 위험 고객 > 갱신 임박 > 지연 작업 > 정체 후보. + */ +function _morningActions( + runway: ReturnType, + customers: CustomerState[], + upcomingRenewals: Array<{ c: CustomerState; days: number | null }>, + overdueTasks: any[], + stalled: CandidateState[], + candidates: CandidateState[], +): string[] { + const actions: string[] = []; + + if (runway.runwayMonths !== null && Number.isFinite(runway.runwayMonths) && runway.runwayMonths < 3) { + actions.push(`💸 **자금 조달 계획** — 런웨이 ${runway.runwayMonths.toFixed(1)}개월. 투자자 미팅 / 비용 절감 즉시.`); + } + + const atRisk = customers.filter((c) => c.status === 'at-risk').sort((a, b) => b.mrr - a.mrr); + if (atRisk.length > 0) { + const top = atRisk[0]; + actions.push(`📞 **${top.customerName}** 위험 대응 — MRR ${_fmtKrw(top.mrr)}원. 사유 점검 후 액션.`); + } + + if (upcomingRenewals.length > 0) { + const next = upcomingRenewals[0]; + actions.push(`📨 **${next.c.customerName}** 갱신 D-${next.days} — 갱신 의사 확인 / 가격 협의.`); + } + + if (overdueTasks.length >= 5) { + actions.push(`🚧 지연 작업 ${overdueTasks.length}건 — \`/blocked\` 로 멤버별 확인 후 우선순위 재조정.`); + } else if (overdueTasks.length > 0) { + actions.push(`🚧 지연 작업 ${overdueTasks.length}건 — \`/blocked\` 로 확인.`); + } + + if (stalled.length > 0) { + const next = stalled[0]; + const days = Math.floor((Date.now() - Date.parse(next.lastEventAt)) / (24 * 60 * 60 * 1000)); + actions.push(`👥 **${next.candidateName}** 채용 후속 — ${next.stage} 단계 ${days}일 정체.`); + } + + // 채용 단계가 inbox 에 5명 이상 쌓이면 스크리닝 필요. + const inboxCount = candidates.filter((c) => c.stage === 'inbox').length; + if (inboxCount >= 5) { + actions.push(`📥 채용 inbox ${inboxCount}명 누적 — 스크리닝 시간 확보.`); + } + + return actions; +} + +// ─── /evening — 하루 마무리 카드 ───────────────────────────────────────── +// `/morning` 의 짝. 오늘 진척(완료 작업·고객/채용 이벤트·재무 기록) + 내일 준비 +// (다가오는 갱신·지연 작업·정체 후보) + 짧은 회고 프롬프트 한 줄. + +async function runEvening(arg: string, view: any, context?: vscode.ExtensionContext): Promise { + const today = new Date().toISOString().slice(0, 10); + const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + const dayStartMs = Date.parse(today + 'T00:00:00'); + + chunk(view, `\n🌙 **오늘 (${today}) — 마무리 카드**\n`); + + // ─── 1. 오늘의 진척 ────────────────────────────────────────── + // 1a. 완료된 Tasks (오늘 completed) + let completedTasks: any[] = []; + let tasksError: string | undefined; + if (context) { + try { + const res = await listTasks(context, { showCompleted: true, maxResults: 300 }); + if (res.ok) { + completedTasks = res.tasks.filter((t: any) => t.status === 'completed' && t.completed && Date.parse(t.completed) >= dayStartMs); + } else { + tasksError = res.error; + } + } catch (e: any) { tasksError = e?.message || String(e); } + } + + // 1b. 오늘의 customer / hire / runway 이벤트 + const customerEvents = readCustomerEvents().filter((e) => Date.parse(e.timestamp) >= dayStartMs); + const hireEvents = readHireEvents().filter((e) => Date.parse(e.timestamp) >= dayStartMs); + const runwayToday = readRunway().filter((e) => Date.parse(e.timestamp) >= dayStartMs); + + chunk(view, '\n## ✅ 오늘의 진척\n'); + const progressEmpty = completedTasks.length === 0 && customerEvents.length === 0 && hireEvents.length === 0 && runwayToday.length === 0; + if (progressEmpty) { + chunk(view, '- _기록된 진척 없음._ (작업 완료 / 고객 이벤트 / 채용 이동 등이 오늘 입력되지 않음)\n'); + if (tasksError) chunk(view, ` _Tasks 조회 실패: ${tasksError}_\n`); + } else { + if (completedTasks.length > 0) { + chunk(view, `\n### 작업 완료 (${completedTasks.length}건)\n`); + // 멤버별 그룹 + const byOwner = new Map(); + for (const t of completedTasks) { + const { owner, displayTitle } = parseTaskOwner(t.title, t.notes); + const k = owner || '(미지정)'; + if (!byOwner.has(k)) byOwner.set(k, []); + byOwner.get(k)!.push({ title: displayTitle }); + } + const ranked = [...byOwner.entries()].sort((a, b) => b[1].length - a[1].length); + for (const [owner, list] of ranked) { + chunk(view, `- **@${owner}** (${list.length}건)\n`); + for (const t of list.slice(0, 5)) chunk(view, ` - ${t.title}\n`); + if (list.length > 5) chunk(view, ` - _…+${list.length - 5}건_\n`); + } + } + + if (customerEvents.length > 0) { + chunk(view, `\n### 📒 고객 이벤트 (${customerEvents.length}건)\n`); + for (const e of customerEvents.slice(0, 10)) { + const tagEmoji = e.type === 'add' ? '➕' : e.type === 'renew' ? '🔄' : e.type === 'risk' ? '⚠️' : e.type === 'churn' ? '💀' : '📝'; + const detail = e.type === 'add' || e.type === 'renew' || e.type === 'update' + ? (e.mrr !== undefined ? ` MRR ${_fmtKrw(e.mrr)}원` : '') + : (e.memo ? ` — ${e.memo.slice(0, 60)}` : ''); + chunk(view, `- ${tagEmoji} ${e.customerName} ${e.type}${detail}\n`); + } + } + + if (hireEvents.length > 0) { + chunk(view, `\n### 🎯 채용 이벤트 (${hireEvents.length}건)\n`); + for (const e of hireEvents.slice(0, 10)) { + const stageNote = e.stage ? ` → ${e.stage}` : ''; + const memo = e.memo ? ` — ${e.memo.slice(0, 60)}` : ''; + chunk(view, `- ${e.candidateName} ${e.type}${stageNote}${memo}\n`); + } + } + + if (runwayToday.length > 0) { + chunk(view, `\n### 💰 재무 기록 (${runwayToday.length}건)\n`); + for (const e of runwayToday) { + const typeLabel = e.type === 'snapshot' ? '잔고' : e.type === 'expense' ? '지출' : e.type === 'revenue' ? '수입' : '월 burn'; + chunk(view, `- ${typeLabel}: ${_fmtKrw(e.amount)}원${e.memo ? ` — ${e.memo}` : ''}\n`); + } + } + } + + // ─── 2. 내일 준비 ──────────────────────────────────────────── + chunk(view, '\n## 🌅 내일 준비\n'); + + // 2a. 내일 마감 작업 + let tomorrowTasks: any[] = []; + if (context && !tasksError) { + try { + const res = await listTasks(context, { showCompleted: false, maxResults: 300 }); + if (res.ok) tomorrowTasks = res.tasks.filter((t: any) => t.due === tomorrow); + } catch { /* ignore */ } + } + if (tomorrowTasks.length > 0) { + chunk(view, `\n### 내일 마감 (${tomorrowTasks.length}건)\n`); + for (const t of tomorrowTasks.slice(0, 8)) { + const { owner, displayTitle } = parseTaskOwner(t.title, t.notes); + chunk(view, `- **@${owner || '(미지정)'}** — ${displayTitle}\n`); + } + } + + // 2b. 7일 내 갱신 (다시 표시 — /morning 과 중복 가능하나 마무리에서 한 번 더 강조) + const customers = Array.from(computeCustomerStates().values()); + const upcomingRenewals = customers.filter((c) => c.status !== 'churned') + .map((c) => ({ c, days: _daysUntil(c.renewalAt) })) + .filter((x) => x.days !== null && x.days >= 0 && x.days <= 7); + if (upcomingRenewals.length > 0) { + chunk(view, `\n### 🔔 7일 내 갱신 (${upcomingRenewals.length}건)\n`); + for (const { c, days } of upcomingRenewals.slice(0, 5)) { + const emoji = (days as number) <= 3 ? '🔴' : '🟡'; + chunk(view, `- ${emoji} ${c.customerName} D-${days} · ${_fmtKrw(c.mrr)}원/월\n`); + } + } + + // 2c. 정체 후보 + const stalled = Array.from(computeCandidateStates().values()) + .filter((c) => !TERMINAL_STAGES.has(c.stage)) + .filter((c) => (Date.now() - Date.parse(c.lastEventAt)) > 7 * 24 * 60 * 60 * 1000); + if (stalled.length > 0) { + chunk(view, `\n### ⏰ 정체 후보 (${stalled.length}명)\n`); + for (const c of stalled.slice(0, 3)) { + const days = Math.floor((Date.now() - Date.parse(c.lastEventAt)) / (24 * 60 * 60 * 1000)); + chunk(view, `- ${c.candidateName} _(${c.role})_ · ${c.stage} · ${days}일 정체\n`); + } + } + + if (tomorrowTasks.length === 0 && upcomingRenewals.length === 0 && stalled.length === 0) { + chunk(view, '- _내일 마감·갱신 임박·정체 후보 모두 없음._ ✨\n'); + } + + // ─── 3. 회고 한 줄 ────────────────────────────────────────── + const reflections = [ + '오늘 가장 중요한 한 가지는 무엇이었나? 의도한 대로 됐나?', + '내일 무엇을 안 하기로 했나? 안 할 일을 정해야 할 일이 또렷해진다.', + '오늘 한 결정 중 일주일 뒤에도 옳을 결정은 어느 것인가?', + '시간이 가장 많이 든 활동은 가장 영향력 있는 활동과 일치했나?', + '오늘 멤버들에게 충분한 명확함을 줬나? 무엇을 미루지 않고 답해야 하나?', + '에너지가 가장 좋았던 30분은 무엇을 하던 때였나?', + '오늘 안 한 일 중 내일도 안 해도 되는 일은 무엇인가?', + '리스크 한 가지를 꼽는다면? 그것에 대해 누구와 이야기해야 하나?', + ]; + // 날짜 기반 결정적 선택 — 같은 날 같은 질문 (재실행해도 동일). + const idx = (Date.parse(today) / (24 * 60 * 60 * 1000)) % reflections.length; + chunk(view, `\n## 🧭 회고\n> ${reflections[idx]}\n`); + + chunk(view, '\n_명령 한 줄로 기록 남기기:_ `/decisions` · `/feedback` · `/customers note` · `/hire note`\n'); + return true; +} + +// ─── /memory — 메모리 라이프사이클 관리 ───────────────────────────────── +// Temporal Markers (expiresAt) + Distillation Loop (stale episodes → LongTerm digest) +// 의 사용자 진입점. 자동 트리거는 세션 종료 시, 수동은 여기서. + +function _formatDate(epoch: number | undefined): string { + if (!epoch) return '-'; + return new Date(epoch).toISOString().slice(0, 10); +} + +function _memoryOverview(view: any): void { + const cfg = getConfig(); + const brainPath = cfg.localBrainPath; + if (!brainPath) { chunk(view, '\n❌ Brain path 미설정.\n'); return; } + + // 직접 두 store 만 띄움 — 전체 MemoryManager 인스턴스화는 무거움. + const mgr = new MemoryManager(brainPath, { + longTermMaxEntries: cfg.memoryLongTermFiles ?? 100, + episodicMaxEpisodes: 50, + }); + const lt = mgr.getLongTermMemory(); + const ep = mgr.getEpisodicMemory(); + const allLt = lt.getAllEntries({ includeExpired: true }); + const activeLt = lt.getAllEntries(); + const expiredLt = allLt.length - activeLt.length; + const allEpisodes = ep.loadAllEpisodes(); + const stalePromoted = allEpisodes.filter((e) => e.promoted).length; + const staleCandidates = ep.findStaleEpisodes(cfg.distillationAgeThresholdDays).length; + const last = getLastDistillationRun(brainPath); + + chunk(view, '\n🧠 **/memory — 메모리 라이프사이클**\n'); + chunk(view, `\n## 📊 현재 상태\n`); + chunk(view, `- **LongTerm**: 활성 ${activeLt.length}개${expiredLt > 0 ? ` (만료 ${expiredLt}개 숨김)` : ''}\n`); + + // 카테고리 분포 + const catCounts = new Map(); + for (const e of activeLt) catCounts.set(e.category, (catCounts.get(e.category) || 0) + 1); + if (catCounts.size > 0) { + const parts = [...catCounts.entries()].map(([c, n]) => `${c} ${n}`).join(' · '); + chunk(view, ` - 카테고리: ${parts}\n`); + } + + chunk(view, `- **Episodic**: 전체 ${allEpisodes.length}개 (승급 ${stalePromoted} · 미승급 stale 후보 ${staleCandidates})\n`); + + chunk(view, `\n## 🔄 Distillation\n`); + chunk(view, `- 임계: **${cfg.distillationAgeThresholdDays}일** 이상 stale episode → LongTerm 'episode-digest' 승급\n`); + chunk(view, `- 자동 트리거 간격: ${cfg.distillationIntervalDays}일\n`); + chunk(view, `- Archive 모드: \`${cfg.distillationArchiveMode}\`\n`); + if (last) { + const ago = Math.floor((Date.now() - last.timestamp) / (24 * 60 * 60 * 1000)); + chunk(view, `- 마지막 실행: ${_formatDate(last.timestamp)} (${ago}일 전) — 승급 ${last.report?.promotedCount ?? 0}개\n`); + } else { + chunk(view, `- 마지막 실행: _없음_ — \`/memory distill\` 로 첫 실행\n`); + } + + // 만료 임박 LongTerm + const upcoming = activeLt + .filter((e) => e.expiresAt && (e.expiresAt - Date.now()) <= 30 * 24 * 60 * 60 * 1000) + .sort((a, b) => (a.expiresAt || 0) - (b.expiresAt || 0)); + if (upcoming.length > 0) { + chunk(view, `\n## ⏰ 30일 내 만료 LongTerm (${upcoming.length}개)\n`); + for (const e of upcoming.slice(0, 5)) { + const days = Math.ceil(((e.expiresAt || 0) - Date.now()) / (24 * 60 * 60 * 1000)); + chunk(view, `- D-${days} [${e.category}] \`${e.id.slice(0, 8)}\` ${e.content.slice(0, 80)}\n`); + } + if (upcoming.length > 5) chunk(view, `- _…+${upcoming.length - 5}개_\n`); + } + + chunk(view, `\n_명령어:_ \`/memory distill\` · \`/memory expire \` · \`/memory list-expiring [days]\` · \`/memory help\`\n`); +} + +async function runMemory(arg: string, view: any, _context?: vscode.ExtensionContext): Promise { + const trimmed = arg.trim(); + if (!trimmed) { _memoryOverview(view); return true; } + + const parts = trimmed.split(/\s+/); + const sub = parts[0].toLowerCase(); + const cfg = getConfig(); + const brainPath = cfg.localBrainPath; + if (!brainPath) { chunk(view, '\n❌ Brain path 미설정.\n'); return true; } + + if (sub === 'help' || sub === '?') { + chunk(view, [ + '\n🧠 **/memory — 메모리 라이프사이클**', + '', + '사용법:', + ' `/memory` — 현재 상태 (LongTerm/Episodic 카운트, distillation, 만료 임박)', + ' `/memory distill [age_days]` — Stale episodes → LongTerm digest 승급 (기본 30일 임계)', + ' `/memory expire ` — LongTerm entry 에 만료일 설정', + ' `/memory list-expiring [days]` — N일 내 만료 LongTerm 목록 (기본 30)', + ' `/memory list-promoted` — 승급된 episodes (digest 형태로 LongTerm 에 살아 있음)', + '', + 'Temporal Markers: LongTerm entry 의 expiresAt < now 이면 검색에서 자동 제외.', + 'Distillation Loop: 자동 트리거는 세션 종료 시 (interval 기준), 수동은 `/memory distill`.', + '저장: `{brainPath}/memory/long_term.json` + `{brainPath}/memory/episodes/*.json`.\n', + ].join('\n')); + return true; + } + + if (sub === 'distill') { + const ageOverride = parts[1] ? parseInt(parts[1], 10) : undefined; + const age = Number.isFinite(ageOverride) && (ageOverride as number) > 0 + ? (ageOverride as number) + : cfg.distillationAgeThresholdDays; + chunk(view, `\n🔄 Distillation 시작 — ${age}일 이상 stale episodes 대상...\n`); + const mgr = new MemoryManager(brainPath); + const report = distillStaleEpisodes(mgr.getEpisodicMemory(), mgr.getLongTermMemory(), brainPath, { + ageThresholdDays: age, + archiveMode: cfg.distillationArchiveMode as DistillationArchiveMode, + }); + recordDistillationRun(brainPath, report); + chunk(view, `\n✅ **Distillation 완료** (${report.durationMs}ms)\n`); + chunk(view, `- 후보 ${report.candidateCount}개 → 승급 ${report.promotedCount}개`); + chunk(view, cfg.distillationArchiveMode === 'archive-file' ? ` · 아카이브 ${report.archivedCount}개\n` : '\n'); + if (report.skipped.length > 0) { + chunk(view, `- ⚠️ 스킵 ${report.skipped.length}개:\n`); + for (const s of report.skipped.slice(0, 3)) chunk(view, ` - \`${s.episodeId.slice(0, 8)}\`: ${s.reason}\n`); + } + if (report.longTermDigestIds.length > 0) { + chunk(view, `\n_생성된 digest IDs (앞 8자):_ ${report.longTermDigestIds.slice(0, 5).map((id) => `\`${id.slice(0, 8)}\``).join(' · ')}\n`); + } + return true; + } + + if (sub === 'expire') { + const idPrefix = parts[1]; + const dateStr = parts[2]; + if (!idPrefix || !dateStr) { + chunk(view, '\n❌ 사용법: `/memory expire `\n예: `/memory expire a3f7d2c9 2026-09-30`\n'); + return true; + } + if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) { + chunk(view, `\n❌ 날짜 형식: YYYY-MM-DD (입력: "${dateStr}")\n`); + return true; + } + const expiresAt = Date.parse(dateStr + 'T23:59:59'); + if (!Number.isFinite(expiresAt)) { chunk(view, `\n❌ 날짜 파싱 실패: "${dateStr}"\n`); return true; } + const mgr = new MemoryManager(brainPath); + const updated = mgr.getLongTermMemory().setExpiration(idPrefix, expiresAt); + if (!updated) { chunk(view, `\n❌ "${idPrefix}" prefix 매치 entry 없음.\n`); return true; } + chunk(view, `\n⏰ **${updated.category}** \`${updated.id.slice(0, 8)}\` 만료일 설정 → ${dateStr}\n`); + chunk(view, `> ${updated.content.slice(0, 120)}\n`); + return true; + } + + if (sub === 'list-expiring') { + const days = parts[1] ? parseInt(parts[1], 10) : 30; + const window = Number.isFinite(days) && days > 0 ? days : 30; + const mgr = new MemoryManager(brainPath); + const all = mgr.getLongTermMemory().getAllEntries(); + const horizon = Date.now() + window * 24 * 60 * 60 * 1000; + const upcoming = all + .filter((e) => e.expiresAt && e.expiresAt <= horizon) + .sort((a, b) => (a.expiresAt || 0) - (b.expiresAt || 0)); + if (upcoming.length === 0) { chunk(view, `\nℹ️ ${window}일 내 만료 예정 entry 없음.\n`); return true; } + chunk(view, `\n⏰ **${window}일 내 만료 LongTerm (${upcoming.length}개)**\n\n`); + for (const e of upcoming) { + const d = Math.ceil(((e.expiresAt || 0) - Date.now()) / (24 * 60 * 60 * 1000)); + chunk(view, `- D-${d} [${e.category}] \`${e.id.slice(0, 8)}\` ${e.content.slice(0, 100)}\n`); + } + return true; + } + + if (sub === 'list-promoted') { + const mgr = new MemoryManager(brainPath); + const promoted = mgr.getEpisodicMemory().loadAllEpisodes().filter((e) => e.promoted); + if (promoted.length === 0) { chunk(view, '\nℹ️ 승급된 episode 없음. `/memory distill` 로 첫 승급.\n'); return true; } + chunk(view, `\n📚 **승급된 episodes (${promoted.length}개)** — LongTerm 에 digest 형태로 살아 있음\n\n`); + for (const e of promoted.slice(0, 15)) { + const date = (new Date(e.timestamp)).toISOString().slice(0, 10); + chunk(view, `- \`${date}\` ${e.title} → digest \`${(e.promotedToLongTermId || '').slice(0, 8)}\`\n`); + } + if (promoted.length > 15) chunk(view, `- _…+${promoted.length - 15}개_\n`); + return true; + } + + chunk(view, `\n❌ 알 수 없는 서브명령: "${sub}". \`/memory help\` 참조.\n`); + return true; +} + +// ─── /cohort — MoM 추세 분석 (customers + runway events 결합) ───────────── +// `/customers` event log 와 `/runway` event log 의 timestamp 를 월별 그룹핑해 +// "이번 달 vs 지난 달" 추세를 한 화면에. 회계 SaaS 의 board metric 의 미니 버전. + +interface MonthlyBucket { + yearMonth: string; // 'YYYY-MM' + newCustomers: number; + churnedCustomers: number; + renewals: number; + mrrDelta: number; // 이 달의 customers 이벤트로 인한 MRR 변화 합 + expenseTotal: number; // 이 달의 expense 합계 + revenueTotal: number; // 이 달의 revenue 합계 + cashSnapshots: number[]; // 이 달의 cash snapshot 들 (마지막 = 월말 잔고 근사) +} + +function _yearMonth(iso: string): string { + return (iso || '').slice(0, 7); +} + +function _buildMonthlyBuckets(monthsBack: number): Map { + const map = new Map(); + const now = new Date(); + // 빈 버킷 미리 생성 — 이벤트 없는 달도 행으로 표시. + for (let i = monthsBack - 1; i >= 0; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + const ym = d.toISOString().slice(0, 7); + map.set(ym, { + yearMonth: ym, + newCustomers: 0, churnedCustomers: 0, renewals: 0, + mrrDelta: 0, expenseTotal: 0, revenueTotal: 0, + cashSnapshots: [], + }); + } + + // customers events + for (const e of readCustomerEvents()) { + const ym = _yearMonth(e.timestamp); + const b = map.get(ym); + if (!b) continue; + if (e.type === 'add') { + b.newCustomers++; + if (e.mrr) b.mrrDelta += e.mrr; + } else if (e.type === 'churn') { + b.churnedCustomers++; + // churn 의 mrr 감소는 직전 mrr 만큼 빼지만 event 에 그 값이 없음. 보수적으로 0. + } else if (e.type === 'renew') { + b.renewals++; + // renew 도 mrrDelta 변화 가능 — 새 mrr 가 있을 때만 반영. + } else if (e.type === 'update' && e.mrr !== undefined) { + // update 는 새 mrr 와 직전 mrr 차이를 모르므로 0. 향후 state 추적 시 보강. + } + } + + // runway events + for (const e of readRunway()) { + const ym = _yearMonth(e.timestamp); + const b = map.get(ym); + if (!b) continue; + if (e.type === 'expense') b.expenseTotal += e.amount; + else if (e.type === 'revenue') b.revenueTotal += e.amount; + else if (e.type === 'snapshot') b.cashSnapshots.push(e.amount); + } + + return map; +} + +function _cohortDashboard(view: any, monthsBack: number): void { + const buckets = _buildMonthlyBuckets(monthsBack); + if (buckets.size === 0) { + chunk(view, '\nℹ️ 데이터 없음. `/customers add` / `/runway cash` 로 시작.\n'); + return; + } + + const rows = Array.from(buckets.values()); + chunk(view, `\n📈 **/cohort — 최근 ${monthsBack}개월 추세**\n`); + + // ─── 표: MRR / 고객 추이 ─── + chunk(view, '\n## 고객 & MRR 추이\n'); + chunk(view, '| 월 | 신규 | 갱신 | 이탈 | MRR Δ |\n'); + chunk(view, '|---|---:|---:|---:|---:|\n'); + for (const r of rows) { + chunk(view, `| ${r.yearMonth} | ${r.newCustomers} | ${r.renewals} | ${r.churnedCustomers} | ${r.mrrDelta > 0 ? '+' : ''}${_fmtKrw(r.mrrDelta)} |\n`); + } + + // 합계 + 증감 + const totNew = rows.reduce((s, r) => s + r.newCustomers, 0); + const totChurn = rows.reduce((s, r) => s + r.churnedCustomers, 0); + const totMrr = rows.reduce((s, r) => s + r.mrrDelta, 0); + chunk(view, `\n- **누적 ${monthsBack}개월**: 신규 +${totNew} · 이탈 -${totChurn} · 순 ${totNew - totChurn >= 0 ? '+' : ''}${totNew - totChurn}\n`); + chunk(view, `- **MRR 순증**: ${totMrr >= 0 ? '+' : ''}${_fmtKrw(totMrr)}원/월 _(추가된 신규 MRR 만, 이탈로 인한 감소는 history 부재로 미반영)_\n`); + + // Churn rate (월평균) + const avgNew = totNew / monthsBack; + const avgChurn = totChurn / monthsBack; + if (avgNew > 0 || avgChurn > 0) { + chunk(view, `- 월평균 신규 ${avgNew.toFixed(1)}곳, 월평균 이탈 ${avgChurn.toFixed(1)}곳`); + if (totNew > 0) chunk(view, ` (이탈/신규 비율 ${((totChurn / totNew) * 100).toFixed(0)}%)\n`); + else chunk(view, '\n'); + } + + // ─── 재무 추이 ─── + chunk(view, '\n## 재무 추이\n'); + chunk(view, '| 월 | 지출 | 수입 | 순 burn | 월말 잔고 |\n'); + chunk(view, '|---|---:|---:|---:|---:|\n'); + for (const r of rows) { + const netBurn = r.expenseTotal - r.revenueTotal; + const lastCash = r.cashSnapshots.length > 0 ? r.cashSnapshots[r.cashSnapshots.length - 1] : null; + const cashCell = lastCash !== null ? _fmtKrw(lastCash) : '-'; + chunk(view, `| ${r.yearMonth} | ${_fmtKrw(r.expenseTotal)} | ${_fmtKrw(r.revenueTotal)} | ${netBurn > 0 ? '+' : ''}${_fmtKrw(netBurn)} | ${cashCell} |\n`); + } + + const totExp = rows.reduce((s, r) => s + r.expenseTotal, 0); + const totRev = rows.reduce((s, r) => s + r.revenueTotal, 0); + const totBurn = totExp - totRev; + const avgBurn = totBurn / monthsBack; + chunk(view, `\n- **${monthsBack}개월 누계**: 지출 ${_fmtKrw(totExp)} · 수입 ${_fmtKrw(totRev)} · 순 burn ${totBurn > 0 ? '+' : ''}${_fmtKrw(totBurn)}\n`); + chunk(view, `- **월평균 burn**: ${_fmtKrw(avgBurn)}원/월\n`); + + // ─── 인사이트 한 줄 ─── + chunk(view, '\n## 💡 인사이트\n'); + const insights: string[] = []; + // 신규/이탈 추세 — 최근 3개월 vs 그 이전 + if (monthsBack >= 6) { + const recent3 = rows.slice(-3); + const prior3 = rows.slice(-6, -3); + const recentNew = recent3.reduce((s, r) => s + r.newCustomers, 0); + const priorNew = prior3.reduce((s, r) => s + r.newCustomers, 0); + if (recentNew > priorNew * 1.2) insights.push('🟢 최근 3개월 신규 획득 가속 (이전 3개월 대비 +20%↑)'); + else if (recentNew < priorNew * 0.8 && priorNew >= 2) insights.push('🟡 최근 3개월 신규 획득 둔화 (이전 3개월 대비 -20%↓)'); + + const recentBurn = recent3.reduce((s, r) => s + (r.expenseTotal - r.revenueTotal), 0); + const priorBurn = prior3.reduce((s, r) => s + (r.expenseTotal - r.revenueTotal), 0); + if (priorBurn > 0 && recentBurn > priorBurn * 1.3) insights.push('🔴 최근 3개월 burn 가속 (이전 3개월 대비 +30%↑) — 비용 점검 권장'); + } + if (avgBurn > 0 && totRev > 0) { + const coverage = totRev / totExp; + if (coverage > 0.8) insights.push(`🟢 매출 커버리지 ${(coverage * 100).toFixed(0)}% — 흑자 진입 임박`); + else if (coverage < 0.2 && totExp > 0) insights.push(`🟡 매출 커버리지 ${(coverage * 100).toFixed(0)}% — 매출 기반 약함`); + } + if (insights.length === 0) { + chunk(view, '- _데이터 부족 또는 추세 신호 약함._ 더 누적되면 인사이트 표시.\n'); + } else { + for (const i of insights) chunk(view, `- ${i}\n`); + } + + chunk(view, '\n_데이터 출처: `.astra/customers.jsonl` + `.astra/runway.jsonl`. 더 많은 이벤트 누적 시 추세 정확도↑._\n'); +} + +async function runCohort(arg: string, view: any, _context?: vscode.ExtensionContext): Promise { + const trimmed = arg.trim().toLowerCase(); + if (trimmed === 'help' || trimmed === '?') { + chunk(view, [ + '\n📈 **/cohort — MoM 추세 분석**', + '', + '사용법:', + ' `/cohort` — 최근 6개월 추세 (기본)', + ' `/cohort yearly` — 최근 12개월', + ' `/cohort ` — 최근 N개월 (1~24)', + '', + '데이터 출처: `/customers` events + `/runway` events 의 timestamp 월별 그룹핑.', + '표시 항목: 신규/갱신/이탈 + MRR 변화 + 지출/수입/burn + 월말 잔고 + 인사이트 한 줄.\n', + ].join('\n')); + return true; + } + + let monthsBack = 6; + if (trimmed === 'yearly' || trimmed === 'year') monthsBack = 12; + else if (/^\d+$/.test(trimmed)) { + monthsBack = Math.max(1, Math.min(24, parseInt(trimmed, 10))); + } + + _cohortDashboard(view, monthsBack); + return true; +} + +// ─── /weekly — 주간 리뷰 카드 (CEO 전략 시야) ──────────────────────────── +// `/morning` 일 단위, `/evening` 일 끝 — 그 *주 단위 짝*. 7일 진척 + 지난 주 대비 +// + 다음 주 준비 + 회고. `/standup weekly` (팀 공유) 와 달리 *대표가 본다* 용도 — +// cross-data delta 포커스. + +function _isoWeek(d: Date): { year: number; week: number; label: string } { + // ISO week (월요일 시작). 단순 구현 — Thursday-of-week 트릭. + const target = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); + const dayNum = (target.getUTCDay() + 6) % 7; + target.setUTCDate(target.getUTCDate() - dayNum + 3); + const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4)); + const firstDayNum = (firstThursday.getUTCDay() + 6) % 7; + firstThursday.setUTCDate(firstThursday.getUTCDate() - firstDayNum + 3); + const week = 1 + Math.round((target.getTime() - firstThursday.getTime()) / (7 * 24 * 60 * 60 * 1000)); + return { year: target.getUTCFullYear(), week, label: `${target.getUTCFullYear()}-W${String(week).padStart(2, '0')}` }; +} + +interface WeeklyWindow { + startIso: string; // YYYY-MM-DD (inclusive) + endIso: string; // YYYY-MM-DD (inclusive) + startMs: number; + endMs: number; + label: string; // 'YYYY-Wnn (5/22-5/28)' +} + +function _thisWeekWindow(now: Date = new Date()): WeeklyWindow { + // 월요일 00:00 ~ 일요일 23:59:59 + const dayOfWeek = now.getDay(); // 0=Sun, 1=Mon, ..., 6=Sat + const daysFromMonday = (dayOfWeek + 6) % 7; + const monday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysFromMonday); + const sunday = new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() + 6, 23, 59, 59); + const { label: yw } = _isoWeek(monday); + const startIso = monday.toISOString().slice(0, 10); + const endIso = sunday.toISOString().slice(0, 10); + const shortStart = `${monday.getMonth() + 1}/${monday.getDate()}`; + const shortEnd = `${sunday.getMonth() + 1}/${sunday.getDate()}`; + return { + startIso, endIso, + startMs: monday.getTime(), endMs: sunday.getTime(), + label: `${yw} (${shortStart}-${shortEnd})`, + }; +} + +function _priorWeekWindow(thisWeek: WeeklyWindow): WeeklyWindow { + const priorMonday = new Date(thisWeek.startMs - 7 * 24 * 60 * 60 * 1000); + const priorSunday = new Date(thisWeek.startMs - 1000); + const { label: yw } = _isoWeek(priorMonday); + const startIso = priorMonday.toISOString().slice(0, 10); + const endIso = priorSunday.toISOString().slice(0, 10); + const shortStart = `${priorMonday.getMonth() + 1}/${priorMonday.getDate()}`; + const shortEnd = `${priorSunday.getMonth() + 1}/${priorSunday.getDate()}`; + return { + startIso, endIso, + startMs: priorMonday.getTime(), endMs: priorSunday.getTime(), + label: `${yw} (${shortStart}-${shortEnd})`, + }; +} + +interface WeeklyAggregate { + taskCompleted: number; + taskByOwner: Map; + customerEvents: number; + customerNewCount: number; + customerRenewCount: number; + customerRiskCount: number; + customerChurnCount: number; + customerNewMrr: number; + hireEvents: number; + hireMoved: number; + hireAdded: number; + hireHired: number; + runwayExpense: number; + runwayRevenue: number; + runwayLastCash: number | null; + runwayFirstCash: number | null; + adrCount: number; +} + +function _aggregateWeek( + win: WeeklyWindow, + completedTasks: any[], + cevs: CustomerEvent[], + hevs: HireEvent[], + rs: RunwayEntry[], + adrs: { date: string; title: string }[], +): WeeklyAggregate { + const agg: WeeklyAggregate = { + taskCompleted: 0, taskByOwner: new Map(), + customerEvents: 0, customerNewCount: 0, customerRenewCount: 0, + customerRiskCount: 0, customerChurnCount: 0, customerNewMrr: 0, + hireEvents: 0, hireMoved: 0, hireAdded: 0, hireHired: 0, + runwayExpense: 0, runwayRevenue: 0, runwayLastCash: null, runwayFirstCash: null, + adrCount: 0, + }; + + // Tasks completed in window + for (const t of completedTasks) { + if (!t.completed) continue; + const ms = Date.parse(t.completed); + if (ms < win.startMs || ms > win.endMs) continue; + agg.taskCompleted++; + const { owner } = parseTaskOwner(t.title, t.notes); + const k = owner || '(미지정)'; + agg.taskByOwner.set(k, (agg.taskByOwner.get(k) || 0) + 1); + } + + // Customers + for (const e of cevs) { + const ms = Date.parse(e.timestamp); + if (ms < win.startMs || ms > win.endMs) continue; + agg.customerEvents++; + if (e.type === 'add') { agg.customerNewCount++; if (e.mrr) agg.customerNewMrr += e.mrr; } + else if (e.type === 'renew') agg.customerRenewCount++; + else if (e.type === 'risk') agg.customerRiskCount++; + else if (e.type === 'churn') agg.customerChurnCount++; + } + + // Hire + for (const e of hevs) { + const ms = Date.parse(e.timestamp); + if (ms < win.startMs || ms > win.endMs) continue; + agg.hireEvents++; + if (e.type === 'add') agg.hireAdded++; + else if (e.type === 'stage') agg.hireMoved++; + else if (e.type === 'hire') agg.hireHired++; + } + + // Runway — first/last cash snapshot within window + const cashInWin = rs + .filter((r) => r.type === 'snapshot') + .filter((r) => { const ms = Date.parse(r.timestamp); return ms >= win.startMs && ms <= win.endMs; }) + .sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || '')); + if (cashInWin.length > 0) { + agg.runwayFirstCash = cashInWin[0].amount; + agg.runwayLastCash = cashInWin[cashInWin.length - 1].amount; + } + for (const e of rs) { + const ms = Date.parse(e.timestamp); + if (ms < win.startMs || ms > win.endMs) continue; + if (e.type === 'expense') agg.runwayExpense += e.amount; + else if (e.type === 'revenue') agg.runwayRevenue += e.amount; + } + + // ADRs + for (const a of adrs) { + if (a.date >= win.startIso && a.date <= win.endIso) agg.adrCount++; + } + + return agg; +} + +function _deltaSymbol(now: number, prev: number): string { + if (prev === 0 && now === 0) return '→'; + if (prev === 0) return `↑${now}`; + const diff = now - prev; + if (diff > 0) return `↑${diff}`; + if (diff < 0) return `↓${Math.abs(diff)}`; + return '→'; +} + +async function runWeekly(arg: string, view: any, context?: vscode.ExtensionContext): Promise { + const trimmed = arg.trim().toLowerCase(); + if (trimmed === 'help' || trimmed === '?') { + chunk(view, [ + '\n📅 **/weekly — 주간 리뷰 카드 (대표용)**', + '', + '사용법:', + ' `/weekly` — 이번 주 (월~일) 리뷰 + 지난 주 대비 + 다음 주 준비', + '', + '`/standup weekly` 와 차이:', + '- `/standup weekly` → 팀 공유용 (멤버별 완료/진행/블로커, 슬랙 복붙)', + '- `/weekly` → 대표용 전략 시야 (cross-data delta, 회고 프롬프트)', + '', + '데이터 출처: Google Tasks + /customers + /hire + /runway + Chronicle ADR.\n', + ].join('\n')); + return true; + } + + const thisWeek = _thisWeekWindow(); + const priorWeek = _priorWeekWindow(thisWeek); + chunk(view, `\n📅 **/weekly — 주간 리뷰 ${thisWeek.label}**\n`); + + // ─── 데이터 수집 ────────────────────────────────────────────── + let completedTasks: any[] = []; + let tasksError: string | undefined; + if (context) { + try { + const res = await listTasks(context, { showCompleted: true, maxResults: 500 }); + if (res.ok) completedTasks = res.tasks.filter((t: any) => t.status === 'completed'); + else tasksError = res.error; + } catch (e: any) { tasksError = e?.message || String(e); } + } + + const cevs = readCustomerEvents(); + const hevs = readHireEvents(); + const rs = readRunway(); + + // ADR 수집 — chronicleProjectStore 직접 fs 스캔. + const adrs: { date: string; title: string }[] = []; + try { + const folders = vscode.workspace.workspaceFolders; + if (folders && folders.length > 0) { + const wsRoot = folders[0].uri.fsPath; + const cs = new ChronicleProjectStore(context!); + const projects = cs.getAll(); + // 워크스페이스 매칭 프로젝트만 + for (const p of projects) { + const recRoot = p.recordRoot; + const decisionsDir = path.join(recRoot, 'decisions'); + if (!fs.existsSync(decisionsDir)) continue; + const files = fs.readdirSync(decisionsDir).filter((f) => f.startsWith('ADR-') && f.endsWith('.md')); + for (const f of files) { + try { + const full = path.join(decisionsDir, f); + const stat = fs.statSync(full); + const d = new Date(stat.mtimeMs).toISOString().slice(0, 10); + const title = f.replace(/^ADR-\d+-/, '').replace(/\.md$/, '').replace(/[-_]/g, ' '); + adrs.push({ date: d, title }); + } catch { /* skip */ } + } + } + } + } catch { /* ignore */ } + + const aggNow = _aggregateWeek(thisWeek, completedTasks, cevs, hevs, rs, adrs); + const aggPrev = _aggregateWeek(priorWeek, completedTasks, cevs, hevs, rs, adrs); + + // ─── Section 1: 이번 주 진척 ────────────────────────────────── + chunk(view, '\n## ✅ 이번 주 진척\n'); + + // 작업 + if (tasksError) { + chunk(view, `- _Tasks 조회 실패: ${tasksError}_\n`); + } else if (aggNow.taskCompleted === 0) { + chunk(view, '- _완료된 작업 없음._\n'); + } else { + chunk(view, `\n### 작업 (${aggNow.taskCompleted}건 완료)\n`); + const ranked = [...aggNow.taskByOwner.entries()].sort((a, b) => b[1] - a[1]); + for (const [owner, n] of ranked) { + const prev = aggPrev.taskByOwner.get(owner) || 0; + chunk(view, `- **@${owner}**: ${n}건 ${_deltaSymbol(n, prev)}\n`); + } + } + + // 고객 + if (aggNow.customerEvents > 0) { + chunk(view, `\n### 📒 고객 (${aggNow.customerEvents} 이벤트)\n`); + if (aggNow.customerNewCount > 0) chunk(view, `- 신규 ${aggNow.customerNewCount}곳 _(MRR +${_fmtKrw(aggNow.customerNewMrr)})_\n`); + if (aggNow.customerRenewCount > 0) chunk(view, `- 갱신 ${aggNow.customerRenewCount}곳\n`); + if (aggNow.customerRiskCount > 0) chunk(view, `- ⚠️ 위험 표시 ${aggNow.customerRiskCount}곳\n`); + if (aggNow.customerChurnCount > 0) chunk(view, `- 💀 이탈 ${aggNow.customerChurnCount}곳\n`); + } + + // 채용 + if (aggNow.hireEvents > 0) { + chunk(view, `\n### 🎯 채용 (${aggNow.hireEvents} 이벤트)\n`); + if (aggNow.hireAdded > 0) chunk(view, `- 신규 후보 ${aggNow.hireAdded}명\n`); + if (aggNow.hireMoved > 0) chunk(view, `- 단계 이동 ${aggNow.hireMoved}건\n`); + if (aggNow.hireHired > 0) chunk(view, `- 🎉 합격 ${aggNow.hireHired}명\n`); + } + + // 재무 + if (aggNow.runwayExpense > 0 || aggNow.runwayRevenue > 0 || aggNow.runwayLastCash !== null) { + chunk(view, '\n### 💰 재무\n'); + if (aggNow.runwayExpense > 0 || aggNow.runwayRevenue > 0) { + const netBurn = aggNow.runwayExpense - aggNow.runwayRevenue; + chunk(view, `- 지출 ${_fmtKrw(aggNow.runwayExpense)} · 수입 ${_fmtKrw(aggNow.runwayRevenue)} · 순 burn ${netBurn > 0 ? '+' : ''}${_fmtKrw(netBurn)}\n`); + } + if (aggNow.runwayLastCash !== null && aggNow.runwayFirstCash !== null) { + const delta = aggNow.runwayLastCash - aggNow.runwayFirstCash; + chunk(view, `- 잔고: ${_fmtKrw(aggNow.runwayFirstCash)} → ${_fmtKrw(aggNow.runwayLastCash)} _(Δ ${delta >= 0 ? '+' : ''}${_fmtKrw(delta)})_\n`); + } else if (aggNow.runwayLastCash !== null) { + chunk(view, `- 현재 잔고: ${_fmtKrw(aggNow.runwayLastCash)}\n`); + } + } + + if (aggNow.adrCount > 0) { + chunk(view, `\n### 📋 결정 (ADR ${aggNow.adrCount}건)\n`); + const weekAdrs = adrs.filter((a) => a.date >= thisWeek.startIso && a.date <= thisWeek.endIso).slice(0, 5); + for (const a of weekAdrs) chunk(view, `- ${a.date} — ${a.title}\n`); + } + + // ─── Section 2: 지난 주 대비 ────────────────────────────────── + chunk(view, `\n## 🔄 지난 주 대비 (${priorWeek.label})\n`); + const compRows: string[] = []; + compRows.push(`- 작업: ${aggNow.taskCompleted}건 ${_deltaSymbol(aggNow.taskCompleted, aggPrev.taskCompleted)} _(이번 ${aggNow.taskCompleted} ← 지난 ${aggPrev.taskCompleted})_`); + compRows.push(`- 신규 고객: ${aggNow.customerNewCount} ${_deltaSymbol(aggNow.customerNewCount, aggPrev.customerNewCount)}`); + compRows.push(`- 이탈: ${aggNow.customerChurnCount} ${_deltaSymbol(aggNow.customerChurnCount, aggPrev.customerChurnCount)}`); + const burnNow = aggNow.runwayExpense - aggNow.runwayRevenue; + const burnPrev = aggPrev.runwayExpense - aggPrev.runwayRevenue; + if (burnNow !== 0 || burnPrev !== 0) { + const diff = burnNow - burnPrev; + const arrow = diff > 0 ? '↑' : diff < 0 ? '↓' : '→'; + compRows.push(`- 순 burn: ${_fmtKrw(burnNow)} ${arrow} _(지난 ${_fmtKrw(burnPrev)})_`); + } + compRows.push(`- 채용 이벤트: ${aggNow.hireEvents} ${_deltaSymbol(aggNow.hireEvents, aggPrev.hireEvents)}`); + for (const r of compRows) chunk(view, r + '\n'); + + // ─── Section 3: 다음 주 준비 ────────────────────────────────── + chunk(view, '\n## 🌅 다음 주 준비\n'); + + // 7일 내 갱신 + const customerStates = computeCustomerStates(); + const upcoming = Array.from(customerStates.values()) + .filter((c) => c.status !== 'churned') + .map((c) => ({ c, days: _daysUntil(c.renewalAt) })) + .filter((x) => x.days !== null && x.days >= 0 && x.days <= 14) + .sort((a, b) => (a.days as number) - (b.days as number)); + if (upcoming.length > 0) { + chunk(view, `\n### 🔔 14일 내 갱신 (${upcoming.length}건)\n`); + for (const { c, days } of upcoming.slice(0, 5)) { + const emoji = (days as number) <= 7 ? '🔴' : '🟡'; + chunk(view, `- ${emoji} ${c.customerName} D-${days} · ${_fmtKrw(c.mrr)}원/월\n`); + } + } + + // 다음 주 마감 작업 + let nextWeekDue = 0; + if (!tasksError && context) { + try { + const res = await listTasks(context, { showCompleted: false, maxResults: 300 }); + if (res.ok) { + const startNext = new Date(thisWeek.endMs + 1000); + const endNext = new Date(thisWeek.endMs + 7 * 24 * 60 * 60 * 1000); + const startIso = startNext.toISOString().slice(0, 10); + const endIso = endNext.toISOString().slice(0, 10); + nextWeekDue = res.tasks.filter((t: any) => t.due && t.due >= startIso && t.due <= endIso).length; + } + } catch { /* ignore */ } + } + if (nextWeekDue > 0) chunk(view, `\n- 📅 다음 주 마감 작업: ${nextWeekDue}건\n`); + + // 정체 후보 + const stalled = Array.from(computeCandidateStates().values()) + .filter((c) => !TERMINAL_STAGES.has(c.stage)) + .filter((c) => (Date.now() - Date.parse(c.lastEventAt)) > 7 * 24 * 60 * 60 * 1000); + if (stalled.length > 0) { + chunk(view, `\n### ⏰ 정체 후보 (${stalled.length}명)\n`); + for (const c of stalled.slice(0, 3)) { + const days = Math.floor((Date.now() - Date.parse(c.lastEventAt)) / (24 * 60 * 60 * 1000)); + chunk(view, `- ${c.candidateName} _(${c.role})_ · ${c.stage} · ${days}일 정체\n`); + } + } + + // ─── Section 4: 주간 회고 프롬프트 ─────────────────────────── + const reflections = [ + '이번 주 가장 중요한 결정은 무엇이었나? 다음 주에 어떤 결정을 미루면 안 되나?', + '이번 주 가장 시간을 많이 쓴 활동이 가장 영향력 있는 활동과 일치했나?', + '이번 주 멤버 중 누구와 1:1 시간이 충분치 않았나? 다음 주 일정 잡기.', + '이번 주 *안 한 일* 중 다음 주에도 안 해도 되는 일은 무엇인가?', + '이번 주 발견한 리스크 한 가지를 꼽는다면? 누구와 이야기해야 하나?', + '이번 주 가장 좋았던 30분은 무엇을 하던 때였나? 다음 주에 어떻게 복제할까?', + ]; + const weekKey = Math.floor(thisWeek.startMs / (1000 * 60 * 60 * 24 * 7)); + const idx = weekKey % reflections.length; + chunk(view, `\n## 💭 주간 회고\n> ${reflections[idx]}\n`); + + chunk(view, '\n_답을 어디 적으면 좋은지:_ `/decisions` (ADR 작성) · `/feedback` (고객 학습) · `/onesie @멤버` (1:1 준비)\n'); + return true; +} + +// ─── /glossary — Terminology Dictionary 사용자 진입점 ─────────────────── +// 사용자 편집 글로서리 파일(.astra/glossary.md) 의 상태/경로/초기화/캐시 비우기. +// 글로서리 본문은 *에디터에서 직접 편집* — 슬래시 명령은 admin/util 역할. + +async function runGlossary(arg: string, view: any, _context?: vscode.ExtensionContext): Promise { + const trimmed = arg.trim().toLowerCase(); + const cfg = getConfig(); + const fp = getGlossaryFilePath(cfg.glossaryPath || '.astra/glossary.md'); + + if (trimmed === 'help' || trimmed === '?') { + chunk(view, [ + '\n📖 **/glossary — Terminology Dictionary**', + '', + '사용법:', + ' `/glossary` — 상태 (파일 존재/크기, system prompt 주입 여부)', + ' `/glossary path` — 글로서리 파일 절대 경로', + ' `/glossary init` — 권장 템플릿으로 글로서리 파일 생성 (이미 있으면 덮어쓰지 않음)', + ' `/glossary reload` — 캐시 비우기 (편집 직후 즉시 반영 강제)', + '', + '편집 방법: 위 path 의 markdown 파일을 *VS Code 에서 직접 편집*.', + '효과: 다음 채팅 turn 부터 ASTRA 시스템 프롬프트의 [TERMINOLOGY DICTIONARY] 블록에 자동 주입.', + '글로서리 본문은 자유 markdown — H2/H3 섹션 구분 권장.\n', + ].join('\n')); + return true; + } + + if (!fp) { chunk(view, '\n❌ Workspace 폴더 없음 — 글로서리 사용 불가.\n'); return true; } + + if (trimmed === 'path') { + chunk(view, `\n📂 \`${fp}\`\n`); + return true; + } + + if (trimmed === 'init') { + if (fs.existsSync(fp)) { + chunk(view, `\nℹ️ 이미 존재: \`${fp}\` — 덮어쓰지 않음. 새로 시작하려면 파일 직접 삭제 후 재실행.\n`); + return true; + } + try { + fs.mkdirSync(path.dirname(fp), { recursive: true }); + fs.writeFileSync(fp, GLOSSARY_TEMPLATE, 'utf-8'); + clearGlossaryCache(); + chunk(view, `\n✅ 글로서리 초기화 — \`${fp}\`\n`); + chunk(view, '편집 후 다음 채팅 turn 부터 자동 반영. 즉시 반영 강제는 `/glossary reload`.\n'); + } catch (e: any) { + chunk(view, `\n❌ 생성 실패: ${e?.message || String(e)}\n`); + } + return true; + } + + if (trimmed === 'reload') { + clearGlossaryCache(); + chunk(view, '\n🔄 글로서리 캐시 비움. 다음 채팅 turn 에 파일 재읽기.\n'); + return true; + } + + // 기본 상태 카드 + chunk(view, '\n📖 **/glossary — Terminology Dictionary 상태**\n'); + chunk(view, `\n- 경로: \`${fp}\`\n`); + if (!fs.existsSync(fp)) { + chunk(view, `- ❌ 파일 없음 — \`/glossary init\` 로 권장 템플릿 생성 가능\n`); + chunk(view, '- system prompt 주입: ⊘ (no-op)\n'); + return true; + } + try { + const stat = fs.statSync(fp); + const raw = fs.readFileSync(fp, 'utf-8'); + const len = raw.length; + const cap = cfg.glossaryMaxBodyLength ?? 4000; + const truncated = len > cap; + const mtime = new Date(stat.mtimeMs).toISOString().slice(0, 10); + chunk(view, `- ✅ 파일 ${len}자 (${truncated ? `cap ${cap} 초과 — 잘림` : `cap ${cap} 이내`}) · 마지막 편집 ${mtime}\n`); + chunk(view, `- Enabled: ${cfg.glossaryEnabled !== false ? '✓' : '✗'}\n`); + chunk(view, `- system prompt 주입: ${cfg.glossaryEnabled !== false && len > 0 ? '✓ 매 turn 자동' : '⊘'}\n`); + // 첫 5줄 미리보기 + const preview = raw.split('\n').slice(0, 5).join('\n'); + chunk(view, `\n### 미리보기 (첫 5줄)\n\`\`\`\n${preview}\n\`\`\`\n`); + } catch (e: any) { + chunk(view, `- ⚠️ 읽기 실패: ${e?.message || String(e)}\n`); + } + return true; +} + +// ─── /help — 카테고리별 슬래시 명령 브라우저 ───────────────────────────── +// 누적된 명령이 25개+ — 사용자 발견 부담 줄이기. listSlashCommands() 로 *현재 등록된* +// 명령만 동적 표시 (외부 플러그인이 추가한 명령도 자동 노출). + +interface HelpCategory { + title: string; + emoji: string; + /** 매칭 함수 — 명령 이름 받아 boolean. */ + match: (name: string) => boolean; + blurb?: string; +} + +const HELP_CATEGORIES: HelpCategory[] = [ + { + title: '일과 리듬 (Daily Cycle)', + emoji: '☀️', + match: (n) => ['/morning', '/evening', '/weekly', '/cohort', '/standup'].includes(n), + blurb: '아침 (`/morning`) · 저녁 (`/evening`) · 주말 (`/weekly`) · 분기 (`/cohort`) · 팀 공유 (`/standup`)', + }, + { + title: '4인 팀 운영 트래커', + emoji: '🏢', + match: (n) => ['/runway', '/customers', '/hire'].includes(n), + blurb: '재무 (`/runway`) · 매출 (`/customers`) · 채용 (`/hire`) — 모두 event-sourced `.astra/*.jsonl`', + }, + { + title: '작업·블로커·1:1', + emoji: '📋', + match: (n) => ['/task', '/blocked', '/onesie', '/decisions'].includes(n), + blurb: 'Google Tasks 등록 (`/task`) · 지연 분석 (`/blocked`) · 멤버 1:1 카드 (`/onesie`) · ADR (`/decisions`)', + }, + { + title: '외부 출력·기록', + emoji: '✉️', + match: (n) => ['/draft', '/feedback'].includes(n), + blurb: 'email/slack/blog/newsletter 초안 (`/draft`) · 고객 피드백 누적+분석 (`/feedback`)', + }, + { + title: '리서치·분석', + emoji: '🔬', + match: (n) => ['/research', '/benchmark', '/youtube', '/blog', '/wikify', '/meet'].includes(n), + blurb: 'Datacollect bridge 통합 — Deep Research / 웹 벤치마크 / YouTube 4-렌즈 / Blog 파이프라인 / Wikify / 회의록', + }, + { + title: '시스템·메모리', + emoji: '⚙️', + match: (n) => ['/memory', '/glossary'].includes(n), + blurb: 'Temporal Markers + Distillation (`/memory`) · 용어집 (`/glossary`)', + }, + { + title: '주식·외부', + emoji: '📈', + match: (n) => ['/stocks'].includes(n), + blurb: 'Yahoo 가격 + Sheets 동기화 + 텔레그램 보고서 + LLM judge', + }, +]; + +async function runHelp(arg: string, view: any, _context?: vscode.ExtensionContext): Promise { + const trimmed = arg.trim().toLowerCase(); + const allCommands = listSlashCommands(); + const allNames = allCommands.map((c) => c.name.toLowerCase()); + + // 특정 명령에 대한 도움말 — `/help ` + if (trimmed && trimmed !== 'help' && trimmed !== '?') { + const target = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; + const def = allCommands.find((c) => c.name.toLowerCase() === target); + if (!def) { + chunk(view, `\n❌ 명령 "${target}" 등록되지 않음. \`/help\` 로 전체 목록.\n`); + return true; + } + chunk(view, `\n📘 **${def.name}**\n\n${def.description || '_(설명 없음)_'}\n\n`); + chunk(view, `세부 도움말은 \`${def.name} help\` 또는 \`${def.name} ?\` 로 직접 호출 (대부분 명령이 자체 도움말 제공).\n`); + return true; + } + + chunk(view, `\n📚 **ASTRA 슬래시 명령 — 전체 ${allCommands.length}개**\n`); + chunk(view, '_사용: `/<명령>` · 세부 도움말: `/<명령> help` · 특정 명령 검색: `/help <명령>`_\n'); + + const usedNames = new Set(); + for (const cat of HELP_CATEGORIES) { + const matched = allCommands.filter((c) => cat.match(c.name.toLowerCase())); + if (matched.length === 0) continue; + chunk(view, `\n## ${cat.emoji} ${cat.title}\n`); + if (cat.blurb) chunk(view, `_${cat.blurb}_\n\n`); + for (const c of matched) { + usedNames.add(c.name.toLowerCase()); + chunk(view, `- \`${c.name}\` — ${c.description || '_(설명 없음)_'}\n`); + } + } + + // 미분류 명령 (외부 플러그인 등) + const uncategorized = allCommands.filter((c) => !usedNames.has(c.name.toLowerCase()) && c.name !== '/help'); + if (uncategorized.length > 0) { + chunk(view, '\n## 🔌 기타·외부 플러그인\n\n'); + for (const c of uncategorized) { + chunk(view, `- \`${c.name}\` — ${c.description || '_(설명 없음)_'}\n`); + } + } + + // ASTRA Engine 상태 요약 (verification 6종) + const cfg = getConfig(); + chunk(view, '\n---\n\n## 🛡️ ASTRA 추론 엔진 (v2.2.183~ 누적)\n'); + chunk(view, '_각 turn 시스템 프롬프트 자동 주입 (casual 모드 제외):_\n\n'); + const engineStatus = [ + { label: 'Intent Clarification', on: cfg.intentClarificationEnabled !== false, key: '[INTENT CLARIFICATION GUIDANCE]', when: '답변 시작 전' }, + { label: 'Terminology Dictionary', on: cfg.glossaryEnabled !== false, key: '[TERMINOLOGY DICTIONARY]', when: '답변 작성 중' }, + { label: 'Conflict Surface', on: cfg.conflictHighlightingEnabled !== false, key: '[CONFLICT WARNINGS]', when: '답변 작성 중' }, + { label: 'Chain-of-Verification (CoVe)', on: cfg.coveEnabled !== false, key: '[VERIFICATION CHECKLIST]', when: '답변 작성 중' }, + { label: 'Citation Trace', on: cfg.citationTraceEnabled !== false, key: '[CITATION TRACE]', when: '답변 끝' }, + { label: 'Post-hoc Self-Check', on: cfg.selfCheckEnabled === true, key: 'footer (별도 LLM 호출)', when: '답변 완료 후' }, + ]; + for (const e of engineStatus) { + chunk(view, `- ${e.on ? '✓' : '⊘'} **${e.label}** — \`${e.key}\` _(${e.when})_\n`); + } + chunk(view, '\n_추가 검색 신호:_ Recency · Actionability · Hierarchical Level · Semantic Re-rank (opt-in)\n'); + chunk(view, '_메모리 관리:_ Temporal Markers (만료일) · Distillation Loop (stale episodes → LongTerm digest)\n'); + + return true; +} + // ─── 기본 명령 등록 ─────────────────────────────────────────────────────── // Astra 가 기본 제공하는 6개 명령. 외부 플러그인 / 사용자 정의 명령은 동일하게 // `registerSlashCommand({ name, description, handler })` 한 번 호출로 추가. @@ -1262,6 +4149,22 @@ registerSlashCommand({ name: '/blog', description: 'Blog Pipeline 안내 (Dataco registerSlashCommand({ name: '/wikify', description: '웹 URL → P-Reinforce v3.0 위키 합성·저장', handler: runWikify }); registerSlashCommand({ name: '/meet', description: '회의 transcript → 회의록 합성 + 캘린더·task 등록', handler: runMeet }); registerSlashCommand({ name: '/task', description: '단일 작업을 Google Tasks + Calendar 양쪽에 등록 (제목 + 시작일 + 완료일)', handler: runTask }); +registerSlashCommand({ name: '/decisions', description: 'Chronicle 결정 기록(ADR) 검색 — 키워드/담당자로 필터', handler: runDecisions }); +registerSlashCommand({ name: '/onesie', description: '멤버별 1:1 미팅 준비 카드 — Tasks 진행 + 최근 결정 + 대화 토픽 제안', handler: runOnesie }); +registerSlashCommand({ name: '/draft', description: '외부 커뮤니케이션 초안 — email/slack/blog/newsletter/investor-update/proposal', handler: runDraft }); +registerSlashCommand({ name: '/feedback', description: '고객 피드백 누적 + 자동 카테고리 분류 + 패턴 분석 (로컬 .jsonl)', handler: runFeedback }); +registerSlashCommand({ name: '/blocked', description: '전사 across 지연·블로커 한 화면 — 일일 아침 ritual', handler: runBlocked }); +registerSlashCommand({ name: '/standup', description: '팀 스탠드업 카드 (멤버별 완료/진행/블로커, 슬랙 복붙 친화)', handler: runStandup }); +registerSlashCommand({ name: '/runway', description: '현금 / 월 소진율 / 런웨이 — 4인 기업 CEO 의 가장 중요한 숫자 (로컬 .jsonl)', handler: runRunway }); +registerSlashCommand({ name: '/customers', description: '고객사 / MRR / 갱신 / 위험 트래커 — event-sourced 로그 (로컬 .jsonl)', handler: runCustomers }); +registerSlashCommand({ name: '/hire', description: '채용 파이프라인 — 후보자 단계·오퍼·합격 트래커 (로컬 .jsonl)', handler: runHire }); +registerSlashCommand({ name: '/morning', description: '매일 아침 통합 대시보드 — runway/customers/tasks/hire 핵심 한 화면 + 오늘의 액션', handler: runMorning }); +registerSlashCommand({ name: '/evening', description: '하루 마무리 카드 — 오늘의 진척 + 내일 준비 + 회고 한 줄', handler: runEvening }); +registerSlashCommand({ name: '/memory', description: '메모리 라이프사이클 — Temporal Markers (만료일) + Distillation Loop (stale episodes → LongTerm digest)', handler: runMemory }); +registerSlashCommand({ name: '/cohort', description: 'MoM 추세 분석 — customers + runway events 월별 그룹핑 (신규/이탈/MRR/burn)', handler: runCohort }); +registerSlashCommand({ name: '/weekly', description: '주간 리뷰 카드 (대표용) — 이번 주 진척 + 지난 주 대비 + 다음 주 준비 + 회고', handler: runWeekly }); +registerSlashCommand({ name: '/glossary', description: 'Terminology Dictionary — 표준 용어집 (.astra/glossary.md) 상태/생성/리로드. 다음 turn 부터 system prompt 자동 주입', handler: runGlossary }); +registerSlashCommand({ name: '/help', description: '슬래시 명령 전체 목록 (카테고리별) + ASTRA 추론 엔진 상태. `/help <명령>` 으로 특정 명령 정보.', handler: runHelp }); // /stocks 는 `src/features/stocks/slashStocks.ts` 의 sub-command 라우터로 위임. // list/check/signal/sync/add/remove/judge/report/run/path 9 개의 subcommand 가 그 안에서 분기. diff --git a/src/features/feedback/feedbackStore.ts b/src/features/feedback/feedbackStore.ts new file mode 100644 index 0000000..b1ce8af --- /dev/null +++ b/src/features/feedback/feedbackStore.ts @@ -0,0 +1,81 @@ +/** + * 고객 피드백 누적 저장소. + * + * 단일 운영자(대표) 모드에서 슬랙·이메일·CS 채널에 흩어진 고객 피드백을 + * `/feedback <텍스트>` 한 줄로 모아 둔다. 패턴 분석은 `/feedback summary` 로 + * LLM 이 누적 데이터를 보고 카테고리 분포 + 반복 주제를 추출. + * + * 저장 형식: JSON Lines (.jsonl) — 한 줄 = 한 entry. 누적·append-only, 사람이 + * 직접 편집 가능, grep / 백업 친화. 위치: `/.astra/customer-feedback.jsonl`. + * + * 워크스페이스 폴더가 없으면 저장 불가 (null 반환). 사용자가 `/feedback path` 로 + * 위치 확인 가능. 민감 정보 포함 가능성 있으므로 외부로 안 보냄 — 로컬 only. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +const STORE_REL_PATH = '.astra/customer-feedback.jsonl'; + +export interface FeedbackEntry { + /** unique id — timestamp 기반 (정렬·dedup 용도). */ + id: string; + /** ISO timestamp of when this entry was captured. */ + timestamp: string; + /** 사용자가 입력한 원본 텍스트 (그대로 보존). */ + text: string; + /** 선택적 출처 — 'slack' / 'email' / 'cs' / 'review' 등. */ + source?: string; + /** LLM 이 부여한 카테고리 (1~3개). */ + categories?: string[]; + /** LLM 판정 — 'positive' / 'neutral' / 'negative'. */ + sentiment?: 'positive' | 'neutral' | 'negative'; +} + +export function getFeedbackFilePath(): string | null { + const folders = vscode.workspace.workspaceFolders; + if (!folders || folders.length === 0) return null; + return path.join(folders[0].uri.fsPath, STORE_REL_PATH); +} + +export function readFeedback(): FeedbackEntry[] { + const filePath = getFeedbackFilePath(); + if (!filePath || !fs.existsSync(filePath)) return []; + const out: FeedbackEntry[] = []; + let content = ''; + try { content = fs.readFileSync(filePath, 'utf-8'); } catch { return []; } + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const entry = JSON.parse(trimmed); + if (entry && typeof entry.id === 'string' && typeof entry.text === 'string') { + out.push(entry as FeedbackEntry); + } + } catch { /* skip malformed line — append-only 라 손상 1줄이 전체 무력화하면 안 됨 */ } + } + return out; +} + +export function appendFeedback(entry: FeedbackEntry): { ok: true; filePath: string } | { ok: false; error: string } { + const filePath = getFeedbackFilePath(); + if (!filePath) return { ok: false, error: '워크스페이스 폴더가 없어 저장 불가. VS Code 에서 폴더를 열어 주세요.' }; + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.appendFileSync(filePath, JSON.stringify(entry) + '\n', 'utf-8'); + return { ok: true, filePath }; + } catch (e: any) { + return { ok: false, error: e?.message || String(e) }; + } +} + +/** 누적 항목 수 — 빠른 확인용. */ +export function countFeedback(): number { + const filePath = getFeedbackFilePath(); + if (!filePath || !fs.existsSync(filePath)) return 0; + try { + const content = fs.readFileSync(filePath, 'utf-8'); + return content.split('\n').filter((l) => l.trim()).length; + } catch { return 0; } +} diff --git a/src/features/hire/hireStore.ts b/src/features/hire/hireStore.ts new file mode 100644 index 0000000..0dba0ab --- /dev/null +++ b/src/features/hire/hireStore.ts @@ -0,0 +1,150 @@ +/** + * 채용 파이프라인 트래커. + * + * 4인 → 5인 이상 확장 시점에 후보자가 여러 명, 여러 역할(개발/기획/디자인) 로 + * 들어오기 시작 — 노션·스프레드시트·이메일에 흩어진 정보를 한 명령으로 본다. + * + * Event-sourced (customersStore 와 동일 패턴) — append-only 이벤트 로그를 + * 재생해 후보자별 현재 단계 + 노트 누적 도출. + * + * 위치: `/.astra/hire.jsonl`. 사람 직접 편집 가능. + * 민감 정보(이름, 연봉, 거절 사유) 포함 — 로컬 only. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +const STORE_REL_PATH = '.astra/hire.jsonl'; + +export type HireEventType = 'add' | 'stage' | 'note' | 'offer' | 'reject' | 'decline' | 'hire'; + +/** + * 기본 파이프라인 단계. 사용자가 다른 단계명 지정 가능 — 그냥 문자열로 저장. + * 표시 순서·정렬용으로 알려진 단계는 가중치 부여. + */ +export const KNOWN_STAGES = ['inbox', 'screened', 'interview', 'final', 'offer', 'accepted', 'hired', 'rejected', 'declined'] as const; +export type KnownStage = typeof KNOWN_STAGES[number]; + +export interface HireEvent { + id: string; + timestamp: string; + candidateId: string; + candidateName: string; + role: string; + type: HireEventType; + /** stage 전환 시 새 단계. add 시 시작 단계 (기본 'inbox'). */ + stage?: string; + /** offer 의 연봉 — KRW. */ + salary?: number; + /** 입사 예정일 / 거절·이탈 사유. */ + memo?: string; +} + +export interface CandidateState { + candidateId: string; + candidateName: string; + role: string; + stage: string; + salary?: number; + addedAt: string; + lastEventAt: string; + eventCount: number; + notes: { timestamp: string; type: HireEventType; memo: string }[]; +} + +export function getHireFilePath(): string | null { + const folders = vscode.workspace.workspaceFolders; + if (!folders || folders.length === 0) return null; + return path.join(folders[0].uri.fsPath, STORE_REL_PATH); +} + +export function candidateIdFromName(name: string): string { + return name.toLowerCase().trim().replace(/\s+/g, '-').replace(/[^\w가-힣-]/g, ''); +} + +export function readHireEvents(): HireEvent[] { + const filePath = getHireFilePath(); + if (!filePath || !fs.existsSync(filePath)) return []; + const out: HireEvent[] = []; + let content = ''; + try { content = fs.readFileSync(filePath, 'utf-8'); } catch { return []; } + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const e = JSON.parse(trimmed); + if (e && typeof e.id === 'string' && typeof e.candidateId === 'string' && typeof e.type === 'string') { + out.push(e as HireEvent); + } + } catch { /* skip malformed */ } + } + return out; +} + +export function appendHireEvent(event: HireEvent): { ok: true; filePath: string } | { ok: false; error: string } { + const filePath = getHireFilePath(); + if (!filePath) return { ok: false, error: '워크스페이스 폴더가 없어 저장 불가.' }; + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.appendFileSync(filePath, JSON.stringify(event) + '\n', 'utf-8'); + return { ok: true, filePath }; + } catch (e: any) { + return { ok: false, error: e?.message || String(e) }; + } +} + +export function computeCandidateStates(): Map { + const events = readHireEvents().slice().sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || '')); + const states = new Map(); + for (const e of events) { + let s = states.get(e.candidateId); + if (!s) { + s = { + candidateId: e.candidateId, + candidateName: e.candidateName, + role: e.role || '', + stage: e.stage || 'inbox', + addedAt: e.timestamp, + lastEventAt: e.timestamp, + eventCount: 0, + notes: [], + }; + states.set(e.candidateId, s); + } + s.candidateName = e.candidateName || s.candidateName; + s.role = e.role || s.role; + s.lastEventAt = e.timestamp; + s.eventCount += 1; + + switch (e.type) { + case 'add': + s.stage = e.stage || s.stage || 'inbox'; + break; + case 'stage': + if (e.stage) s.stage = e.stage; + break; + case 'offer': + s.stage = 'offer'; + if (e.salary !== undefined) s.salary = e.salary; + if (e.memo) s.notes.push({ timestamp: e.timestamp, type: 'offer', memo: e.memo }); + break; + case 'hire': + s.stage = 'hired'; + if (e.memo) s.notes.push({ timestamp: e.timestamp, type: 'hire', memo: e.memo }); + break; + case 'reject': + s.stage = 'rejected'; + if (e.memo) s.notes.push({ timestamp: e.timestamp, type: 'reject', memo: e.memo }); + break; + case 'decline': + s.stage = 'declined'; + if (e.memo) s.notes.push({ timestamp: e.timestamp, type: 'decline', memo: e.memo }); + break; + case 'note': + if (e.memo) s.notes.push({ timestamp: e.timestamp, type: 'note', memo: e.memo }); + break; + } + } + return states; +} diff --git a/src/features/runway/runwayStore.ts b/src/features/runway/runwayStore.ts new file mode 100644 index 0000000..f92de30 --- /dev/null +++ b/src/features/runway/runwayStore.ts @@ -0,0 +1,173 @@ +/** + * Runway / Cash 누적 저장소. + * + * 4인 기업 운영의 가장 중요한 숫자 — 현금 잔고 / 월 소진율 / 남은 개월수 — 를 + * 한 명령 (`/runway`) 로 본다. 회계 시스템은 아니고, 대표가 머리에 가지고 있는 + * "지금 통장에 얼마, 한 달에 얼마 나감" 을 코드 옆에서 잡는 가벼운 트래커. + * + * 저장 형식: JSON Lines (.jsonl) — 한 줄 = 한 entry. 외부 회계 SaaS 연동 안 함. + * 위치: `/.astra/runway.jsonl`. 사람이 직접 편집 가능, grep / 백업 친화. + * + * 민감 정보(현금 잔고) 포함되므로 외부로 안 보냄 — 로컬 only. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +const STORE_REL_PATH = '.astra/runway.jsonl'; + +export type RunwayEntryType = 'snapshot' | 'expense' | 'revenue' | 'burn'; + +export interface RunwayEntry { + /** unique id — timestamp 기반. */ + id: string; + /** ISO timestamp. */ + timestamp: string; + /** 항목 종류 — snapshot(잔고) / expense(지출) / revenue(수입) / burn(월 소진율 수동 설정). */ + type: RunwayEntryType; + /** 금액 — KRW 기본 단위, 소수점 허용. */ + amount: number; + /** 통화 — 기본 'KRW'. 추후 'USD' 등 확장 가능. */ + currency?: string; + /** 카테고리 — expense 의 경우 'salary' / 'rent' / 'saas' / 'misc' 등. */ + category?: string; + /** 자유 메모. */ + memo?: string; +} + +export function getRunwayFilePath(): string | null { + const folders = vscode.workspace.workspaceFolders; + if (!folders || folders.length === 0) return null; + return path.join(folders[0].uri.fsPath, STORE_REL_PATH); +} + +export function readRunway(): RunwayEntry[] { + const filePath = getRunwayFilePath(); + if (!filePath || !fs.existsSync(filePath)) return []; + const out: RunwayEntry[] = []; + let content = ''; + try { content = fs.readFileSync(filePath, 'utf-8'); } catch { return []; } + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const entry = JSON.parse(trimmed); + if (entry && typeof entry.id === 'string' && typeof entry.amount === 'number' && typeof entry.type === 'string') { + out.push(entry as RunwayEntry); + } + } catch { /* skip malformed line */ } + } + return out; +} + +export function appendRunway(entry: RunwayEntry): { ok: true; filePath: string } | { ok: false; error: string } { + const filePath = getRunwayFilePath(); + if (!filePath) return { ok: false, error: '워크스페이스 폴더가 없어 저장 불가. VS Code 에서 폴더를 열어 주세요.' }; + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.appendFileSync(filePath, JSON.stringify(entry) + '\n', 'utf-8'); + return { ok: true, filePath }; + } catch (e: any) { + return { ok: false, error: e?.message || String(e) }; + } +} + +/** + * 현재 상태 계산 — 최신 snapshot, 최근 30일 net burn, 명시적 burn 설정 중 우선순위. + * + * - latestCash: 가장 최근 'snapshot' entry 의 amount (없으면 null). + * - explicitBurn: 가장 최근 'burn' entry — 사용자가 수동 설정한 월 소진율. + * - computedBurn: 최근 30일 expense - revenue, 30일 미만이면 일 평균 × 30 으로 보정. + * - effectiveBurn: explicitBurn 우선, 없으면 computedBurn. + * - runwayMonths: latestCash / effectiveBurn — burn 이 0 이하면 Infinity. + */ +export interface RunwayStatus { + latestCash: number | null; + latestCashAt: string | null; + explicitBurn: number | null; + computedBurn: number | null; + effectiveBurn: number | null; + runwayMonths: number | null; + last30Expense: number; + last30Revenue: number; + last30Days: number; + totalEntries: number; +} + +export function computeRunwayStatus(now: Date = new Date()): RunwayStatus { + const entries = readRunway(); + const nowMs = now.getTime(); + const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000; + + let latestCash: number | null = null; + let latestCashAt: string | null = null; + let explicitBurn: number | null = null; + let last30Expense = 0; + let last30Revenue = 0; + let oldestRecentMs = nowMs; + let hasRecent = false; + + for (const e of entries) { + const t = Date.parse(e.timestamp); + if (e.type === 'snapshot') { + if (!latestCashAt || (Date.parse(e.timestamp) >= Date.parse(latestCashAt))) { + latestCash = e.amount; + latestCashAt = e.timestamp; + } + } else if (e.type === 'burn') { + if (!explicitBurn || t >= (entries.find(x => x.type === 'burn' && x.amount === explicitBurn)?.timestamp ? Date.parse(e.timestamp) : 0)) { + explicitBurn = e.amount; + } + } else if (e.type === 'expense' && nowMs - t <= thirtyDaysMs) { + last30Expense += e.amount; + if (t < oldestRecentMs) oldestRecentMs = t; + hasRecent = true; + } else if (e.type === 'revenue' && nowMs - t <= thirtyDaysMs) { + last30Revenue += e.amount; + if (t < oldestRecentMs) oldestRecentMs = t; + hasRecent = true; + } + } + + // 최신 burn 정확히 다시 — 위 로직이 꼬여서 단순화. + explicitBurn = null; + let burnAt = 0; + for (const e of entries) { + if (e.type !== 'burn') continue; + const t = Date.parse(e.timestamp); + if (t >= burnAt) { explicitBurn = e.amount; burnAt = t; } + } + + const netBurn30 = last30Expense - last30Revenue; + let computedBurn: number | null = null; + let last30Days = 0; + if (hasRecent) { + const span = Math.max(1, Math.ceil((nowMs - oldestRecentMs) / (24 * 60 * 60 * 1000))); + last30Days = Math.min(30, span); + // 30일 미만이면 일 평균 × 30 으로 환산. + if (last30Days < 30) computedBurn = (netBurn30 / last30Days) * 30; + else computedBurn = netBurn30; + } + + const effectiveBurn = explicitBurn ?? computedBurn; + let runwayMonths: number | null = null; + if (latestCash !== null && effectiveBurn !== null && effectiveBurn > 0) { + runwayMonths = latestCash / effectiveBurn; + } else if (latestCash !== null && effectiveBurn !== null && effectiveBurn <= 0) { + runwayMonths = Infinity; + } + + return { + latestCash, + latestCashAt, + explicitBurn, + computedBurn, + effectiveBurn, + runwayMonths, + last30Expense, + last30Revenue, + last30Days, + totalEntries: entries.length, + }; +} diff --git a/src/lib/contextBuilders/memoryContext.ts b/src/lib/contextBuilders/memoryContext.ts index 1aeb5f6..f216e50 100644 --- a/src/lib/contextBuilders/memoryContext.ts +++ b/src/lib/contextBuilders/memoryContext.ts @@ -15,6 +15,14 @@ import { mapWeightToRetrievalRatio, ResolvedKnowledgeMix, } from '../../retrieval/knowledgeMix'; +import { buildConflictWarningsBlock, ConflictThresholdSetting } from '../../retrieval/conflictBlock'; +import { buildCoveChecklistBlock } from '../../retrieval/coveBlock'; +import { captureWorkStateSignals } from '../../retrieval/actionabilityScoring'; +import { getRecentSlashCommands } from '../../features/datacollect/slashRouter'; +import { semanticRerank, DEFAULT_SEMANTIC_RERANK_OPTIONS } from '../../retrieval/semanticRerank'; +import { detectAmbiguity, buildIntentClarificationBlock, IntentStrictness } from '../../retrieval/intentClarification'; +import { buildCitationTraceBlock } from '../../retrieval/citationTrace'; +import { buildTerminologyBlock } from '../../retrieval/terminologyBlock'; /** * 한 turn 의 RAG / 5-layer memory 컨텍스트 빌드. @@ -54,6 +62,29 @@ export interface TurnContextSink { retrieval: TurnRetrievalSummary | null; lessons: string[]; knowledgeMix: ResolvedKnowledgeMix | null; + /** + * [CONFLICT WARNINGS] 시스템 프롬프트 블록 — 빈 문자열이면 충돌 없음. + * buildAstraModeSystemPrompt 가 직접 prompt 에 주입. + */ + conflictWarnings: string; + /** + * [VERIFICATION CHECKLIST] Chain-of-Verification 블록 — 빈 문자열이면 CoVe 비활성/근거 없음. + * 모델이 답변 *작성 전* 그라운딩 체크하도록 instructional prompt 주입. + */ + coveChecklist: string; + /** + * [INTENT CLARIFICATION GUIDANCE] 블록 — 질의가 모호한 차원이 감지된 경우 LLM 에게 + * 답변보다 *역질문* 우선 지시. 빈 문자열이면 모호 차원 없음 또는 disable. + */ + intentClarification: string; + /** + * [CITATION TRACE] 블록 — 답변 끝에 사용 출처 한 줄 정리 지시. 검색 결과 있을 때만. + */ + citationTrace: string; + /** Post-hoc Self-Check 용 — selected chunks 의 (title, excerpt) 요약. */ + selfCheckSources: Array<{ title: string; excerpt: string }>; + /** [TERMINOLOGY DICTIONARY] 시스템 프롬프트 블록 — 글로서리 파일 있을 때만 채워짐. */ + terminology: string; } export interface MemoryContextDeps { @@ -163,6 +194,12 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise= 3) { + const rerankModel = (config.semanticRerankModel || '').trim() || config.defaultModel; + if (rerankModel && config.ollamaUrl) { + const rerankRes = await semanticRerank(deps.currentPrompt, result.selectedChunks, { + ollamaUrl: config.ollamaUrl, + model: rerankModel, + candidateK: config.semanticRerankCandidateK ?? DEFAULT_SEMANTIC_RERANK_OPTIONS.candidateK, + timeoutMs: (config.semanticRerankTimeoutSec ?? 8) * 1000, + excerptLength: DEFAULT_SEMANTIC_RERANK_OPTIONS.excerptLength, + }); + // In-place 교체 — buildContextString 가 이 배열을 그대로 읽음. + result.selectedChunks = rerankRes.rerankedChunks; + result.fusionLog.push(`Semantic re-rank: ${rerankRes.success ? '✓' : '✗'} ${rerankRes.note} (${rerankRes.durationMs}ms)`); + } + } + // Fire-and-forget background embedding. Vector 없는 파일만 embed 하므로 // steady-state turn 은 작업량 0 — 다음 turn 이 혜택. if (config.embeddingModel) { @@ -225,6 +283,67 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise c.content); + + // Conflict Surface — selectedChunks 의 per-doc conflictSeverity 신호 + 교차-문서 + // 발산 후보를 LLM 에 노출. 블록은 [CONTEXT] *밖*에 주입돼 token-truncation 보호. + // 설정으로 disable 가능 — 기본 켜져 있음 (v4 정책이 이미 CONFLICT WARNING 플래그 참조). + if (config.conflictHighlightingEnabled !== false) { + const threshold: ConflictThresholdSetting = (config.conflictSeverityThreshold || 'medium') as ConflictThresholdSetting; + deps.turnCtx.conflictWarnings = buildConflictWarningsBlock(result.selectedChunks, { + selfFlagThreshold: threshold, + crossDivergenceEnabled: config.conflictCrossDocEnabled !== false, + }); + } else { + deps.turnCtx.conflictWarnings = ''; + } + + // CoVe (Chain-of-Verification) — 답변 *작성 전* 그라운딩 체크리스트를 시스템 프롬프트에 + // 주입. 모델이 한 패스 안에서 self-verify. Conflict Surface 와 보완 관계 — 충돌 + // 데이터를 *어떻게* verify 할지 지시. + if (config.coveEnabled !== false) { + deps.turnCtx.coveChecklist = buildCoveChecklistBlock(result.selectedChunks, deps.currentPrompt, { + topSourcesCount: config.coveTopSourcesCount ?? 5, + strictMode: config.coveStrictMode === true, + }); + } else { + deps.turnCtx.coveChecklist = ''; + } + + // Intent Clarification — 모호 차원 감지 시 *역질문 우선* 지시. CoVe / Citation 과 + // 동일 패턴: instructional system prompt block. + if (config.intentClarificationEnabled !== false) { + const strict = (config.intentClarificationStrictness || 'medium') as IntentStrictness; + const ambig = detectAmbiguity(deps.currentPrompt, strict); + deps.turnCtx.intentClarification = buildIntentClarificationBlock(ambig); + } else { + deps.turnCtx.intentClarification = ''; + } + + // Citation Trace — 답변 끝에 출처 한 줄 명시 지시. CoVe Strict 의 가벼운 형제. + // 검색 결과가 있을 때만 의미 있음. + if (config.citationTraceEnabled !== false && result.selectedChunks.length > 0) { + deps.turnCtx.citationTrace = buildCitationTraceBlock(result.selectedChunks); + } else { + deps.turnCtx.citationTrace = ''; + } + + // Self-Check 용 source 미리보기 — agent.ts 가 post-stream 에서 사용. + deps.turnCtx.selfCheckSources = result.selectedChunks.slice(0, 5).map((c) => ({ + title: c.title || '(제목 없음)', + excerpt: (c.content || '').slice(0, 200), + })); + + // Terminology Dictionary — 사용자 편집 글로서리 파일을 시스템 프롬프트 블록으로 주입. + // 파일 없으면 빈 문자열 (no-op). 캐시 + mtime 체크로 매 turn 디스크 read 최소화. + if (config.glossaryEnabled !== false) { + deps.turnCtx.terminology = buildTerminologyBlock({ + relPath: config.glossaryPath || '.astra/glossary.md', + maxBodyLength: config.glossaryMaxBodyLength ?? 4000, + }); + } else { + deps.turnCtx.terminology = ''; + } + // Lessons 블록은 일반 RAG context 보다 앞 — context-overflow truncation 에서 먼저 // 살아남게. const lessonBlock = buildLessonChecklistBlock(lessonChunks.map((c) => ({ title: c.title, content: c.content }))); diff --git a/src/memory/EpisodicMemory.ts b/src/memory/EpisodicMemory.ts index ef42bb6..881a21b 100644 --- a/src/memory/EpisodicMemory.ts +++ b/src/memory/EpisodicMemory.ts @@ -130,7 +130,12 @@ export class EpisodicMemory { * 프롬프트와 관련된 에피소드를 검색합니다. */ public findRelevantEpisodes(prompt: string, limit = 3): EpisodicEntry[] { - const episodes = this.loadAllEpisodes(); + // Temporal + Distillation 필터: 만료된 episode 와 LongTerm 으로 이미 promote 된 + // episode 는 검색에서 제외 (digest 가 LongTerm 에 있으니 중복 노출 방지). + const now = Date.now(); + const episodes = this.loadAllEpisodes() + .filter((ep) => !ep.expiresAt || ep.expiresAt > now) + .filter((ep) => !ep.promoted); const promptLower = prompt.toLowerCase(); const terms = promptLower .split(/[^a-z0-9가-힣_]+/g) @@ -276,6 +281,45 @@ export class EpisodicMemory { .map(([word]) => word); } + /** + * 단일 episode 의 promoted 플래그를 true 로 마킹하고 LongTerm digest id 를 기록. + * Distillation Loop 가 호출. 파일 rewrite 1회. + */ + public markPromoted(episodeId: string, longTermDigestId: string): boolean { + const episodes = this.loadAllEpisodes(); + const ep = episodes.find((e) => e.id === episodeId); + if (!ep) return false; + ep.promoted = true; + ep.promotedToLongTermId = longTermDigestId; + // Find the file holding this episode and rewrite. + try { + const files = fs.readdirSync(this.episodeDir).filter((f) => f.endsWith('.json')); + for (const file of files) { + const full = path.join(this.episodeDir, file); + try { + const raw = fs.readFileSync(full, 'utf-8'); + const parsed = JSON.parse(raw) as EpisodicEntry; + if (parsed.id === episodeId) { + parsed.promoted = true; + parsed.promotedToLongTermId = longTermDigestId; + fs.writeFileSync(full, JSON.stringify(parsed, null, 2), 'utf-8'); + this._episodeCache = null; // dir mtime bump → cache 다음 호출에 갱신 + return true; + } + } catch { /* skip */ } + } + } catch { /* ignore */ } + return false; + } + + /** Distillation 후보 — 지정 일수보다 오래되고 아직 promoted 되지 않은 episodes. */ + public findStaleEpisodes(ageThresholdDays: number): EpisodicEntry[] { + const cutoff = Date.now() - ageThresholdDays * 24 * 60 * 60 * 1000; + return this.loadAllEpisodes() + .filter((ep) => !ep.promoted) + .filter((ep) => ep.timestamp < cutoff); + } + private pruneOldEpisodes(): void { try { const files = fs.readdirSync(this.episodeDir) diff --git a/src/memory/LongTermMemory.ts b/src/memory/LongTermMemory.ts index 95a6592..77e8f21 100644 --- a/src/memory/LongTermMemory.ts +++ b/src/memory/LongTermMemory.ts @@ -53,7 +53,13 @@ export class LongTermMemory { // ─── CRUD ─── - public addEntry(category: LongTermCategory, content: string, source: string, confidence = 0.8): LongTermEntry { + public addEntry( + category: LongTermCategory, + content: string, + source: string, + confidence = 0.8, + opts: { expiresAt?: number } = {}, + ): LongTermEntry { const entry: LongTermEntry = { id: crypto.randomUUID(), category, @@ -62,7 +68,8 @@ export class LongTermMemory { confidence, createdAt: Date.now(), lastReferencedAt: Date.now(), - referenceCount: 0 + referenceCount: 0, + ...(opts.expiresAt ? { expiresAt: opts.expiresAt } : {}), }; this.store.entries.push(entry); // Enforce the retention cap — drop the oldest entries (by createdAt) once @@ -87,12 +94,32 @@ export class LongTermMemory { return false; } - public getAllEntries(): LongTermEntry[] { - return [...this.store.entries]; + /** 만료된 entry 를 필터링한 활성 entries (검색·context build 가 보는 것). */ + private getActiveEntries(): LongTermEntry[] { + const now = Date.now(); + return this.store.entries.filter((e) => !e.expiresAt || e.expiresAt > now); + } + + public getAllEntries(opts: { includeExpired?: boolean } = {}): LongTermEntry[] { + return opts.includeExpired ? [...this.store.entries] : this.getActiveEntries(); } public getEntriesByCategory(category: LongTermCategory): LongTermEntry[] { - return this.store.entries.filter((e) => e.category === category); + return this.getActiveEntries().filter((e) => e.category === category); + } + + /** + * 기존 entry 의 expiresAt 갱신. id 또는 id prefix (8자 이상) 로 식별. + * 반환: 갱신된 entry 또는 null (못 찾음). + */ + public setExpiration(idOrPrefix: string, expiresAt: number): LongTermEntry | null { + const match = this.store.entries.find((e) => e.id === idOrPrefix) + || (idOrPrefix.length >= 8 ? this.store.entries.find((e) => e.id.startsWith(idOrPrefix)) : undefined); + if (!match) return null; + match.expiresAt = expiresAt; + this.dirty = true; + this.save(); + return match; } // ─── Context Building ─── @@ -101,7 +128,9 @@ export class LongTermMemory { * 프롬프트와 관련성이 높은 Long-Term 기억을 반환합니다. */ public buildContext(currentPrompt: string, maxEntries = 10): MemoryContextResult | null { - if (this.store.entries.length === 0) return null; + // 만료된 entry 는 검색에서 자동 제외 — Temporal Markers 의 핵심. + const activeEntries = this.getActiveEntries(); + if (activeEntries.length === 0) return null; const promptLower = currentPrompt.toLowerCase(); const terms = promptLower @@ -109,7 +138,7 @@ export class LongTermMemory { .filter((t) => t.length >= 2); // Score entries by relevance to prompt - const scored = this.store.entries.map((entry) => { + const scored = activeEntries.map((entry) => { let score = 0; const contentLower = entry.content.toLowerCase(); @@ -134,8 +163,8 @@ export class LongTermMemory { .slice(0, maxEntries); if (relevant.length === 0) { - // Still include all rules and goals even without prompt match - const alwaysInclude = this.store.entries + // Still include all rules and goals even without prompt match — 만료 제외. + const alwaysInclude = activeEntries .filter((e) => e.category === 'rule' || e.category === 'goal') .slice(0, 5); if (alwaysInclude.length === 0) return null; diff --git a/src/memory/distillation.ts b/src/memory/distillation.ts new file mode 100644 index 0000000..03ce911 --- /dev/null +++ b/src/memory/distillation.ts @@ -0,0 +1,200 @@ +/** + * Distillation Loop — stale Episodic Memory → Long-Term "episode-digest" 승급. + * + * 배경: Episodic Memory 가 무한히 누적되면 검색 노이즈. 30일+ 지난 에피소드는 + * "지금 이 순간 관련 가능성" 보다 "역사적 패턴" 가치가 커서, 디테일을 압축해 + * Long-Term 으로 옮기고 원본은 archive 하는 게 효율적. + * + * v1 설계 (LLM-less, 예측 가능): + * - LLM 호출 없이 기존 EpisodicEntry 의 title/summary/keyDecisions/topics 를 + * 구조적으로 결합해 LongTerm 'episode-digest' content 생성 + * - 장점: 비용 0, 결정적·재현 가능, LM Studio 다운 시에도 동작 + * - 단점: LLM 요약보다 농축도 낮음 — 추후 strict 모드에서 LLM 패스 추가 가능 + * + * 원본 episode 처리: 두 가지 옵션 — 사용자 설정으로 결정. + * - 'mark-promoted' (기본): promoted=true 마킹만, 파일 보존. 검색에서 제외되나 + * 히스토리·디버깅용으로 디스크에 남음. + * - 'archive-file': promoted 마킹 + 파일을 memory/episodes/archive/ 로 이동. + * 디스크 정리에 더 깔끔하나 복구 시 수동. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { EpisodicMemory } from './EpisodicMemory'; +import { LongTermMemory } from './LongTermMemory'; +import { EpisodicEntry } from './types'; + +export type DistillationArchiveMode = 'mark-promoted' | 'archive-file'; + +export interface DistillationOptions { + /** 며칠 이상 지난 episode 를 대상으로. 기본 30. */ + ageThresholdDays: number; + /** Archive 처리 방식. 기본 'mark-promoted'. */ + archiveMode: DistillationArchiveMode; + /** 한 번에 처리할 최대 episode 수 (안전장치). 기본 50. */ + maxBatchSize: number; +} + +export const DEFAULT_DISTILLATION_OPTIONS: DistillationOptions = { + ageThresholdDays: 30, + archiveMode: 'mark-promoted', + maxBatchSize: 50, +}; + +export interface DistillationReport { + candidateCount: number; + promotedCount: number; + archivedCount: number; + longTermDigestIds: string[]; + skipped: { episodeId: string; reason: string }[]; + durationMs: number; +} + +/** + * Episode → LongTerm 'episode-digest' content 변환. 결정적·LLM 없음. + */ +function episodeToDigestContent(ep: EpisodicEntry): string { + const date = new Date(ep.timestamp).toISOString().slice(0, 10); + const parts: string[] = []; + parts.push(`[${date}] ${ep.title}`); + if (ep.summary && ep.summary.trim()) parts.push(`요약: ${ep.summary.trim()}`); + if (ep.keyDecisions && ep.keyDecisions.length > 0) { + parts.push(`결정: ${ep.keyDecisions.slice(0, 5).join(' · ')}`); + } + if (ep.topics && ep.topics.length > 0) { + parts.push(`토픽: ${ep.topics.slice(0, 8).join(', ')}`); + } + return parts.join('\n'); +} + +/** + * Distillation 실행 — stale episodes 를 LongTerm digest 로 승급 + archive. + * + * 호출자: `/memory distill` 슬래시 명령 + 세션 종료 시 auto-trigger (선택). + */ +export function distillStaleEpisodes( + episodicMemory: EpisodicMemory, + longTermMemory: LongTermMemory, + brainPath: string, + options: Partial = {}, +): DistillationReport { + const opts: DistillationOptions = { ...DEFAULT_DISTILLATION_OPTIONS, ...options }; + const start = Date.now(); + const report: DistillationReport = { + candidateCount: 0, + promotedCount: 0, + archivedCount: 0, + longTermDigestIds: [], + skipped: [], + durationMs: 0, + }; + + const candidates = episodicMemory.findStaleEpisodes(opts.ageThresholdDays).slice(0, opts.maxBatchSize); + report.candidateCount = candidates.length; + + if (candidates.length === 0) { + report.durationMs = Date.now() - start; + return report; + } + + const archiveDir = path.join(brainPath, 'memory', 'episodes', 'archive'); + if (opts.archiveMode === 'archive-file') { + try { fs.mkdirSync(archiveDir, { recursive: true }); } catch { /* ignore */ } + } + + for (const ep of candidates) { + try { + // 1. LongTerm digest entry 생성. confidence 약간 낮춤 (압축 손실 반영). + const digestContent = episodeToDigestContent(ep); + const digest = longTermMemory.addEntry( + 'episode-digest', + digestContent, + `episodic:${ep.id}`, + 0.7, + ); + report.longTermDigestIds.push(digest.id); + + // 2. 원본 episode 처리. + const marked = episodicMemory.markPromoted(ep.id, digest.id); + if (!marked) { + report.skipped.push({ episodeId: ep.id, reason: 'markPromoted failed (file not found)' }); + continue; + } + + if (opts.archiveMode === 'archive-file') { + // 파일 위치 찾아서 archive 디렉터리로 이동. + const moved = tryMoveEpisodeFileToArchive(ep.id, path.join(brainPath, 'memory', 'episodes'), archiveDir); + if (moved) report.archivedCount++; + } + report.promotedCount++; + } catch (e: any) { + report.skipped.push({ episodeId: ep.id, reason: e?.message || String(e) }); + } + } + + report.durationMs = Date.now() - start; + return report; +} + +function tryMoveEpisodeFileToArchive(episodeId: string, episodeDir: string, archiveDir: string): boolean { + try { + const files = fs.readdirSync(episodeDir).filter((f) => f.endsWith('.json')); + for (const file of files) { + const full = path.join(episodeDir, file); + try { + const raw = fs.readFileSync(full, 'utf-8'); + const parsed = JSON.parse(raw) as EpisodicEntry; + if (parsed.id === episodeId) { + fs.renameSync(full, path.join(archiveDir, file)); + return true; + } + } catch { /* skip */ } + } + } catch { /* ignore */ } + return false; +} + +/** + * Distillation 마지막 실행 시각을 저장·조회 — 자동 트리거가 *너무 자주* 안 돌도록. + * brainPath 의 marker 파일 사용 (vscode.globalState 안 쓰는 이유: 메모리 인프라가 + * BrainProfile-scoped 라 brain 디렉터리에 두는 게 일관성 있음). + */ +const MARKER_FILE = 'distillation_last_run.json'; + +export interface DistillationMarker { + timestamp: number; + report?: Partial; +} + +export function getLastDistillationRun(brainPath: string): DistillationMarker | null { + try { + const fp = path.join(brainPath, 'memory', MARKER_FILE); + if (!fs.existsSync(fp)) return null; + return JSON.parse(fs.readFileSync(fp, 'utf-8')) as DistillationMarker; + } catch { return null; } +} + +export function recordDistillationRun(brainPath: string, report: DistillationReport): void { + try { + const dir = path.join(brainPath, 'memory'); + fs.mkdirSync(dir, { recursive: true }); + const marker: DistillationMarker = { + timestamp: Date.now(), + report: { + candidateCount: report.candidateCount, + promotedCount: report.promotedCount, + archivedCount: report.archivedCount, + durationMs: report.durationMs, + }, + }; + fs.writeFileSync(path.join(dir, MARKER_FILE), JSON.stringify(marker, null, 2), 'utf-8'); + } catch { /* ignore */ } +} + +/** 자동 트리거 게이트 — 마지막 실행 후 N일 경과 시 true. */ +export function shouldAutoDistill(brainPath: string, intervalDays: number): boolean { + const last = getLastDistillationRun(brainPath); + if (!last) return true; + const elapsed = (Date.now() - last.timestamp) / (1000 * 60 * 60 * 24); + return elapsed >= intervalDays; +} diff --git a/src/memory/index.ts b/src/memory/index.ts index 3f0bc32..db5066a 100644 --- a/src/memory/index.ts +++ b/src/memory/index.ts @@ -20,6 +20,12 @@ import { ProceduralMemory } from './ProceduralMemory'; import { EpisodicMemory } from './EpisodicMemory'; import { MemoryExtractor } from './MemoryExtractor'; import { MemoryContextResult, MemoryConfig } from './types'; +import { + distillStaleEpisodes, + shouldAutoDistill, + recordDistillationRun, + type DistillationArchiveMode, +} from './distillation'; export { ShortTermMemory } from './ShortTermMemory'; export { LongTermMemory } from './LongTermMemory'; @@ -27,6 +33,17 @@ export { ProjectMemory } from './ProjectMemory'; export { ProceduralMemory } from './ProceduralMemory'; export { EpisodicMemory } from './EpisodicMemory'; export { MemoryExtractor } from './MemoryExtractor'; +export { + distillStaleEpisodes, + getLastDistillationRun, + recordDistillationRun, + shouldAutoDistill, + DEFAULT_DISTILLATION_OPTIONS, + type DistillationOptions, + type DistillationReport, + type DistillationMarker, + type DistillationArchiveMode, +} from './distillation'; export * from './types'; export class MemoryManager { @@ -132,11 +149,18 @@ export class MemoryManager { public onSessionEnd( sessionId: string, messages: Array<{ role: string; content: string; timestamp?: number }>, - workspacePath?: string + workspacePath?: string, + distillationOpts?: { + enabled: boolean; + ageThresholdDays: number; + intervalDays: number; + archiveMode: DistillationArchiveMode; + brainPath: string; + }, ): void { if (!this.config.enabled) return; - const projectMemory = workspacePath + const projectMemory = workspacePath ? this.getProjectMemory(workspacePath) : null; @@ -153,6 +177,19 @@ export class MemoryManager { // Persist long-term memory this.longTerm.save(); + + // Auto-distillation — Distillation Loop 가 enabled 이고 interval 충족 시 stale + // episodes 를 LongTerm digest 로 승급. 세션 종료 시점이 자연스러움 — 사용자가 + // 다음 세션 시작 전 한 번 cleanup. + if (distillationOpts?.enabled && shouldAutoDistill(distillationOpts.brainPath, distillationOpts.intervalDays)) { + try { + const report = distillStaleEpisodes(this.episodic, this.longTerm, distillationOpts.brainPath, { + ageThresholdDays: distillationOpts.ageThresholdDays, + archiveMode: distillationOpts.archiveMode, + }); + recordDistillationRun(distillationOpts.brainPath, report); + } catch { /* distillation should never break session end */ } + } } // ─── Direct Access (for UI & advanced features) ─── diff --git a/src/memory/types.ts b/src/memory/types.ts index 229373f..5248a4d 100644 --- a/src/memory/types.ts +++ b/src/memory/types.ts @@ -28,7 +28,12 @@ export interface ShortTermMessage { // ─── ② Long-Term Memory ─── -export type LongTermCategory = 'preference' | 'rule' | 'decision' | 'goal'; +/** + * Long-term category. + * - 'episode-digest' 는 Distillation Loop 가 stale episodic memory 를 long-term 으로 + * 승급시킬 때 사용. 사용자가 직접 만드는 'decision' / 'rule' 등과 시각적으로 구분. + */ +export type LongTermCategory = 'preference' | 'rule' | 'decision' | 'goal' | 'episode-digest'; export interface LongTermEntry { id: string; @@ -39,6 +44,14 @@ export interface LongTermEntry { createdAt: number; lastReferencedAt: number; referenceCount: number; + /** + * Temporal marker — 이 사실이 *유효한 마지막 시점* (epoch ms). + * 검색·context build 단계에서 expiresAt < now 인 entry 는 자동 제외. + * undefined 면 영구 유효 (legacy 동작). + * + * 사용 예: "Q3 2026 마케팅 계획은 9월 30일까지만 유효" → expiresAt = 2026-09-30 epoch. + */ + expiresAt?: number; } export interface LongTermStore { @@ -105,6 +118,18 @@ export interface EpisodicEntry { timestamp: number; duration: number; // 세션 길이 (ms) messageCount: number; + /** + * Temporal marker — 에피소드의 *유효 마지막 시점* (epoch ms). 검색에서 자동 제외. + * undefined 면 영구 (Distillation 이 archive 할 때까지). + */ + expiresAt?: number; + /** + * Distillation Loop 가 이 episode 를 LongTerm digest 로 promote 했음을 표시. + * promoted=true 면 검색·context build 에서 제외 (LongTerm 에 digest 가 있으니). + */ + promoted?: boolean; + /** promoted 인 경우 — 생성된 LongTerm digest entry id (역참조용). */ + promotedToLongTermId?: string; } export interface EpisodicStore { diff --git a/src/retrieval/actionabilityScoring.ts b/src/retrieval/actionabilityScoring.ts new file mode 100644 index 0000000..cef52c8 --- /dev/null +++ b/src/retrieval/actionabilityScoring.ts @@ -0,0 +1,156 @@ +/** + * Actionability Scoring — 검색 결과를 "현재 작업 상태" 신호로 재가중. + * + * 기존 TF-IDF (단어 매칭) + recency (시간) 만으로는 "지금 이 사용자가 하고 있는 + * 작업과 *직접 연결* 된 문서" 가 우선되지 않음. 예: 사용자가 `/runway` 명령을 + * 막 실행했다면 runway / 재무 관련 문서가 같은 키워드 매치 점수여도 더 위로 와야 함. + * + * v1 신호 (사용자 선택): + * 1. **최근 슬래시 명령** — 마지막 N개 실행된 슬래시 명령 이름을 키워드로 활용 + * → 명령 이름이 chunk title/content 에 포함되면 boost + * 2. **열린 파일 경로** — VS Code 활성 에디터의 파일 이름·확장자·부모 디렉터리를 + * 키워드로 활용 + * + * 점수 결합: TF-IDF normalized score (0~1) × (1 + actionabilityScore × weight) + * - weight 기본 0.3 → actionability=1.0 인 chunk 는 30% boost + * - actionability=0.0 인 chunk 는 변화 없음 + * - TF-IDF 가 여전히 dominant 인 보수적 합산 + * + * 향후 신호 (#1 v2 후보 — 사용자 선택 안 함): + * 3. 최근 7일 Chronicle ADR / decisions + * 4. 최근 24시간 customers/hire/runway 이벤트 + */ + +import * as path from 'path'; +import * as vscode from 'vscode'; +import { RetrievalChunk } from './types'; + +export interface WorkStateSignals { + /** 최근 실행된 슬래시 명령 이름 목록 ('/' 포함, 최신 순). 빈 배열이면 신호 없음. */ + recentSlashCommands: string[]; + /** VS Code 활성 에디터의 파일 절대 경로. undefined 면 신호 없음. */ + openFilePath?: string; +} + +export interface ActionabilityWeights { + /** 슬래시 명령 매치당 boost. 기본 0.30. */ + slashCommandMatch: number; + /** 파일명 매치 boost. 기본 0.40 (가장 강함). */ + openFileNameMatch: number; + /** 부모 디렉터리 매치 boost. 기본 0.20. */ + openFileParentDirMatch: number; + /** 확장자 의미 매치 boost (e.g. .ts → typescript/tsx 단어). 기본 0.10. */ + openFileExtMatch: number; + /** 최종 결합 가중치 — finalScore = base × (1 + actionability × this). 기본 0.30. */ + combinedWeight: number; +} + +export const DEFAULT_ACTIONABILITY_WEIGHTS: ActionabilityWeights = { + slashCommandMatch: 0.30, + openFileNameMatch: 0.40, + openFileParentDirMatch: 0.20, + openFileExtMatch: 0.10, + combinedWeight: 0.30, +}; + +/** VS Code 활성 에디터·최근 슬래시 명령에서 work-state 신호 캡처. */ +export function captureWorkStateSignals(recentSlashCommands: string[]): WorkStateSignals { + const editor = vscode.window.activeTextEditor; + return { + recentSlashCommands: recentSlashCommands.slice(0, 5), // 최신 5개로 cap + openFilePath: editor?.document.uri.fsPath, + }; +} + +const EXTENSION_KEYWORDS: Record = { + '.ts': /\b(typescript|tsx?|ts)\b/i, + '.tsx': /\b(typescript|tsx|react)\b/i, + '.js': /\b(javascript|jsx?)\b/i, + '.jsx': /\b(javascript|jsx|react)\b/i, + '.py': /\b(python|py)\b/i, + '.md': /\b(markdown|md|문서)\b/i, + '.json': /\b(json|config)\b/i, + '.go': /\b(golang|go)\b/i, + '.rs': /\b(rust|rs)\b/i, +}; + +/** + * 한 chunk 의 actionability 점수 계산 — 0.0 ~ 1.0 (cap). + * 매치 boost 들의 단순 합산 후 1.0 cap. + */ +export function computeActionabilityScore( + chunk: RetrievalChunk, + signals: WorkStateSignals, + weights: ActionabilityWeights = DEFAULT_ACTIONABILITY_WEIGHTS, +): number { + if (!chunk) return 0; + const haystack = ((chunk.title || '') + ' ' + (chunk.content || '')).toLowerCase(); + if (!haystack.trim()) return 0; + + let score = 0; + + // Signal 1: 최근 슬래시 명령 — '/runway' → 'runway' 키워드 매치 + for (const cmd of signals.recentSlashCommands) { + const kw = cmd.replace(/^\//, '').toLowerCase().trim(); + if (kw.length < 3) continue; // 너무 짧으면 노이즈 (/q 등) + // 단어 경계 매치 — substring 이 아닌 단어 단위 (영문 한정, 한글은 substring) + const isAscii = /^[a-z0-9-]+$/.test(kw); + const wordRe = isAscii ? new RegExp(`\\b${escapeRegex(kw)}\\b`, 'i') : null; + if (wordRe ? wordRe.test(haystack) : haystack.includes(kw)) { + score += weights.slashCommandMatch; + } + } + + // Signal 2: 열린 파일 — 파일명·부모 디렉터리·확장자 + if (signals.openFilePath) { + const fp = signals.openFilePath; + const ext = path.extname(fp).toLowerCase(); + const base = path.basename(fp, ext).toLowerCase(); + const parent = path.basename(path.dirname(fp)).toLowerCase(); + + // 파일 자체가 chunk 의 filePath 와 같으면 강한 boost (max) + const chunkFile = chunk.metadata?.filePath?.toLowerCase(); + if (chunkFile && chunkFile === fp.toLowerCase()) { + score += weights.openFileNameMatch * 1.5; // exact file = 보너스 + } else if (base.length >= 3) { + const baseRe = new RegExp(`\\b${escapeRegex(base)}\\b`, 'i'); + if (baseRe.test(haystack)) score += weights.openFileNameMatch; + } + + if (parent.length >= 3 && parent !== 'src' && parent !== 'lib') { + // 'src' / 'lib' 같은 일반 디렉터리는 신호로서 약함 — 제외 + const parentRe = new RegExp(`\\b${escapeRegex(parent)}\\b`, 'i'); + if (parentRe.test(haystack)) score += weights.openFileParentDirMatch; + } + + const extRe = EXTENSION_KEYWORDS[ext]; + if (extRe && extRe.test(haystack)) score += weights.openFileExtMatch; + } + + return Math.min(score, 1.0); +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Chunks 배열의 score 를 actionability 로 재가중. *원본 score 를 덮어씀* — 호출자는 + * 반드시 retrieval pipeline 안에서 normalizeScores 직후, selectWithinBudget 직전에만 호출. + * + * 각 chunk 에 actionabilityScore 를 metadata 에 기록 (디버깅·UI 표시 용도). + */ +export function applyActionabilityBoost( + chunks: RetrievalChunk[], + signals: WorkStateSignals, + weights: ActionabilityWeights = DEFAULT_ACTIONABILITY_WEIGHTS, +): void { + if (!signals || (signals.recentSlashCommands.length === 0 && !signals.openFilePath)) return; + for (const c of chunks) { + const a = computeActionabilityScore(c, signals, weights); + if (a > 0) { + (c.metadata as any).actionabilityScore = a; + c.score = c.score * (1 + a * weights.combinedWeight); + } + } +} diff --git a/src/retrieval/citationTrace.ts b/src/retrieval/citationTrace.ts new file mode 100644 index 0000000..fdfc645 --- /dev/null +++ b/src/retrieval/citationTrace.ts @@ -0,0 +1,49 @@ +/** + * Citation Trace — 답변 *끝* 에 "출처:" 한 줄 명시 지시. + * + * CoVe Strict 모드 (v2.2.184) 와 차이: + * - CoVe Strict: 모든 사실 주장 뒤에 inline `[S1]` 인용 강제 — verbose, 학술적 + * - Citation Trace: 답변 끝에 *사용된 출처* 한 줄 정리 — 가벼움, 항상 ON 권장 + * + * 둘은 함께 동작 가능. CoVe 가 [S1]..[SN] 라벨을 system prompt 에 노출하면, + * Citation Trace 는 LLM 에게 "그 라벨들 중 답변에 *실제로 사용된* 것을 끝에 한 줄 + * 정리" 라고 지시. + * + * 효과: 사용자가 답변 검증 가능 — "이 답변이 어느 출처에 기반했나" 명시. + * 할루시네이션 억제 — LLM 이 출처 없는 주장 줄임. + * + * 비용: 시스템 프롬프트 ~10줄 추가. LLM 출력에 1줄 추가. + */ + +import { RetrievalChunk } from './types'; + +export interface CitationTraceOptions { + /** 답변 끝 *출처 한 줄* 형식. 'tail' 만 v1 지원. */ + format: 'tail'; +} + +/** + * Citation Trace 블록 — chunks 가 *있어야* 의미 있으니 비어 있으면 빈 문자열. + * Casual conversation 모드는 호출자가 미리 걸러야. + */ +export function buildCitationTraceBlock( + chunks: RetrievalChunk[], + options: Partial = {}, +): string { + if (!chunks || chunks.length === 0) return ''; + + const lines: string[] = []; + lines.push('[CITATION TRACE]'); + lines.push('답변에서 *검색된 출처를 사용했다면*, 답변 끝에 다음 형식으로 *한 줄* 정리:'); + lines.push(''); + lines.push('*출처:* `파일명.md` · `chunk-title` · `chunk-title2`'); + lines.push(''); + lines.push('[규칙]'); + lines.push('1. 실제 답변 작성에 *사용한* 출처만 나열. 검색됐지만 안 쓴 출처는 제외.'); + lines.push('2. 출처 라벨은 파일명(있으면) 또는 chunk title 그대로 — 임의 변형 금지.'); + lines.push('3. 일반 모델 지식만 사용했다면: *출처: 모델 지식 (검색 출처 미사용)*'); + lines.push('4. 답변이 검증 가능하도록 — 사용자가 그 파일을 열면 답변 근거를 확인할 수 있어야.'); + lines.push('5. *출처:* 라인은 답변 *맨 끝* 한 번만 — 본문 중간에 흩어 놓지 말 것.'); + lines.push('[/CITATION TRACE]'); + return lines.join('\n'); +} diff --git a/src/retrieval/conflictBlock.ts b/src/retrieval/conflictBlock.ts new file mode 100644 index 0000000..2e2da62 --- /dev/null +++ b/src/retrieval/conflictBlock.ts @@ -0,0 +1,204 @@ +/** + * Conflict Surface — [CONFLICT WARNINGS] 시스템 프롬프트 블록 생성. + * + * 기존 scoring.ts 가 문서당 conflictSeverity(NONE/LOW/MEDIUM/HIGH) 를 *이미* + * 계산하지만(반대/논란/vs 등 indicator 단어 카운트), LLM 은 그 사실을 모름. + * buildAstraModeSystemPrompt 의 v4 정책 텍스트는 이미 "[CONFLICT WARNING] 플래그" + * 를 *언급*하나, 실제 어떤 문서가 충돌인지 LLM 에게 *전달되지 않음* — 정책이 + * 명시되어 있지만 데이터가 없어 무용한 상태. + * + * 이 모듈이 그 갭을 메움: + * 1. 자기-신호(self-flag) — chunk.metadata.conflictSeverity ≥ threshold + * 2. 교차-문서 발산(cross-divergence) — 같은 주제 2 chunks, Jaccard < 임계 + * + * 둘을 합쳐 마크다운 블록 한 개로. 결과가 비면 빈 문자열 반환 — 호출자가 + * 안전하게 무조건 join 가능. + */ + +import { RetrievalChunk, ConflictSeverity } from './types'; +import { tokenize } from './scoring'; + +/** 사용자 설정 임계값. 'low' = LOW 부터, 'medium' = MEDIUM 부터, 'high' = HIGH 만. */ +export type ConflictThresholdSetting = 'low' | 'medium' | 'high'; + +export interface ConflictBlockOptions { + /** 자기-신호 surface 시 최소 severity. 기본 'medium'. */ + selfFlagThreshold: ConflictThresholdSetting; + /** 교차 발산 감지 enable. 기본 true. */ + crossDivergenceEnabled: boolean; + /** 자기-신호 / 교차 발산 각각 표시 최대 건수. 기본 5. */ + maxPerSection: number; + /** Chunk 미리보기 길이. 기본 220 chars. */ + excerptLength: number; +} + +const DEFAULT_OPTIONS: ConflictBlockOptions = { + selfFlagThreshold: 'medium', + crossDivergenceEnabled: true, + maxPerSection: 5, + excerptLength: 220, +}; + +function severityRank(s: ConflictSeverity | undefined): number { + switch (s) { + case 'HIGH': return 3; + case 'MEDIUM': return 2; + case 'LOW': return 1; + default: return 0; + } +} + +function thresholdRank(t: ConflictThresholdSetting): number { + switch (t) { + case 'high': return 3; + case 'medium': return 2; + case 'low': return 1; + } +} + +function severityEmoji(s: ConflictSeverity | undefined): string { + switch (s) { + case 'HIGH': return '🔴'; + case 'MEDIUM': return '🟡'; + case 'LOW': return '🟠'; + default: return '⚪'; + } +} + +function shortExcerpt(text: string, n: number): string { + if (!text) return ''; + const cleaned = text.replace(/\s+/g, ' ').trim(); + return cleaned.length <= n ? cleaned : cleaned.slice(0, n) + '…'; +} + +/** 두 토큰 집합의 Jaccard 유사도. */ +function jaccard(a: Set, b: Set): number { + if (a.size === 0 || b.size === 0) return 0; + let intersect = 0; + for (const t of a) if (b.has(t)) intersect++; + const union = a.size + b.size - intersect; + return union === 0 ? 0 : intersect / union; +} + +/** + * 교차-문서 발산 후보 쌍 찾기. + * + * 휴리스틱: + * 1. 각 chunk 의 title 토큰(최대 5개) 으로 "주제 키" 생성 + * 2. 동일 주제 키 2개 이상 공유하는 chunk 쌍을 후보로 + * 3. 본문 토큰 Jaccard < 0.30 이면 발산으로 판정 (같은 주제 다른 내용) + * 4. 점수 = (공유 토픽 토큰 수) × (1 - Jaccard) — 발산이 클수록 우선 + * + * 한 chunk 가 여러 쌍에 등장 가능 — 상위 N 쌍만 반환. + */ +interface DivergencePair { + a: RetrievalChunk; + b: RetrievalChunk; + sharedTopicTokens: string[]; + contentJaccard: number; + score: number; +} + +function findCrossDivergence(chunks: RetrievalChunk[], topicJaccardMax: number = 0.30): DivergencePair[] { + if (chunks.length < 2) return []; + + // Pre-compute title topic tokens + content token sets — n^2 비교 전에 한 번만. + const titleTokenSets: Set[] = []; + const contentTokenSets: Set[] = []; + for (const c of chunks) { + const titleTokens = tokenize(c.title || '').filter((t) => t.length >= 2); + titleTokenSets.push(new Set(titleTokens.slice(0, 8))); + contentTokenSets.push(new Set(tokenize(c.content || ''))); + } + + const pairs: DivergencePair[] = []; + for (let i = 0; i < chunks.length; i++) { + for (let j = i + 1; j < chunks.length; j++) { + // 1. 같은 주제 — title 토큰 공유 ≥ 2 + const shared: string[] = []; + for (const t of titleTokenSets[i]) if (titleTokenSets[j].has(t)) shared.push(t); + if (shared.length < 2) continue; + + // 2. 본문 발산 — Jaccard < 임계 + const cj = jaccard(contentTokenSets[i], contentTokenSets[j]); + if (cj >= topicJaccardMax) continue; + + pairs.push({ + a: chunks[i], + b: chunks[j], + sharedTopicTokens: shared, + contentJaccard: cj, + score: shared.length * (1 - cj), + }); + } + } + pairs.sort((p, q) => q.score - p.score); + return pairs; +} + +/** + * 시스템 프롬프트용 [CONFLICT WARNINGS] 블록 생성. 충돌 없으면 빈 문자열 반환. + * + * 호출 측은 무조건 join 해도 안전 — 빈 문자열이면 프롬프트에 추가 줄바꿈 없음. + */ +export function buildConflictWarningsBlock( + chunks: RetrievalChunk[], + options: Partial = {}, +): string { + const opts: ConflictBlockOptions = { ...DEFAULT_OPTIONS, ...options }; + if (!chunks || chunks.length === 0) return ''; + + // ─── Section 1: self-flag ─── + const threshold = thresholdRank(opts.selfFlagThreshold); + const selfFlagged = chunks + .filter((c) => severityRank(c.metadata?.conflictSeverity) >= threshold) + .sort((a, b) => severityRank(b.metadata?.conflictSeverity) - severityRank(a.metadata?.conflictSeverity)) + .slice(0, opts.maxPerSection); + + // ─── Section 2: cross-doc divergence ─── + const divergence = opts.crossDivergenceEnabled + ? findCrossDivergence(chunks).slice(0, opts.maxPerSection) + : []; + + if (selfFlagged.length === 0 && divergence.length === 0) return ''; + + const lines: string[] = []; + lines.push('[CONFLICT WARNINGS]'); + lines.push('다음 검색된 출처에서 충돌 신호 감지. 단일 결론을 강요하지 말고, 상충되는 관점을 명시하고 사용자 판단에 위임할 것.'); + lines.push(''); + + if (selfFlagged.length > 0) { + lines.push('## 자기-신호 (출처 내부에서 충돌/논란 키워드 감지)'); + for (const c of selfFlagged) { + const sev = c.metadata?.conflictSeverity || 'NONE'; + const emoji = severityEmoji(sev); + const src = c.source; + const title = c.title || '(제목 없음)'; + lines.push(`- ${emoji} **[${sev}]** \`${src}\` · ${title}`); + lines.push(` > ${shortExcerpt(c.content, opts.excerptLength)}`); + } + lines.push(''); + } + + if (divergence.length > 0) { + lines.push('## 교차-문서 발산 (같은 주제·다른 내용 ─ 잠재적 모순)'); + for (const p of divergence) { + const topics = p.sharedTopicTokens.slice(0, 5).join(' · '); + const cjPct = (p.contentJaccard * 100).toFixed(0); + lines.push(`- 🔀 **공유 주제**: ${topics} _(본문 중복 ${cjPct}%)_`); + lines.push(` - A: \`${p.a.source}\` · ${p.a.title || '(제목 없음)'}`); + lines.push(` > ${shortExcerpt(p.a.content, opts.excerptLength)}`); + lines.push(` - B: \`${p.b.source}\` · ${p.b.title || '(제목 없음)'}`); + lines.push(` > ${shortExcerpt(p.b.content, opts.excerptLength)}`); + } + lines.push(''); + } + + lines.push('[지침]'); + lines.push('1. 답변에 위 출처 중 하나라도 사용한다면, 충돌 가능성을 명시 (예: "출처 A 는 X 라 하나 출처 B 는 Y").'); + lines.push('2. 어느 쪽이 옳다고 단정하지 말고, 사용자가 판단할 수 있도록 근거를 분리해 제시.'); + lines.push('3. 충돌이 답변과 무관하면 무시 가능 — 다만 무관 판단 자체도 한 줄로 기록.'); + lines.push('[/CONFLICT WARNINGS]'); + + return lines.join('\n'); +} diff --git a/src/retrieval/coveBlock.ts b/src/retrieval/coveBlock.ts new file mode 100644 index 0000000..ff61d1a --- /dev/null +++ b/src/retrieval/coveBlock.ts @@ -0,0 +1,121 @@ +/** + * Chain-of-Verification (CoVe) — [VERIFICATION CHECKLIST] 시스템 프롬프트 블록 생성. + * + * 배경: 사용자 피드백 "추론 결과가 나오기 직전, 이 결론이 확보된 지식(제2뇌)에만 + * 근거하고 있는가? 를 스스로 질문하고 검증하는 로직" — 할루시네이션 방지 + 그라운딩 + * 명확화. + * + * 원논문 CoVe 는 2-pass (draft → verify → revise). 하지만 ASTRA 는 local-first 라 + * 추가 LLM 호출 비용이 크고, 같은 모델이 self-verify 하는 효과도 제한적. 그래서 + * v1 은 *instructional* CoVe — 시스템 프롬프트에 명시적 검증 체크리스트를 주입해 + * 모델이 한 패스 안에서 "답변 작성 전" 그라운딩 점검을 *내재화* 하도록. + * + * 향후 strict 모드에서 두 번째 verification pass 추가 가능 (config knob 준비). + * + * Conflict Surface 와의 관계: [CONFLICT WARNINGS] 가 "충돌 출처 데이터" 제공, + * 이 CoVe 블록이 "그 데이터를 어떻게 verify 할지" 지시. 둘은 서로 보완. + */ + +import { RetrievalChunk } from './types'; + +export interface CoveBlockOptions { + /** 체크리스트에 나열할 최상위 출처 수. 기본 5. */ + topSourcesCount: number; + /** + * Strict 모드 — 켜면 LLM 에게 *모든 주장에 출처 ID 를 inline 으로 인용*하라고 지시. + * 끄면 일반 가이드만. 기본 off (자연스러운 답변 유지). + */ + strictMode: boolean; + /** 출처 미리보기 길이. 기본 140 chars. */ + excerptLength: number; + /** 사용자 query 일부를 체크리스트에 echo 할지. 기본 true — 모델이 vague answer 방지. */ + echoQuery: boolean; + /** Query echo 최대 길이. 기본 180. */ + queryEchoMaxLength: number; +} + +const DEFAULT_OPTIONS: CoveBlockOptions = { + topSourcesCount: 5, + strictMode: false, + excerptLength: 140, + echoQuery: true, + queryEchoMaxLength: 180, +}; + +function shortExcerpt(text: string, n: number): string { + if (!text) return ''; + const cleaned = text.replace(/\s+/g, ' ').trim(); + return cleaned.length <= n ? cleaned : cleaned.slice(0, n) + '…'; +} + +/** + * CoVe 블록 생성. 검색된 chunks 가 없으면 빈 문자열 — 그라운딩할 출처가 없는 상태에서 + * CoVe 를 강요하면 모델이 "출처 없음" 으로 답변 거부할 수 있음. 단, 사용자 query 가 + * 사실 검증 류일 때만 의미가 있으므로 호출자가 enable/disable 결정 가능. + */ +export function buildCoveChecklistBlock( + chunks: RetrievalChunk[], + userPrompt: string, + options: Partial = {}, +): string { + const opts: CoveBlockOptions = { ...DEFAULT_OPTIONS, ...options }; + if (!chunks || chunks.length === 0) return ''; + + // 점수 순 상위 N — 다양한 source 가 섞이도록 source 별로 1개씩 round-robin 도 고려했으나, + // CoVe 는 *근거 강한 출처* 가 더 중요해서 score 단순 정렬 채택. + const top = chunks + .filter((c) => c.source !== 'brain-trace') // brain-trace 는 trace 표시용, 본문 없음 + .sort((a, b) => b.score - a.score) + .slice(0, opts.topSourcesCount); + + if (top.length === 0) return ''; + + const lines: string[] = []; + lines.push('[VERIFICATION CHECKLIST — Chain-of-Verification]'); + lines.push('답변을 *작성하기 전* 다음을 점검. 검증 통과한 주장만 답변에 포함할 것.'); + + if (opts.echoQuery && userPrompt && userPrompt.trim()) { + const q = userPrompt.replace(/\s+/g, ' ').trim(); + const echo = q.length > opts.queryEchoMaxLength ? q.slice(0, opts.queryEchoMaxLength) + '…' : q; + lines.push(''); + lines.push(`> **사용자 질의**: ${echo}`); + } + + // ─── Section 1: 근거 매핑 ─── + lines.push(''); + lines.push('## 1. 근거 매핑 (Grounding Inventory)'); + lines.push('이 답변의 핵심 주장 각각이 *어느 출처* 에서 왔는지 명시 가능한가?'); + lines.push(''); + for (let i = 0; i < top.length; i++) { + const c = top[i]; + const scoreFmt = c.score.toFixed(2); + const sev = c.metadata?.conflictSeverity && c.metadata.conflictSeverity !== 'NONE' + ? ` ⚠️${c.metadata.conflictSeverity}` : ''; + lines.push(`- **[S${i + 1}]** \`${c.source}\` · ${c.title || '(제목 없음)'} _(score ${scoreFmt})_${sev}`); + lines.push(` > ${shortExcerpt(c.content, opts.excerptLength)}`); + } + lines.push(''); + lines.push('출처 미매핑(=어느 S 도 직접 지지하지 않음) 주장은 *모델 일반 지식*. 그 사실을 답변에 명시.'); + + // ─── Section 2: 자기 질문 ─── + lines.push(''); + lines.push('## 2. 답변 직전 자기 질문 (Pre-Output Self-Check)'); + lines.push('답변 보내기 전 *반드시* 답하라:'); + lines.push('- (a) 이 답변의 결론이 위 [S1..SN] 중 어디에 직접 근거하나? 매핑 안 되는 결론 = 일반 지식 → 명시.'); + lines.push('- (b) "확실하다", "반드시", "이미 결정됨" 같은 단정적 표현을 쓴다면 출처가 그 강도를 지지하는가? 아니면 톤 완화.'); + lines.push('- (c) 사용자에게 다음 *구체적 액션* 을 제시했는가, 아니면 추상적 조언만 했는가?'); + lines.push('- (d) [CONFLICT WARNINGS] 블록과 결합 — 충돌 출처 사용 시 양측 명시했는가?'); + + // ─── Section 3: Strict 모드 (옵션) ─── + if (opts.strictMode) { + lines.push(''); + lines.push('## 3. ⚙️ STRICT 모드 — Inline Citation 강제'); + lines.push('각 사실 주장 뒤에 `[S1]`, `[S2]` 형식으로 출처 ID 를 *반드시* 인용. 인용 없으면 모델 지식으로 간주되어 답변 신뢰도 감점.'); + lines.push('예) "큐브앤코는 enterprise 요금제다 [S2]." / "일반적으로 SaaS B2B 는 ~ (모델 지식, 직접 출처 없음)."'); + } + + lines.push(''); + lines.push('[/VERIFICATION CHECKLIST]'); + + return lines.join('\n'); +} diff --git a/src/retrieval/hierarchicalLevel.ts b/src/retrieval/hierarchicalLevel.ts new file mode 100644 index 0000000..a22af20 --- /dev/null +++ b/src/retrieval/hierarchicalLevel.ts @@ -0,0 +1,135 @@ +/** + * Hierarchical Context Window — 질의·문서의 *추상도 레벨* 매칭으로 검색 노이즈 감소. + * + * 사용자 제안: "사용자가 '배포해줘' 라고 하면 L1(실행) 우선, '전략 검토' 라고 하면 + * L3(전략) 우선". 같은 키워드 매치 점수여도 추상도가 안 맞으면 noise. + * + * v1 — 3-level 휴리스틱 (LLM 호출 없음, 결정적): + * - `concrete` — 코드, 로그, 디버그, 실행 명령 + * - `operational` — 작업, 일정, 운영 절차, 회의록 (기본/기본값) + * - `strategic` — 전략, 비전, 의사결정 근거, 아키텍처 방향 + * + * 매칭 정책: + * - 같은 레벨 → 보너스 (× 1.15) + * - 인접 레벨 (concrete↔operational, operational↔strategic) → 변화 없음 + * - 양 끝 mismatch (concrete↔strategic) → 페널티 (× 0.7) + * + * 약한 시그널 — TF-IDF dominant 유지, 동점 깨기 역할. 검색 결과를 *제외* 하지 않음. + */ + +import { RetrievalChunk } from './types'; + +export type AbstractionLevel = 'concrete' | 'operational' | 'strategic'; + +const QUERY_STRATEGIC_KEYWORDS = [ + '전략', '방향', '비전', '미션', '목표', '의사결정', '아키텍처', '설계 방향', + '왜 이렇게', '왜 그렇게', '뭐가 맞', '어떤 게 좋', '어떻게 가야', '어떤 방향', + '판단', '결정', '관점', '평가', '검토', + 'strategy', 'vision', 'mission', 'roadmap', 'direction', 'goal', + 'why', 'rationale', 'pros and cons', 'tradeoff', 'evaluate', +]; + +const QUERY_CONCRETE_KEYWORDS = [ + '코드', '함수', '버그', '에러', '로그', '실행', '명령어', '스크립트', '디버그', + '고쳐', '수정', '리팩토링', '리팩터', '커밋', '머지', '배포해', '돌려', + '에러 메시지', '스택 트레이스', 'syntax', 'compile', + 'code', 'function', 'bug', 'error', 'log', 'execute', 'command', 'script', + 'debug', 'fix', 'refactor', 'commit', 'merge', 'deploy', 'run', +]; + +const FOLDER_STRATEGIC_HINTS = ['strategy', 'vision', 'mission', 'roadmap', 'decision', 'principle', '전략', '비전']; +const FOLDER_OPERATIONAL_HINTS = ['playbook', 'runbook', 'operation', 'process', 'sop', '운영', '절차', 'meeting', '회의']; +const FOLDER_CONCRETE_HINTS = ['code', 'snippet', 'log', 'debug', 'fix', 'patch', '디버그', 'commit']; + +const TITLE_STRATEGIC_HINTS = ['strategy', 'vision', 'rationale', 'direction', 'decision', 'plan', '전략', '계획', '방향', '결정', '평가']; +const TITLE_CONCRETE_HINTS = ['fix', 'bug', 'error', 'log', 'script', 'command', '버그', '에러', '로그', '커밋']; + +function countMatches(text: string, keywords: string[]): number { + const lower = text.toLowerCase(); + let n = 0; + for (const k of keywords) if (lower.includes(k.toLowerCase())) n++; + return n; +} + +/** + * 질의 추상도 분류. 키워드 카운트 우열로 결정, 동률·없음이면 'operational' (기본). + */ +export function classifyQueryLevel(query: string): AbstractionLevel { + if (!query) return 'operational'; + const s = countMatches(query, QUERY_STRATEGIC_KEYWORDS); + const c = countMatches(query, QUERY_CONCRETE_KEYWORDS); + if (s > c && s >= 1) return 'strategic'; + if (c > s && c >= 1) return 'concrete'; + return 'operational'; +} + +/** + * 한 chunk 의 추상도 분류 — 폴더 경로 → 파일명/제목 → 본문 순으로 강도 감소. + * 어느 신호도 없으면 'operational' (기본). + */ +export function classifyChunkLevel(chunk: RetrievalChunk): AbstractionLevel { + // 1. 폴더 경로 (가장 강함) + const fp = (chunk.metadata?.filePath || '').toLowerCase(); + if (fp) { + for (const h of FOLDER_STRATEGIC_HINTS) if (fp.includes(`/${h}`) || fp.includes(`\\${h}`)) return 'strategic'; + for (const h of FOLDER_CONCRETE_HINTS) if (fp.includes(`/${h}`) || fp.includes(`\\${h}`)) return 'concrete'; + for (const h of FOLDER_OPERATIONAL_HINTS) if (fp.includes(`/${h}`) || fp.includes(`\\${h}`)) return 'operational'; + } + + // 2. 제목 + const t = (chunk.title || '').toLowerCase(); + if (t) { + let strat = 0, conc = 0; + for (const h of TITLE_STRATEGIC_HINTS) if (t.includes(h.toLowerCase())) strat++; + for (const h of TITLE_CONCRETE_HINTS) if (t.includes(h.toLowerCase())) conc++; + if (strat > conc && strat >= 1) return 'strategic'; + if (conc > strat && conc >= 1) return 'concrete'; + } + + return 'operational'; +} + +const LEVEL_INDEX: Record = { + concrete: 0, operational: 1, strategic: 2, +}; + +export interface HierarchicalWeights { + /** 같은 레벨 매치 multiplier. 기본 1.15. */ + sameLevelBonus: number; + /** 양 끝 mismatch (concrete↔strategic) multiplier. 기본 0.70. */ + farMismatchPenalty: number; +} + +export const DEFAULT_HIERARCHICAL_WEIGHTS: HierarchicalWeights = { + sameLevelBonus: 1.15, + farMismatchPenalty: 0.70, +}; + +/** + * 질의 레벨에 따라 chunks 의 score 를 hierarchical 매칭으로 재가중. in-place. + * metadata 에 분류 결과 기록 (debug/UI 노출). + */ +export function applyHierarchicalReweight( + chunks: RetrievalChunk[], + queryLevel: AbstractionLevel, + weights: HierarchicalWeights = DEFAULT_HIERARCHICAL_WEIGHTS, +): { sameLevel: number; farMismatch: number } { + let sameLevel = 0; + let farMismatch = 0; + const qi = LEVEL_INDEX[queryLevel]; + for (const c of chunks) { + const cl = classifyChunkLevel(c); + (c.metadata as any).abstractionLevel = cl; + const ci = LEVEL_INDEX[cl]; + const diff = Math.abs(qi - ci); + if (diff === 0) { + c.score *= weights.sameLevelBonus; + sameLevel++; + } else if (diff === 2) { + c.score *= weights.farMismatchPenalty; + farMismatch++; + } + // diff === 1: 인접 레벨 → 변화 없음 + } + return { sameLevel, farMismatch }; +} diff --git a/src/retrieval/index.ts b/src/retrieval/index.ts index 4ab5e2d..6f4c2ac 100644 --- a/src/retrieval/index.ts +++ b/src/retrieval/index.ts @@ -24,6 +24,8 @@ import { selectWithinBudget, assembleContext, estimateTokens } from './contextBu import { getBrainTokenIndex, getBrainEmbeddings } from './brainIndex'; import { extractLessonEssence } from './lessonHelpers'; import { cosineSimilarity } from './embeddings'; +import { applyActionabilityBoost, WorkStateSignals, ActionabilityWeights } from './actionabilityScoring'; +import { applyHierarchicalReweight, classifyQueryLevel, AbstractionLevel, HierarchicalWeights } from './hierarchicalLevel'; export { tokenize, expandQuery, scoreTfIdf, scoreTfIdfPreTokenized, extractBestExcerpt } from './scoring'; export { selectWithinBudget, assembleContext, estimateTokens } from './contextBudget'; @@ -81,6 +83,20 @@ interface RetrievalOptions { embeddingModel?: string; /** Blend weight: 0 = TF-IDF only, 1 = cosine only. Default 0.5. */ embeddingBlendAlpha?: number; + /** + * Actionability — "현재 작업 상태" 신호(최근 슬래시 명령 + 열린 파일) 로 검색 결과 재가중. + * undefined 면 actionability re-rank 안 함 (legacy 동작). + */ + workStateSignals?: WorkStateSignals; + /** Actionability 결합 가중치. undefined 면 default. */ + actionabilityWeights?: ActionabilityWeights; + /** + * Hierarchical Context Window — 질의·문서 추상도 매칭 재가중. + * true 면 query 추상도 분류 후 chunks 재가중. false / undefined 면 skip. + */ + hierarchicalReweightEnabled?: boolean; + /** Hierarchical 가중치 override. undefined 면 default. */ + hierarchicalWeights?: HierarchicalWeights; } export class RetrievalOrchestrator { @@ -148,6 +164,25 @@ export class RetrievalOrchestrator { this.normalizeScores(allChunks); fusionLog.push(`Total chunks before budget: ${allChunks.length}`); + // ── ③-b Actionability Re-rank — work-state 신호로 점수 boost ── + // normalize 직후, budget 전 — actionability 가 어떤 chunk 가 살아남는지에 영향. + if (options.workStateSignals) { + applyActionabilityBoost(allChunks, options.workStateSignals, options.actionabilityWeights); + const boosted = allChunks.filter((c) => (c.metadata as any).actionabilityScore > 0).length; + const cmds = options.workStateSignals.recentSlashCommands.slice(0, 3).join(','); + const openFile = options.workStateSignals.openFilePath ? path.basename(options.workStateSignals.openFilePath) : '-'; + fusionLog.push(`Actionability re-rank: ${boosted} chunks boosted (cmds=[${cmds}], openFile=${openFile})`); + } + + // ── ③-c Hierarchical Context Window — 추상도 레벨 매칭 ── + // 질의·문서 추상도 매칭 점수 조정. 같은 레벨 bonus, 양 끝 mismatch penalty. + // Actionability 직후 — 두 재가중을 합쳐 한 번의 budget selection. + if (options.hierarchicalReweightEnabled) { + const queryLevel = classifyQueryLevel(query); + const { sameLevel, farMismatch } = applyHierarchicalReweight(allChunks, queryLevel, options.hierarchicalWeights); + fusionLog.push(`Hierarchical re-rank (query=${queryLevel}): ${sameLevel} same-level (+), ${farMismatch} far-mismatch (-)`); + } + // ── ④ Context Budget Selection ── const { selected, dropped, tokensUsed } = selectWithinBudget( allChunks, diff --git a/src/retrieval/intentClarification.ts b/src/retrieval/intentClarification.ts new file mode 100644 index 0000000..5a8d15c --- /dev/null +++ b/src/retrieval/intentClarification.ts @@ -0,0 +1,141 @@ +/** + * Intent Clarification — 모호한 질의에서 *추측 답변 대신 질문 던지기* 지시. + * + * 사용자 피드백: "ASTRA 는 질문을 받으면 즉시 답변을 생성하려 함. 하지만 '날카로운' + * 파악을 위해서는 질문 뒤의 '실행 목적' 을 먼저 정의해야". 예: "배포해줘" → 환경 + * (dev/prod) / 태그 묻기. "그 부분 고쳐줘" → 어느 파일/모듈인지 묻기. + * + * 현재 ASTRA: 모호 감지 메커니즘 없음. CoVe(v2.2.184) 가 *답변 작성 시* 출처 매핑 + * 검증하지만, *질문 자체가 모호한 경우* 는 다루지 않음. 이 모듈이 그 갭. + * + * 설계: + * - 휴리스틱 차원(환경/대상/범위/포맷/마감) 별로 *trigger 키워드 + 명시 키워드* 정의 + * - trigger 가 있는데 명시가 없으면 missing + * - missing 차원이 strictness 임계 이상이면 ambiguous → 시스템 프롬프트에 질문 지시 + * + * 위험: false positive → 사용자가 "그냥 답해" 짜증. strictness 로 조절. + */ + +export type IntentStrictness = 'low' | 'medium' | 'high'; + +interface AmbiguityDimensionDef { + key: string; + label: string; // 한국어 표시명 + /** 이 차원이 *문제 되는 지* 판정하는 trigger 단어들 (있으면 의심 시작). */ + triggers: string[]; + /** 차원이 *명시* 됐다고 보는 단어들 (있으면 ambiguity 해소). */ + specifiers: string[]; + /** missing 일 때 사용자에게 권장 질문 예시. */ + suggestedQuestion: string; +} + +const DIMENSIONS: AmbiguityDimensionDef[] = [ + { + key: 'environment', + label: '환경 (dev/prod/staging)', + triggers: ['배포', '롤백', 'deploy', 'rollback', 'release', '릴리스', '릴리즈', '띄워', '재시작', 'restart'], + specifiers: ['dev', 'prod', 'staging', 'local', '로컬', '개발', '운영', '프로덕션', '스테이징', '본번', '본 번', '본번에', '운영에'], + suggestedQuestion: '어느 환경에 작업할지 (dev/prod/staging) 명시해 주실 수 있나요?', + }, + { + key: 'target', + label: '대상 (파일/모듈/멤버)', + triggers: ['고쳐', '고처', '수정', '바꿔', '추가', '제거', '리팩토', '리팩터', '리팩터링', '리팩토링', '개선', '정리', '리뷰', '검토해'], + specifiers: ['.ts', '.tsx', '.js', '.py', '.md', '.json', '.go', '.rs', '파일', '함수', '클래스', '모듈', '@', 'src/', 'lib/', 'features/', '폴더'], + suggestedQuestion: '어느 파일/모듈/함수를 대상으로 할지 명시해 주실 수 있나요?', + }, + { + key: 'scope', + label: '범위 (전체/부분)', + triggers: ['리팩토', '리팩터', '리팩터링', '리팩토링', '정리해', '개선', '최적화', '튜닝', '청소', '정비'], + specifiers: ['전체', '전부', '모두', '일부', '특정', '하나만', '이것만', '여기만', '단', '단지'], + suggestedQuestion: '범위가 전체인지 특정 부분인지 알려 주실 수 있나요?', + }, + { + key: 'format', + label: '출력 포맷', + triggers: ['요약', '보고서', '리포트', '정리', '문서', '카드', '발표', '슬라이드', '프레젠테이션'], + specifiers: ['표', '리스트', 'json', 'markdown', '마크다운', '단락', 'bullet', '글머리표', '한장', '한 장', '슬라이드', 'pdf', '문장으로', '항목별', '단계별'], + suggestedQuestion: '어떤 형식 (표/리스트/단락 등) 으로 받고 싶은지 알려 주실 수 있나요?', + }, + { + key: 'deadline', + label: '마감/긴급도', + triggers: ['언제까지', '마감', '빨리', '급함', '오늘 안에', '내일까지'], + specifiers: ['오늘', '내일', '이번 주', '다음 주', '월', '일', '시', '분'], + suggestedQuestion: '마감일이나 긴급도를 알려 주실 수 있나요?', + }, +]; + +export interface AmbiguityResult { + ambiguous: boolean; + missingDimensions: { key: string; label: string; suggestedQuestion: string }[]; + triggerCount: number; + promptLength: number; +} + +function hasAnyKeyword(text: string, keywords: string[]): boolean { + const lower = text.toLowerCase(); + return keywords.some((k) => lower.includes(k.toLowerCase())); +} + +/** + * 모호 감지. strictness 에 따라 threshold 변동: + * - low: 2개 이상 missing → ambiguous + * - medium: 1개 이상 missing → ambiguous (기본) + * - high: 1개 이상 missing OR 프롬프트 짧음 (<20 chars) → ambiguous + */ +export function detectAmbiguity(prompt: string, strictness: IntentStrictness = 'medium'): AmbiguityResult { + const result: AmbiguityResult = { + ambiguous: false, + missingDimensions: [], + triggerCount: 0, + promptLength: (prompt || '').length, + }; + if (!prompt || !prompt.trim()) return result; + + for (const dim of DIMENSIONS) { + const hasTrigger = hasAnyKeyword(prompt, dim.triggers); + if (!hasTrigger) continue; + result.triggerCount++; + const hasSpecifier = hasAnyKeyword(prompt, dim.specifiers); + if (!hasSpecifier) { + result.missingDimensions.push({ + key: dim.key, + label: dim.label, + suggestedQuestion: dim.suggestedQuestion, + }); + } + } + + const missingCount = result.missingDimensions.length; + if (strictness === 'low') result.ambiguous = missingCount >= 2; + else if (strictness === 'medium') result.ambiguous = missingCount >= 1; + else result.ambiguous = missingCount >= 1 || (result.promptLength < 20 && result.triggerCount > 0); + + return result; +} + +/** + * 시스템 프롬프트용 [INTENT CLARIFICATION GUIDANCE] 블록. + * ambiguous=false 면 빈 문자열 반환. + */ +export function buildIntentClarificationBlock(result: AmbiguityResult): string { + if (!result.ambiguous || result.missingDimensions.length === 0) return ''; + + const lines: string[] = []; + lines.push('[INTENT CLARIFICATION GUIDANCE]'); + lines.push('사용자 질의에서 다음 의도 차원이 *명시되지 않음* — 추측 답변보다 *짧은 역질문* 우선:'); + lines.push(''); + for (const d of result.missingDimensions) { + lines.push(`- **${d.label}** — 예: "${d.suggestedQuestion}"`); + } + lines.push(''); + lines.push('[지침]'); + lines.push('1. 모호 차원이 답변의 *방향* 을 좌우하는 경우, 1~2개 핵심 질문을 *먼저* 던질 것 (전체 답변 미리 만들지 말 것).'); + lines.push('2. 사용자가 이미 "추정해도 OK", "그냥 진행", "알아서" 같은 표현을 했으면 합리적 가정 + *가정 명시* 후 진행.'); + lines.push('3. 모호 차원이 답변과 *무관* 한 정보성/탐색성 질의면 그대로 답변 OK.'); + lines.push('4. 질문 던질 때 사용자가 다시 입력하기 쉽도록 *선택지 2~3개* 또는 *기대 형식* 같이 명시.'); + lines.push('[/INTENT CLARIFICATION GUIDANCE]'); + return lines.join('\n'); +} diff --git a/src/retrieval/semanticRerank.ts b/src/retrieval/semanticRerank.ts new file mode 100644 index 0000000..cce912a --- /dev/null +++ b/src/retrieval/semanticRerank.ts @@ -0,0 +1,213 @@ +/** + * LLM Semantic Re-ranking — TF-IDF / 임베딩이 놓치는 *의도* 매치를 작은 LLM 호출 + * 한 번으로 잡는다. + * + * 동작: + * 1. 1차 검색(TF-IDF + embedding + 부스트들) 결과의 *상위 K* (기본 15) 후보를 추출 + * 2. 가벼운 프롬프트로 LLM 에게 "이 중 query 의도에 가장 부합하는 순서로 ID 나열" 요청 + * 3. LLM 응답을 파싱해 순서 적용 — 응답 실패/누락 ID 는 원순서 유지 + * + * 비용·위험 관리: + * - 기본 OFF (g1nation.semanticRerankEnabled). 사용자가 latency 감수할 의지 있을 때만. + * - 짧은 timeout (기본 8초) — 초과 시 원순서 그대로 반환, 검색 실패 안 됨. + * - 후보 K 제한 — 토큰 비용 cap. + * - 별도 빠른 모델 지정 가능 (`g1nation.semanticRerankModel`) — 메인 모델 외 작은 모델. + * + * 인터페이스: input chunks 순서는 *원본 score 내림차순* 으로 들어와야 함. + * 반환: re-rank 가 성공하면 새 순서의 RetrievalChunk[], 실패하면 원순서. + */ + +import { RetrievalChunk } from './types'; + +export interface SemanticRerankOptions { + ollamaUrl: string; + /** Re-rank 전용 모델 ID. 비면 fallback model 사용. */ + model: string; + /** 후보로 LLM 에 넘길 최대 chunk 개수. 기본 15. */ + candidateK: number; + /** LLM 호출 타임아웃 (ms). 기본 8000. */ + timeoutMs: number; + /** 각 chunk 미리보기 길이. 기본 240 chars. */ + excerptLength: number; +} + +export const DEFAULT_SEMANTIC_RERANK_OPTIONS: Omit = { + candidateK: 15, + timeoutMs: 8000, + excerptLength: 240, +}; + +export interface SemanticRerankResult { + rerankedChunks: RetrievalChunk[]; + /** true 면 LLM 응답으로 순서 변경됨. false 면 원순서 (실패/타임아웃/파싱 실패). */ + success: boolean; + durationMs: number; + /** 디버그·footer 표시용 — re-rank 가 어떻게 동작했는지. */ + note: string; +} + +function shortExcerpt(text: string, n: number): string { + if (!text) return ''; + const cleaned = text.replace(/\s+/g, ' ').trim(); + return cleaned.length <= n ? cleaned : cleaned.slice(0, n) + '…'; +} + +function buildRerankPrompt(query: string, candidates: RetrievalChunk[], excerptLength: number): { system: string; user: string } { + const lines: string[] = []; + for (let i = 0; i < candidates.length; i++) { + const c = candidates[i]; + lines.push(`[C${i + 1}] (${c.source}) ${c.title || '(제목 없음)'}`); + lines.push(` ${shortExcerpt(c.content, excerptLength)}`); + } + + const system = [ + '당신은 검색 결과 재정렬기 (re-ranker). 사용자 질의의 *의도* 와 각 후보 문서의 *내용 부합도* 를 평가해 가장 유용한 순서로 정렬.', + '', + '[규칙]', + '1. 응답은 *반드시* 한 줄의 JSON: `{"ranking":[3,1,5,2,4,...]}` 형식.', + '2. ranking 배열 원소 = 입력 [C1], [C2] 의 *번호* (1-based).', + '3. 모든 입력 후보를 한 번씩만 포함. 누락·중복·번호 외 값 금지.', + '4. 다른 설명·코드 블록·텍스트 출력 절대 금지 — JSON 한 줄만.', + '5. 평가 기준: (a) 질의 의도와의 직접 부합도 > (b) 키워드 매치 > (c) 문맥 풍부도.', + ].join('\n'); + + const user = [ + `[사용자 질의]\n${query}`, + '', + `[후보 ${candidates.length}개]`, + ...lines, + '', + '위 후보를 가장 부합도 높은 순서로 정렬한 ranking 배열만 JSON 한 줄로 출력.', + ].join('\n'); + + return { system, user }; +} + +/** + * Ollama / OpenAI 호환 endpoint 로 단발 호출. agents/factory.ts 의 BaseAgent.callLLM + * 패턴 단순화. timeout, retry 1회만. + */ +async function callLlmForRerank( + ollamaUrl: string, + model: string, + system: string, + user: string, + timeoutMs: number, +): Promise { + const isOllama = ollamaUrl.includes(':11434') || ollamaUrl.includes('ollama'); + const endpoint = isOllama ? `${ollamaUrl}/api/chat` : `${ollamaUrl}/v1/chat/completions`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const body = isOllama + ? { + model, stream: false, + messages: [ + { role: 'system', content: system }, + { role: 'user', content: user }, + ], + options: { temperature: 0.0, num_predict: 256 }, + } + : { + model, + messages: [ + { role: 'system', content: system }, + { role: 'user', content: user }, + ], + stream: false, temperature: 0.0, max_tokens: 256, + }; + const res = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: controller.signal, + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data: any = await res.json(); + const content = + data?.message?.content ?? + data?.choices?.[0]?.message?.content ?? + data?.choices?.[0]?.text ?? + data?.response ?? + ''; + return String(content || ''); + } finally { + clearTimeout(timer); + } +} + +/** LLM 응답에서 ranking 배열 추출 + 검증. 실패 시 null. */ +function parseRanking(raw: string, expectedSize: number): number[] | null { + if (!raw) return null; + // JSON 한 줄 추출 — { ... } 안에 ranking + const match = raw.match(/\{[\s\S]*?\}/); + if (!match) return null; + try { + const parsed = JSON.parse(match[0]); + const arr = parsed?.ranking; + if (!Array.isArray(arr)) return null; + const seen = new Set(); + const out: number[] = []; + for (const v of arr) { + const n = typeof v === 'number' ? v : parseInt(String(v), 10); + if (!Number.isFinite(n) || n < 1 || n > expectedSize) continue; + if (seen.has(n)) continue; + seen.add(n); + out.push(n); + } + // 누락 보충 — LLM 이 일부 빠뜨렸으면 원순서로 뒤에 붙임. + for (let i = 1; i <= expectedSize; i++) { + if (!seen.has(i)) out.push(i); + } + return out.length === expectedSize ? out : null; + } catch { + return null; + } +} + +export async function semanticRerank( + query: string, + chunks: RetrievalChunk[], + options: SemanticRerankOptions, +): Promise { + const start = Date.now(); + const k = Math.max(2, Math.min(options.candidateK, chunks.length)); + if (chunks.length < 2 || k < 2) { + return { rerankedChunks: chunks, success: false, durationMs: 0, note: 'too few candidates' }; + } + // 입력은 score 내림차순 가정 — 상위 K 가 re-rank 대상, 나머지는 그대로 꼬리. + const candidates = chunks.slice(0, k); + const tail = chunks.slice(k); + + const { system, user } = buildRerankPrompt(query, candidates, options.excerptLength); + + let raw = ''; + try { + raw = await callLlmForRerank(options.ollamaUrl, options.model, system, user, options.timeoutMs); + } catch (e: any) { + return { + rerankedChunks: chunks, + success: false, + durationMs: Date.now() - start, + note: `LLM call failed: ${e?.name || e?.message || 'unknown'}`, + }; + } + + const ranking = parseRanking(raw, candidates.length); + if (!ranking) { + return { + rerankedChunks: chunks, + success: false, + durationMs: Date.now() - start, + note: 'unparseable LLM response', + }; + } + + const reranked = ranking.map((i) => candidates[i - 1]); + return { + rerankedChunks: [...reranked, ...tail], + success: true, + durationMs: Date.now() - start, + note: `re-ranked top ${k} (changed positions: ${ranking.filter((v, i) => v !== i + 1).length})`, + }; +} diff --git a/src/retrieval/terminologyBlock.ts b/src/retrieval/terminologyBlock.ts new file mode 100644 index 0000000..9cd1c82 --- /dev/null +++ b/src/retrieval/terminologyBlock.ts @@ -0,0 +1,137 @@ +/** + * Terminology Dictionary — 프로젝트 표준 용어집을 시스템 프롬프트에 주입. + * + * 사용자 제안: "표준 표기 강제 + 답변 내 표기 일관성 검증". 예: `runway` vs `런웨이`, + * `P-Reinforce` vs `p-reinforce`, `Chronicle` vs `크로니클`. + * + * 설계 — 사용자 편집 markdown 파일: + * - 위치: `/.astra/glossary.md` + * - 형식: 자유 markdown. ASTRA 는 *형식을 강제하지 않고* 통째로 주입 + * - 권장 컨벤션: H2/H3 섹션으로 표준 표기 / 영-한 컨벤션 / 금지 용어 등 그룹핑 + * + * 시스템 프롬프트 블록 `[TERMINOLOGY DICTIONARY]`: + * - 글로서리 본문 + Term Check 지침 (#1 typo/용어 self-check 사용자 제안 통합) + * - 답변 작성 시 표준 표기 우선 + 답변 직전 자기 점검 + 새 용어 도입 시 명시 + * + * 캐시: 파일 mtime 기반 — 매 turn 디스크 read 안 함. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +const DEFAULT_GLOSSARY_REL_PATH = '.astra/glossary.md'; + +/** mtime-keyed cache — 사용자가 편집할 때만 다시 읽음. */ +const _cache = new Map(); + +export function getGlossaryFilePath(relPath: string = DEFAULT_GLOSSARY_REL_PATH): string | null { + const folders = vscode.workspace.workspaceFolders; + if (!folders || folders.length === 0) return null; + return path.join(folders[0].uri.fsPath, relPath); +} + +function readGlossary(relPath: string): string { + const fp = getGlossaryFilePath(relPath); + if (!fp) return ''; + try { + if (!fs.existsSync(fp)) return ''; + const st = fs.statSync(fp); + const cached = _cache.get(fp); + if (cached && cached.mtime === st.mtimeMs) return cached.content; + const content = fs.readFileSync(fp, 'utf-8').trim(); + _cache.set(fp, { mtime: st.mtimeMs, content }); + return content; + } catch { + return ''; + } +} + +export function clearGlossaryCache(): void { + _cache.clear(); +} + +export interface TerminologyBlockOptions { + /** Glossary 파일 상대 경로. 기본 '.astra/glossary.md'. */ + relPath: string; + /** 본문 최대 길이 (chars). 너무 큰 글로서리는 시스템 프롬프트 비대 — cap. 기본 4000. */ + maxBodyLength: number; + /** 길이 초과 시 잘릴 안내 표시 여부. */ + showTruncationNote: boolean; +} + +export const DEFAULT_TERMINOLOGY_OPTIONS: TerminologyBlockOptions = { + relPath: DEFAULT_GLOSSARY_REL_PATH, + maxBodyLength: 4000, + showTruncationNote: true, +}; + +export function buildTerminologyBlock(options: Partial = {}): string { + const opts: TerminologyBlockOptions = { ...DEFAULT_TERMINOLOGY_OPTIONS, ...options }; + const raw = readGlossary(opts.relPath); + if (!raw) return ''; // 파일 없음 → 블록 안 만듦 (no-op) + + let body = raw; + let truncated = false; + if (body.length > opts.maxBodyLength) { + body = body.slice(0, opts.maxBodyLength); + truncated = true; + } + + const lines: string[] = []; + lines.push('[TERMINOLOGY DICTIONARY]'); + lines.push('프로젝트 표준 용어집. 답변 생성 시 다음 표기·컨벤션을 *최우선* 으로 사용.'); + lines.push(''); + lines.push('---'); + lines.push(body); + if (truncated && opts.showTruncationNote) { + lines.push(''); + lines.push(`_…(글로서리 ${raw.length - opts.maxBodyLength}자 잘림 — 핵심 용어를 앞쪽에 배치해 주세요)_`); + } + lines.push('---'); + + // Term Check 지침 — 사용자 제안 #1 (typo/용어 self-check) 통합. + lines.push(''); + lines.push('[Term Check — 답변 직전 자기 점검]'); + lines.push('1. **표준 표기 우선**: 위 용어가 답변에 등장하면 *글로서리의 표기를 그대로* 사용. 변형·번역 임의 적용 금지.'); + lines.push('2. **표기 흔들림 방지**: 같은 용어를 한 답변 안에서 *동일 표기* 로 일관 사용 (예: "Chronicle" 과 "크로니클" 섞지 말 것).'); + lines.push('3. **새 용어 도입 시**: 글로서리에 없는 고유 명사·약어 처음 사용 시 *"새 용어: X"* 라고 한 번 명시.'); + lines.push('4. **금지 표기 검증**: 답변 직전, 글로서리의 *금지·비추* 항목이 답변에 들어가지 않았는지 검토. 들어갔으면 *재작성*.'); + lines.push('5. **모르겠으면 글로서리**: 표기 확신 없을 때 "글로서리에 없어 일반 표기 사용" 한 줄 명시 후 진행.'); + lines.push('[/TERMINOLOGY DICTIONARY]'); + + return lines.join('\n'); +} + +/** + * 글로서리 파일 작성 도우미 — 처음 사용자가 만들 때 권장 컨벤션 템플릿. + * 슬래시 명령 `/glossary init` 등에서 호출. + */ +export const GLOSSARY_TEMPLATE = `# 프로젝트 용어집 + +ASTRA 가 답변 시 표준 표기로 사용. 사용자가 자유롭게 편집 가능. +파일 저장 후 다음 채팅 turn 부터 자동 반영. + +## 표준 표기 + +- **ASTRA** (X: astra, Astra 외) — 본 VS Code extension 이름 +- **P-Reinforce v3.0** (X: p-reinforce, p reinforce) — 지식 압축 규칙 +- **Chronicle ADR** (X: chronicle, ADR 단독) — 의사결정 기록 + +## 영-한 표기 컨벤션 + +- Performance → 성능 +- Bug → 버그 +- Memory → 메모리 + +## 금지·비추 표현 + +- ❌ "절대적", "반드시" (단정적 표현 — 정책 충돌 위험) +- ❌ "에이전트가 알아서" (그라운딩 위반) +- ❌ 한·영 깨짐 (예: "결ently", "p-rein동") + +## 슬래시 명령 표기 + +원문 그대로 — 한국어 번역 금지: +- /runway, /customers, /hire, /morning, /evening, /weekly, /cohort, /memory, /glossary +`;