From 7bec20620a4a719ff2266cf3454151b934746a79 Mon Sep 17 00:00:00 2001 From: g1nation Date: Mon, 1 Jun 2026 11:55:22 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20v2.2.195-201=20=E2=80=94=20slashRou?= =?UTF-8?q?ter=20god-file=20=ED=95=B4=EC=B2=B4=20(=E2=80=9395%)=20+=20?= =?UTF-8?q?=EC=9D=B8=ED=94=84=EB=9D=BC=205=EA=B0=9C=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 아키텍처 감사 결과 HIGH 2건 + MED 2건 + LOW 1건 — 7 라운드 정리 시리즈. 기능 변경 없음, 순수 구조 정리. **slashRouter.ts: 4,174 → 201줄 (–3,973, –95%)** **agent.ts: 1,617 → 1,551줄 (–66, –4%)** v2.2.195: eventSourcedStore + SystemPromptBlock registry - createEventStore(opts) — 4 store (customers/hire/runway/feedback) I/O 240줄 중복 제거 - _turnCtx 5 named string field → 1 Map (새 verification block 추가 25곳→1곳) - buildAstraModeSystemPrompt: 5 ternary gate + 5 위치 → 1 for-loop join v2.2.196: trackers cluster split - src/features/teamops/handlers/_shared.ts (fmtKrw/parseAmount/daysUntil/parseTaskOwner/stageEmoji/STAGE_ORDER/TERMINAL_STAGES) - src/features/teamops/handlers/trackers.ts (runway/customers/hire) - src/features/teamops/handlers/index.ts (barrel) - extension.ts 에 side-effect import (순환 import 회피) v2.2.197: mtimeFileCache + PostAnswerHook registry - src/lib/mtimeFileCache.ts — createMtimeFileCache(name, parse) (terminologyBlock + termValidator 2-cache invariant 자동화) - src/agent/postAnswerHooks/{types,index}.ts — Devil/SelfCheck/TermValidator 3 _maybeX method → 1 runPostAnswerHooks(ctx) loop - agent.ts –66줄 v2.2.198: dashboards cluster split - src/features/teamops/handlers/dashboards.ts (morning/evening/cohort/weekly) v2.2.199: coordination + communication clusters split - src/features/teamops/handlers/coordination.ts (task/decisions/onesie/blocked/standup) - src/features/teamops/handlers/communication.ts (draft/feedback) - callLmSynthesis export 노출 (communication 이 사용) - 옛 parseTaskOwner local 정의 삭제 (_shared.ts 사용) v2.2.200: system cluster split - src/features/system/handlers.ts (memory/glossary/help) v2.2.201: datacollect cluster split + LLM 인프라 추출 - src/features/datacollect/handlers.ts (research/benchmark/youtube/blog/wikify/meet) - src/features/datacollect/llm.ts (callLmSynthesis + repairKoreanGlitches + bridgeErrorRemedy) - slashRouter import 4개로 축소: vscode/logInfo/getBridgeBaseUrl/bridgeErrorRemedy **최종 slashRouter (201줄):** - REGISTRY Map + registerSlashCommand/listSlashCommands/isSlashCommand - handleSlashCommand (dispatcher + 에러 처리) - Webview interface + chunk helper - getRecentSlashCommands ring buffer (actionability scoring 용) **미래 부담 감소 metrics:** - 새 슬래시 명령: god-file 끝에 함수 + register → 1 파일 + 1 register call - 새 verification block: 5곳 편집 → 1 set call - 새 event store: 60줄 boilerplate → createEventStore 한 줄 - 새 post-answer hook: 3 step → 1 push - 새 mtime cache: Map + invariant 관리 → createMtimeFileCache 한 줄 Co-Authored-By: Claude Opus 4.7 --- .astra/project-context/architecture.md | 36 +- .astra/project-context/scan-cache.json | 229 +- ...cde86955f34dda22a6e02b95c9adc0a456927.json | 2 +- ...c10d377a9fef641dd359504b8d53aecd0a4c3.json | 4 +- .../tests/engine/.astra/missions/wiki_on.json | 12 +- ...b3d9d44f32b0e4cd024b2e055db3a0d34417e.json | 2 +- ...973124fb64ba505f767c53a783833bbc3fa6a.json | 2 +- ...0e6575e54853929e991e579e318f2f5a19030.json | 2 +- ...b73b3a5a01af5d82391ec29a25bd72b8239a5.json | 2 +- ...son => stress_conflict_1780282143218.json} | 20 +- PATCHNOTES.md | 341 ++ ...-환경-face-api-js는-원래-브라우저용입니다-node-단.md | 16 + docs/records/ConnectAI/chronicle.config.json | 2 +- docs/records/ConnectAI/timeline.md | 3 + package-lock.json | 2 +- package.json | 2 +- src/agent.ts | 130 +- .../buildAstraModeSystemPrompt.ts | 67 +- src/agent/postAnswerHooks/index.ts | 96 + src/agent/postAnswerHooks/types.ts | 48 + src/agent/termValidator.ts | 135 +- src/extension.ts | 5 + src/features/_shared/eventSourcedStore.ts | 83 + src/features/customers/customersStore.ts | 51 +- src/features/datacollect/handlers.ts | 744 +++ src/features/datacollect/llm.ts | 122 + src/features/datacollect/slashRouter.ts | 3989 +---------------- src/features/feedback/feedbackStore.ts | 59 +- src/features/hire/hireStore.ts | 49 +- src/features/runway/runwayStore.ts | 49 +- src/features/system/handlers.ts | 394 ++ src/features/teamops/handlers/_shared.ts | 84 + .../teamops/handlers/communication.ts | 255 ++ src/features/teamops/handlers/coordination.ts | 572 +++ src/features/teamops/handlers/dashboards.ts | 806 ++++ src/features/teamops/handlers/index.ts | 14 + src/features/teamops/handlers/trackers.ts | 704 +++ src/lib/contextBuilders/memoryContext.ts | 104 +- src/lib/mtimeFileCache.ts | 72 + src/retrieval/terminologyBlock.ts | 20 +- 40 files changed, 4784 insertions(+), 4545 deletions(-) rename .astra/tests/stress/.astra/missions/{stress_conflict_1780039534851.json => stress_conflict_1780282143218.json} (75%) create mode 100644 docs/records/ConnectAI/bugs/BUG-0015-짚어둘-관찰-사항-참고용-face-api-js-환경-face-api-js는-원래-브라우저용입니다-node-단.md create mode 100644 src/agent/postAnswerHooks/index.ts create mode 100644 src/agent/postAnswerHooks/types.ts create mode 100644 src/features/_shared/eventSourcedStore.ts create mode 100644 src/features/datacollect/handlers.ts create mode 100644 src/features/datacollect/llm.ts create mode 100644 src/features/system/handlers.ts create mode 100644 src/features/teamops/handlers/_shared.ts create mode 100644 src/features/teamops/handlers/communication.ts create mode 100644 src/features/teamops/handlers/coordination.ts create mode 100644 src/features/teamops/handlers/dashboards.ts create mode 100644 src/features/teamops/handlers/index.ts create mode 100644 src/features/teamops/handlers/trackers.ts create mode 100644 src/lib/mtimeFileCache.ts diff --git a/.astra/project-context/architecture.md b/.astra/project-context/architecture.md index a7d0ee3..8254d24 100644 --- a/.astra/project-context/architecture.md +++ b/.astra/project-context/architecture.md @@ -3,15 +3,15 @@ ## Snapshot -- **Workspace**: `connectai` `v2.2.194` _(absolute path varies by environment; resolved from the active VS Code workspace)_ +- **Workspace**: `connectai` `v2.2.200` _(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**: 419 source files, ~70,411 lines across 5 top-level modules. +- **Stats**: 431 source files, ~70,417 lines across 5 top-level modules. ## Last Refresh -- **Time**: 2026-05-29T07:30:42.899Z +- **Time**: 2026-06-01T02:30:44.120Z - **Files newly analysed**: 3 -- **Files reused from cache**: 416 +- **Files reused from cache**: 428 ## Directory Map ```mermaid @@ -20,8 +20,8 @@ mindmap src/ features/ sidebar/ - lib/ agent/ + lib/ retrieval/ core/ media/ @@ -40,11 +40,11 @@ mindmap > Arrows: which top-level module imports from which. ```mermaid flowchart LR - src["src/
263 files"] + src["src/
274 files"] media["media/
6 files"] tests["tests/
37 files"] core_py["core_py/
6 files"] - docs["docs/
107 files"] + docs["docs/
108 files"] tests --> src ``` @@ -57,8 +57,8 @@ flowchart LR ## Hub Files > Imported by many other files — touching these has wide blast radius. - `src/utils.ts` — referenced by **87** files +- `src/config.ts` — referenced by **35** files - `src/agent.ts` — referenced by **34** 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 @@ -67,13 +67,13 @@ flowchart LR ## Modules -### `src/` — 263 files, ~52,640 lines +### `src/` — 274 files, ~52,627 lines **Sub-directories** -- `src/features/` (92) — Astra Office — public API. 다음 세션에서 추가될 OfficeSnapshot presenter / schema 도 같은 entry 로 노출 예정. 현재 노출: full webview panel H +- `src/features/` (100) — 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/` (27) — Post-hoc Self-Check — 답변 완료 후 LLM 한 번 호출로 3가지 평가. 사용자 제안: "[Self-Check] 단계 — 이 답변이 사용자 질문에 직접 답하는가 / 규칙 준수 / 논리 모순 없는가". +- `src/agent/` (29) — Post-answer hook registry — 답변 완료 후 실행되는 부가 작업 모음. 새 hook 추가 = 1 객체 push. agent.ts 는 이 배열을 iterate 만 함. 현재 등록 순서 (v2.2.1 +- `src/lib/` (29) — Astra Mode Architecture Context Builder. 의도: 사용자가 Astra 자체의 mode 디자인 (Guard vs Multi-Agent 가 별도 모드여야 하는지) 을 묻는 메타 질문에 답할 - `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일+ 지 @@ -86,12 +86,13 @@ flowchart LR **Key files** - `src/utils.ts` (471 lines) - `src/config.ts` (557 lines) -- `src/agent.ts` (1589 lines) +- `src/agent.ts` (1503 lines) - `src/features/company/types.ts` (446 lines) — Type definitions for the 1인 기업 (One-Person Company) mode. The mode turns the user into a virtual CEO that dispatches work to a roster of specialist agents. Each turn produces a session directory conta - `src/core/services.ts` (176 lines) - `src/sidebarProvider.ts` (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/features/datacollect/slashRouter.ts` (1240 lines) - `src/integrations/telegram/telegramClient.ts` (154 lines) - `src/lib/paths.ts` (151 lines) - `src/agent/actions/types.ts` (41 lines) @@ -108,7 +109,6 @@ flowchart LR - `src/integrations/telegram/telegramBot.ts` (270 lines) - `src/lib/contextBuilders/localProjectIntent.ts` (233 lines) - `src/lib/engine.ts` (1114 lines) -- `src/lmstudio/streamer.ts` (252 lines) ### `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/` — 107 files, ~3,816 lines +### `docs/` — 108 files, ~3,835 lines **Sub-directories** -- `docs/records/` (94) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 프로젝트 코드 리뷰 해줄 수 있어? 개선할 부분이 있는지, 그러고... +- `docs/records/` (95) — 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` (233 lines) — Project Timeline +- `docs/records/ConnectAI/timeline.md` (236 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 @@ -341,7 +341,7 @@ Astra는 대표님의 명시적인 승인 하에 로컬 시스템의 강력한 **Designed for High-Performance Decision Making.** Copyright (C) **g1nation**. All rights reserved. -_Last auto-scan: 2026-05-29T07:30:42.899Z · signature `246e4864`_ +_Last auto-scan: 2026-06-01T02:30:44.120Z · signature `a95021db`_ ## Purpose diff --git a/.astra/project-context/scan-cache.json b/.astra/project-context/scan-cache.json index 2c36c83..c495439 100644 --- a/.astra/project-context/scan-cache.json +++ b/.astra/project-context/scan-cache.json @@ -1,6 +1,6 @@ { "version": 1, - "generatedAt": "2026-05-29T07:30:42.949Z", + "generatedAt": "2026-06-01T02:30:44.174Z", "files": { "src/agent/actions/brainOps.ts": { "mtimeMs": 1779764602582.9768, @@ -135,9 +135,9 @@ ] }, "src/agent/handlePrompt/buildAstraModeSystemPrompt.ts": { - "mtimeMs": 1780033670993.9626, - "size": 8427, - "lines": 133, + "mtimeMs": 1780276266500.7705, + "size": 6433, + "lines": 100, "role": "", "imports": [ "src/lib/contextBuilders/localProjectIntent", @@ -298,6 +298,26 @@ "src/agent" ] }, + "src/agent/postAnswerHooks/index.ts": { + "mtimeMs": 1780278997000.157, + "size": 3900, + "lines": 96, + "role": "Post-answer hook registry — 답변 완료 후 실행되는 부가 작업 모음. 새 hook 추가 = 1 객체 push. agent.ts 는 이 배열을 iterate 만 함. 현재 등록 순서 (v2.2.197): 1. devilRebuttal — Devil Agent 반박 카드 (비활성 시 silent skip) 2. postHocSelfChec", + "imports": [ + "src/agent/postAnswerHooks/types", + "src/agent/llm/devilRebuttal", + "src/agent/postHocSelfCheck", + "src/agent/termValidator", + "src/config" + ] + }, + "src/agent/postAnswerHooks/types.ts": { + "mtimeMs": 1780278967597.8257, + "size": 2001, + "lines": 48, + "role": "Post-Answer Hook 인터페이스 — 답변 streaming 완료 후 실행되는 부가 작업. 옛 구조: agent.ts 의 maybeEmitDevilRebuttal, maybePostHocSelfCheck, maybeRunTermValidator 3개 private method. 새 hook 추가 시 (1) method 정의 (2) import (3)", + "imports": [] + }, "src/agent/postHocSelfCheck.ts": { "mtimeMs": 1780033021215.5054, "size": 8943, @@ -317,16 +337,18 @@ ] }, "src/agent/termValidator.ts": { - "mtimeMs": 1780039129011.7288, - "size": 8675, - "lines": 221, + "mtimeMs": 1780278698973.1787, + "size": 8156, + "lines": 202, "role": "Post-generation Term Validator — 답변 완료 후 정규식/사전 기반 결정론적 스캔. v2.2.192 의 Terminology Dictionary 가 instructional (LLM 에게 표준 표기 사용 지시) 이면, 이건 deterministic — LLM 이 지시를 안 따랐을 때 catch. Glossary 파싱 — 두 패턴 인식", - "imports": [] + "imports": [ + "src/lib/mtimeFileCache" + ] }, "src/agent.ts": { - "mtimeMs": 1780039163639.0967, - "size": 87154, - "lines": 1589, + "mtimeMs": 1780278886140.8762, + "size": 81193, + "lines": 1503, "role": "", "imports": [ "src/utils", @@ -397,8 +419,7 @@ "src/agent/handlePrompt/buildAstraModeSystemPrompt", "src/agent/handlePrompt/computeBudgetedRequest", "src/agent/handlePrompt/processFinalAnswer", - "src/agent/postHocSelfCheck", - "src/agent/termValidator", + "src/agent/postAnswerHooks", "src/agent/handlePrompt/applyAutoContinuation", "src/features/approval/approvalQueue", "src/features/providers", @@ -704,11 +725,13 @@ ] }, "src/extension.ts": { - "mtimeMs": 1779764602602.9807, - "size": 17079, - "lines": 354, + "mtimeMs": 1780280902153.7483, + "size": 17342, + "lines": 358, "role": "", "imports": [ + "src/features/teamops/handlers", + "src/features/system/handlers", "src/utils", "src/config", "src/agent", @@ -1117,11 +1140,13 @@ "imports": [] }, "src/features/customers/customersStore.ts": { - "mtimeMs": 1779952731356.5476, - "size": 6815, - "lines": 176, + "mtimeMs": 1780275713140.8125, + "size": 5722, + "lines": 149, "role": "고객사 / MRR / 갱신 트래커. 4인 기업의 수입 쪽 — /runway 가 통장과 burn 을 본다면, 여기는 어디서 돈이 들어오나. Salesforce / HubSpot 같은 CRM 아닌 가벼운 event-sourced 로그. 저장 형식: JSON Lines (.jsonl) — append-only event log. 같은 customer 의 여러 이", - "imports": [] + "imports": [ + "src/features/_shared/eventSourcedStore" + ] }, "src/features/datacollect/bridgeClient.ts": { "mtimeMs": 1779764602617.1548, @@ -1166,9 +1191,9 @@ "imports": [] }, "src/features/datacollect/slashRouter.ts": { - "mtimeMs": 1780039211177.6035, - "size": 226165, - "lines": 4096, + "mtimeMs": 1780280990100.2815, + "size": 67318, + "lines": 1240, "role": "", "imports": [ "src/utils", @@ -1179,6 +1204,7 @@ "src/config", "src/retrieval/terminologyBlock", "src/agent/termValidator", + "src/features/teamops/handlers/_shared", "src/features/setup/datacollectSetup", "src/features/datacollect/prompts/synthesisPrompt", "src/features/datacollect/prompts/youtubePrompts", @@ -1214,18 +1240,22 @@ ] }, "src/features/feedback/feedbackStore.ts": { - "mtimeMs": 1779931731117.0737, - "size": 3406, - "lines": 81, + "mtimeMs": 1780275889230.5403, + "size": 1851, + "lines": 44, "role": "고객 피드백 누적 저장소. 단일 운영자(대표) 모드에서 슬랙·이메일·CS 채널에 흩어진 고객 피드백을 /feedback <텍스트> 한 줄로 모아 둔다. 패턴 분석은 /feedback summary 로 LLM 이 누적 데이터를 보고 카테고리 분포 + 반복 주제를 추출. 저장 형식: JSON Lines (.jsonl) — 한 줄 = 한 entry. 누적·app", - "imports": [] + "imports": [ + "src/features/_shared/eventSourcedStore" + ] }, "src/features/hire/hireStore.ts": { - "mtimeMs": 1779964473915.006, - "size": 5586, - "lines": 150, + "mtimeMs": 1780275773463.53, + "size": 4567, + "lines": 125, "role": "채용 파이프라인 트래커. 4인 → 5인 이상 확장 시점에 후보자가 여러 명, 여러 역할(개발/기획/디자인) 로 들어오기 시작 — 노션·스프레드시트·이메일에 흩어진 정보를 한 명령으로 본다. Event-sourced (customersStore 와 동일 패턴) — append-only 이벤트 로그를 재생해 후보자별 현재 단계 + 노트 누적 도출. 위치: /.as", - "imports": [] + "imports": [ + "src/lib/mtimeFileCache" + ] }, "src/retrieval/types.ts": { "mtimeMs": 1779764602656.6587, @@ -3356,8 +3486,15 @@ "role": "Bug: 논문 Outline Title 인간-AI 상호작용에서 의도 정렬을 높이기 위한 최소 질의 구조 연구 또는 사용자의 인지적 편향을 보완하는 구조화...", "imports": [] }, + "docs/records/ConnectAI/bugs/BUG-0015-짚어둘-관찰-사항-참고용-face-api-js-환경-face-api-js는-원래-브라우저용입니다-node-단.md": { + "mtimeMs": 1780281037883.6174, + "size": 2482, + "lines": 16, + "role": "Bug: 짚어둘 관찰 사항 (참고용) face-api.js 환경: face-api.js는 원래 브라우저용입니다. Node 단독 실행 시 @tensorfl...", + "imports": [] + }, "docs/records/ConnectAI/chronicle.config.json": { - "mtimeMs": 1780039836702.8801, + "mtimeMs": 1780281037893.3647, "size": 371, "lines": 11, "role": "JSON configuration", @@ -3910,9 +4047,9 @@ "imports": [] }, "docs/records/ConnectAI/timeline.md": { - "mtimeMs": 1780039836694.6646, - "size": 14881, - "lines": 233, + "mtimeMs": 1780281037885.788, + "size": 15049, + "lines": 236, "role": "Project Timeline", "imports": [] }, diff --git a/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json b/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json index f482f2e..461e9d0 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": 1780039528659, + "createdAt": 1780282136586, "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 34013c4..6dc29ca 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-29T07:25:28.661Z\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) (22ms)\n", - "createdAt": 1780039528661, + "result": "---\nid: wiki_on\ndate: 2026-06-01T02:48:56.587Z\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) (26ms)\n", + "createdAt": 1780282136588, "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 589ebb5..e64244f 100644 --- a/.astra/tests/engine/.astra/missions/wiki_on.json +++ b/.astra/tests/engine/.astra/missions/wiki_on.json @@ -1,8 +1,8 @@ { "missionId": "wiki_on", "status": "completed", - "startTime": "2026-05-29T07:25:28.636Z", - "totalElapsedMs": 26, + "startTime": "2026-06-01T02:48:56.559Z", + "totalElapsedMs": 29, "results": { "direct": "직답 결과 — single-pass mock 응답입니다." }, @@ -12,16 +12,16 @@ { "from": "idle", "to": "direct", - "durationMs": 22, + "durationMs": 26, "message": "답변 작성 중... (단일 호출 fast-path)", - "ts": "2026-05-29T07:25:28.658Z" + "ts": "2026-06-01T02:48:56.585Z" }, { "from": "direct", "to": "completed", - "durationMs": 4, + "durationMs": 3, "message": "미션 완료", - "ts": "2026-05-29T07:25:28.662Z" + "ts": "2026-06-01T02:48:56.588Z" } ], "resilienceMetrics": { diff --git a/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json b/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json index 771d30f..997b7be 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": 1780039534874, + "createdAt": 1780282143243, "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 771d30f..eb4c5b0 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": 1780039534874, + "createdAt": 1780282143242, "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 c218834..2c39627 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": 1780039534870, + "createdAt": 1780282143239, "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 9f73afc..ee8df55 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": 1780039534872, + "createdAt": 1780282143241, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/missions/stress_conflict_1780039534851.json b/.astra/tests/stress/.astra/missions/stress_conflict_1780282143218.json similarity index 75% rename from .astra/tests/stress/.astra/missions/stress_conflict_1780039534851.json rename to .astra/tests/stress/.astra/missions/stress_conflict_1780282143218.json index 0a8fde6..94ca63b 100644 --- a/.astra/tests/stress/.astra/missions/stress_conflict_1780039534851.json +++ b/.astra/tests/stress/.astra/missions/stress_conflict_1780282143218.json @@ -1,8 +1,8 @@ { - "missionId": "stress_conflict_1780039534851", + "missionId": "stress_conflict_1780282143218", "status": "completed", - "startTime": "2026-05-29T07:25:34.851Z", - "totalElapsedMs": 24, + "startTime": "2026-06-01T02:49:03.218Z", + "totalElapsedMs": 26, "results": { "outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]", "section_0": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", @@ -14,30 +14,30 @@ { "from": "idle", "to": "outline", - "durationMs": 18, + "durationMs": 20, "message": "답변 구조 잡는 중...", - "ts": "2026-05-29T07:25:34.869Z" + "ts": "2026-06-01T02:49:03.238Z" }, { "from": "outline", "to": "section", "durationMs": 2, "message": "본문 작성 중...", - "ts": "2026-05-29T07:25:34.871Z" + "ts": "2026-06-01T02:49:03.240Z" }, { "from": "section", "to": "polish", - "durationMs": 2, + "durationMs": 1, "message": "최종 다듬기 중...", - "ts": "2026-05-29T07:25:34.873Z" + "ts": "2026-06-01T02:49:03.241Z" }, { "from": "polish", "to": "completed", - "durationMs": 2, + "durationMs": 3, "message": "미션 완료", - "ts": "2026-05-29T07:25:34.875Z" + "ts": "2026-06-01T02:49:03.244Z" } ], "resilienceMetrics": { diff --git a/PATCHNOTES.md b/PATCHNOTES.md index 5805594..720925c 100644 --- a/PATCHNOTES.md +++ b/PATCHNOTES.md @@ -1,5 +1,346 @@ # Astra Patch Notes +## v2.2.201 (2026-05-29) 🎯 +### 🏗️ 리팩터링 라운드 7 — datacollect cluster split (god-file 완전 해체) +slashRouter god-file 의 마지막 클러스터 — 6 datacollect 핸들러 (research/benchmark/youtube/blog/wikify/meet) 도메인 파일로. 더불어 LLM 호출 인프라(callLmSynthesis/repairKoreanGlitches/bridgeErrorRemedy) 도 별도 모듈로. + +**slashRouter.ts: 1,229 → 201줄 (–1,028, –84%)** +**누적 (v2.2.195 부터): 4,174 → 201 (–3,973, –95%) ⭐ 95% 축소** + +**신규 파일 2개:** +- `src/features/datacollect/handlers.ts`: + - runResearch (NotebookLM Deep Research) + - runBenchmark (Playwright + 4-렌즈 LLM) + - runYoutube + helpers (`_looksLikeYoutubeChannelUrl`, `_normalizeYoutubeUrl`, YOUTUBE_BATCH_MAX) + - runBlog (Blog Pipeline 안내) + - runWikify + `wikifyOne` (P-Reinforce v3.0 위키 합성) + - runMeet (회의록 transcript → 합성 + Tasks/Calendar 자동 등록) + +- `src/features/datacollect/llm.ts`: + - `callLmSynthesis` (bridge /api/lm 호출 — datacollect handlers + teamops/communication 양쪽 사용) + - `repairKoreanGlitches` (한·영 토큰 깨짐 자동 교정) + - `bridgeErrorRemedy` (환경 의존성 에러 → 사용자 해결 가이드 생성, slashRouter catch 블록이 사용) + +**Wiring:** +- extension.ts — `import './features/datacollect/handlers'` 추가 +- communication.ts — `callLmSynthesis` import 를 `./slashRouter` → `./llm` 로 변경 +- slashRouter.ts — 모든 import 정리, vscode/logInfo/getBridgeBaseUrl/bridgeErrorRemedy 4개만 남음 + +**slashRouter.ts 최종 (201줄):** +- REGISTRY Map + registerSlashCommand + listSlashCommands + isSlashCommand +- handleSlashCommand (dispatcher + 에러 처리 + bridgeErrorRemedy 적용) +- Webview interface + chunk helper +- getRecentSlashCommands ring buffer + +**아키텍처 감사 HIGH 1번 완전 해결 ✅** +god-file 해체 완료. 모든 슬래시 핸들러 도메인별 파일 분리, slashRouter 는 순수 dispatcher. + +**전체 리팩터링 시리즈 (v2.2.195~201, 7 builds):** +- 신규 utility 5개 (eventSourcedStore, mtimeFileCache, postAnswerHooks, SystemPromptBlock Map, datacollect/llm) +- 신규 도메인 핸들러 모듈 7개 (teamops trackers/dashboards/coordination/communication + system handlers + datacollect handlers) +- slashRouter: 4,174 → 201 (–3,973, –95%) +- agent.ts: 1,617 → 1,551 (–66, –4%) +- 새 슬래시 명령 추가 = 1 파일 + 1 register call (옛 god-file 와 비교 불가능) +- 새 verification block / event store / post-answer hook = 1곳 변경 + +**기능 변경 없음.** + +**신규 패키징:** `astra-2.2.201.vsix`. + +--- + + + +## v2.2.200 (2026-05-29) 🎉 +### 🏗️ 리팩터링 라운드 6 — system cluster split (마무리) +slashRouter 분리 4단계 (마지막 TeamOps 영역) — system cluster (memory/glossary/help). 4인 팀 운영 모든 핸들러 도메인 분리 완료. + +**slashRouter.ts: 1,615 → 1,229줄 (–386, –24%)** +**누적 (v2.2.195 부터): 4,174 → 1,229 (–2,945, –71%)** + +**신규 파일: `src/features/system/handlers.ts`** + +- runMemory + _memoryOverview + _formatDate (Temporal + Distillation 진입점) +- runGlossary (Terminology Dictionary CRUD) +- runHelp + HelpCategory + HELP_CATEGORIES (카테고리별 명령 브라우저 + 엔진 상태) + +**Wiring:** +- `extension.ts` — `import './features/system/handlers'` side-effect (extension.ts 가 entry point) + +**전체 슬래시 분리 현황:** +``` +src/features/teamops/handlers/ +├── _shared.ts (헬퍼) +├── trackers.ts (3) v2.2.196 +├── dashboards.ts (4) v2.2.198 +├── coordination.ts (5) v2.2.199 +├── communication.ts (2) v2.2.199 +└── index.ts (barrel) + +src/features/system/ +└── handlers.ts (3) v2.2.200 +``` + +**slashRouter 잔여 (~1,229줄):** +- 인프라: REGISTRY / registerSlashCommand / chunk / Webview / isSlashCommand / handleSlashCommand / parseTaskOwner / callLmSynthesis / getRecentSlashCommands (~200줄) +- datacollect: research / benchmark / youtube / blog / wikify / meet (~1,000줄) — 다른 도메인 영역, 추후 라운드 + +**아키텍처 감사 5건 최종 상태:** +| Sev | 항목 | 결과 | +|---|---|---| +| HIGH | slashRouter god-file | 🟢 71% 축소 (teamops + system 완료, datacollect 만 잔여) | +| HIGH | 5-block 체인 | ✅ v2.2.195 | +| MED | 4 store 중복 | ✅ v2.2.195 | +| MED | 2-cache invariant | ✅ v2.2.197 | +| LOW | `_maybe*` hook 패턴 | ✅ v2.2.197 | + +**전체 정리 시리즈 (v2.2.195~200, 6 builds):** +- 신규 utility 4개 (eventSourcedStore, mtimeFileCache, postAnswerHooks, SystemPromptBlock Map) +- 신규 도메인별 핸들러 모듈 5개 (teamops trackers/dashboards/coordination/communication + system) +- slashRouter.ts: 4,174 → 1,229 (–2,945, –71%) +- agent.ts: 1,617 → 1,551 (–66, –4%) +- 향후 새 verification block / store / hook / 슬래시 명령 추가 패턴 모두 *1곳 변경* 으로 완료 + +**기능 변경 없음 — 순수 구조 정리.** + +**신규 패키징:** `astra-2.2.200.vsix`. 🎉 v2.2.200 통과! + +--- + + + +## v2.2.199 (2026-05-29) +### 🏗️ 리팩터링 라운드 5 — coordination + communication clusters +slashRouter 분리 3단계 — coordination (5 handlers) + communication (2 handlers) 도메인 파일로. 원래 v2.2.200 예정이던 communication 도 같이 묶음 (sed 범위에 함께 잡혀 자연스러운 통합). + +**slashRouter.ts: 2,537 → 1,615줄 (–922, –36%)** +**누적 (v2.2.195 부터): 4,174 → 1,615 (–2,559, –61%)** + +**신규 파일 2개:** +- `src/features/teamops/handlers/coordination.ts`: + - runTask + parseFlexibleDate (Google Tasks + Calendar 동시 등록) + - runDecisions (Chronicle ADR 검색) + - runOnesie (1:1 미팅 카드) + - runBlocked (전사 지연·블로커 뷰) + - runStandup (팀 스탠드업 카드) +- `src/features/teamops/handlers/communication.ts`: + - runDraft + DRAFT_TYPES (6종 초안) + - runFeedback + feedbackSave/List/Summary/Path + 2개 LLM 프롬프트 + +**Wiring 변경:** +- `slashRouter.callLmSynthesis` 를 `export` 로 노출 (communication 이 LLM 호출용으로 import) +- `parseTaskOwner` 옛 local 정의 삭제 (`_shared.ts` 사용) +- 옛 ParsedTaskOwner interface 삭제 (도메인별로 분산) +- 옛 orphan 주석 블록 (deletion 후 남은 `// ─── /morning` header 등) 정리 + +**slashRouter 잔여 (~1,615줄):** +- system: memory / glossary / help (~480줄) — v2.2.200 +- datacollect: research / benchmark / youtube / blog / wikify / meet (~1,000줄) — 다른 도메인 영역, 추후 +- 인프라: registerSlashCommand / chunk / Webview / parseTaskOwner / callLmSynthesis / handleSlashCommand 등 (~130줄) + +**기능 변경 없음 — 순수 구조 정리.** + +**신규 패키징:** `astra-2.2.199.vsix`. + +--- + + + +## v2.2.198 (2026-05-29) +### 🏗️ 리팩터링 라운드 4 — dashboards cluster split +slashRouter 분리 2단계 — dashboards 클러스터 (morning/evening/cohort/weekly) 도메인 파일로. + +**slashRouter.ts: 3,448 → 2,537줄 (–911, –26%)** +**누적 (v2.2.195 부터): 4,174 → 2,537 (–1,637, –39%)** + +**신규 파일: `src/features/teamops/handlers/dashboards.ts`** + +- runMorning + _morningActions (브리핑 카드 + 액션 도출) +- runEvening (오늘 진척 + 내일 준비 + 회고) +- runCohort + _buildMonthlyBuckets + _cohortDashboard (MoM 추세) +- runWeekly + _isoWeek + _thisWeekWindow + _priorWeekWindow + _aggregateWeek + _deltaSymbol (주간 리뷰) + +**Wiring:** +- `_shared.ts` 의 fmtKrw/daysUntil/parseTaskOwner/stageEmoji/STAGE_ORDER/TERMINAL_STAGES 재사용 (trackers 와 동일) +- ChronicleProjectStore 직접 import (위클리 ADR 스캔) +- `index.ts` 배럴에 한 줄 추가 (`import './dashboards'`) + +**slashRouter 잔여 (~2,540줄):** +- coordination: task / decisions / onesie / blocked / standup (~700줄) — v2.2.199 예정 +- communication: draft / feedback / meet (~430줄) — v2.2.200 예정 +- system: memory / glossary / help (~480줄) — v2.2.200 예정 +- datacollect: research / benchmark / youtube / blog / wikify (~900줄) — 추후 (별 영역) + +**기능 변경 없음 — 순수 구조 정리.** + +**신규 패키징:** `astra-2.2.198.vsix`. + +--- + + + +## v2.2.197 (2026-05-29) +### 🏗️ 리팩터링 라운드 3 — PostAnswerHook registry + mtimeFileCache 공유 유틸 +아키텍처 감사의 MED 1건 (2 cache 동일 invariant) + LOW 1건 (`_maybeX` 3개 메서드 패턴) 마무리. + +**감사 5건 누적 진행도 (라운드 1~3 완료):** +| Sev | 항목 | 결과 | +|---|---|---| +| HIGH | slashRouter god-file | 🟡 v2.2.196 trackers 분리 (–17%). 나머지 보류 — 패턴 입증됨 | +| HIGH | 5-block 체인 (system prompt) | ✅ v2.2.195 | +| MED | 4 store 중복 | ✅ v2.2.195 | +| **MED** | **terminology+termValidator 2캐시** | **✅ v2.2.197** | +| **LOW** | **`_maybe*` hook 패턴** | **✅ v2.2.197** | + +--- + +**Part A — `src/lib/mtimeFileCache.ts` (신규 utility)** + +배경: terminologyBlock 과 termValidator 가 *같은* 글로서리 파일에 *별도* mtime cache 보유. 무효화 invariant ("둘 다 함께 비우기") 가 사람 손으로 강제됨 — slashRouter 가 `/glossary reload` 시 양쪽 `clearXCache()` 모두 호출. + +- `createMtimeFileCache(name, parse)` — generic factory +- mtime 자동 체크, 캐시 hit / 자동 재read+parse +- `read(filePath)`, `invalidate(filePath)`, `clear()`, `size()` + +**Refactor:** +- `terminologyBlock.ts` — raw 본문 cache 를 mtimeFileCache 인스턴스로 +- `termValidator.ts` — parsed forbidden entries cache 를 mtimeFileCache 인스턴스로 +- 각 모듈은 여전히 자기 `clearXCache()` export (slashRouter 가 둘 다 호출 — 동작 동일) +- 핵심 차이: boilerplate `if (cached && cached.mtime === mtime) return cached.content; ... ; cache.set(...)` 삭제. 새 캐시 추가 = 한 줄. + +--- + +**Part B — `src/agent/postAnswerHooks/` (신규 registry)** + +배경: `agent.ts` 의 `_maybeEmitDevilRebuttal` / `_maybePostHocSelfCheck` / `_maybeRunTermValidator` 3개 private method + 3개 import + 3개 call site. 새 hook 추가 시 *3곳* 편집 필요. 패턴 동일 (안전 fallback, try/catch swallow) — 레지스트리화. + +``` +src/agent/postAnswerHooks/ +├── types.ts ← PostAnswerHook 인터페이스 + Context +└── index.ts ← 3 hooks 등록 + runPostAnswerHooks(ctx) iterator +``` + +**Refactor:** +- `agent.ts`: 3 method 삭제 (–66줄, 1617→1551), 3개 호출 → 1개 `runPostAnswerHooks(ctx)` +- 새 hook 추가 = `postAnswerHooks/` 에 hook 객체 push (1곳) + +**효과 측정:** +- 새 후처리 hook 추가: 옛 3 step → 새 1 step +- agent.ts: –66줄 + +--- + +**기능 변경 없음 — 순수 구조 정리.** DevilRebuttal/SelfCheck/TermValidator 동작 동일, 글로서리 캐시 동작 동일. + +**아키텍처 정리 시리즈 (v2.2.195~197) 총평:** +- 신규 utility 모듈 3개 (eventSourcedStore, mtimeFileCache, postAnswerHooks) +- 신규 도메인별 핸들러 모듈 1개 (teamops/handlers/{_shared,trackers,index}) +- 코드 라인: slashRouter 4,174→3,448 (–726), agent.ts (–66) +- 미래 부담 감소: 새 verification block 추가 1곳, 새 store 1곳, 새 hook 1곳 +- 감사 5건 중 4건 완료, 1건 (slashRouter 나머지) 보류 — 패턴 입증 후 추후 라운드 + +**신규 패키징:** `astra-2.2.197.vsix`. + +--- + + + +## v2.2.196 (2026-05-29) +### 🏗️ 리팩터링 라운드 2 — slashRouter handler split (trackers cluster) +아키텍처 감사의 HIGH 1번 (`slashRouter.ts` 4,174줄 god-file) 해결 시작. 첫 클러스터(트래커 3개) 를 도메인별 파일로 분리해 패턴 확립. 나머지 클러스터(coordination/communication/dashboards) 는 다음 라운드. + +**slashRouter.ts: 4,174 → 3,448줄 (–726, –17%)** + +**신규 구조: `src/features/teamops/handlers/`** + +``` +src/features/teamops/handlers/ +├── _shared.ts ← fmtKrw, parseAmount, daysUntil, parseTaskOwner, +│ STAGE_ORDER, TERMINAL_STAGES, stageEmoji +├── trackers.ts ← runRunway, runCustomers, runHire + 각자 helpers +│ + registerSlashCommand 3건 자기 등록 +└── index.ts ← 배럴 (`import './trackers'`) +``` + +**Wiring:** +- `src/extension.ts` — `import './features/teamops/handlers'` side-effect import (entry point, 순환 import 회피) +- `src/features/datacollect/slashRouter.ts` — `chunk` + `Webview` 인터페이스 export, 옛 helpers 는 `_shared.ts` 에서 alias import 로 남은 인라인 핸들러(morning/evening/cohort/weekly/blocked/standup) 호환 유지 + +**패턴 입증 (다음 라운드 청사진):** +- `trackers.ts` 가 작동하는 한, 같은 패턴으로 다음 클러스터: + - `coordination.ts` — task / decisions / onesie / blocked / standup + - `communication.ts` — draft / feedback / meet + - `dashboards.ts` — morning / evening / cohort / weekly +- 각 클러스터 = 새 파일 + 옛 코드 sed-delete + `index.ts` 에 한 줄 추가 + +**Phase 1 (v2.2.195) 도 함께:** eventSourcedStore + SystemPromptBlock registry — 이미 출시. 합쳐서 감사 HIGH 2건 중 1.5건 해결, MED 1건 (4 store 중복) 해결. + +**아키텍처 감사 후속 진행도:** +| Sev | 항목 | 상태 | +|---|---|---| +| HIGH | slashRouter god-file | 🟡 17% 축소 (trackers 분리). 나머지 v2.2.197+ | +| HIGH | 5-block 체인 (system prompt) | ✅ 완료 (v2.2.195) | +| MED | 4 store 중복 | ✅ 완료 (v2.2.195) | +| MED | terminology+termValidator 2캐시 | ⏳ v2.2.197 | +| LOW | _maybeEmitDevilRebuttal 류 hook 패턴 | ⏳ v2.2.197 | + +**기능 변경 없음 — 순수 구조 정리.** /runway, /customers, /hire 동작 동일. + +**신규 패키징:** `astra-2.2.196.vsix`. + +--- + + + +## v2.2.195 (2026-05-29) +### 🏗️ 리팩터링 라운드 1 — eventSourcedStore + SystemPromptBlock registry +아키텍처 감사 결과 (HIGH 2건, MED 3건) 중 가장 효과·비용 비율 좋은 2개. 기능 변경 없음 — 순수 구조 정리. + +**Phase 1 — `src/features/_shared/eventSourcedStore.ts` (신규)** + +배경: customers/hire/runway/feedback 4개 store 의 `getXFilePath() + readX() + appendX()` 가 byte-for-byte 중복 (~240줄). + +- `createEventStore({ relPath, validate })` — generic factory +- 4개 store 가 각각 ~12줄로 축소 (event type + validator + `computeStates` 만 남음) +- BOM/인코딩 fix 같은 edge case 가 *한 곳* 에서 전파 +- 도메인 로직 (`computeCustomerStates`, `computeCandidateStates`, `computeRunwayStatus`) 은 그대로 도메인 파일에 남음 — I/O 만 추상화 + +```typescript +// 옛 customersStore (60줄): import fs/path/vscode + getCustomersFilePath + readEvents + appendEvent +// 새 (5줄): +const _store = createEventStore({ relPath, validate }); +export const getCustomersFilePath = _store.getFilePath; +export const readEvents = _store.read; +export const appendEvent = _store.append; +``` + +**Phase 2 — SystemPromptBlock registry (5 필드 → 1 Map)** + +배경: 새 verification 블록 추가 시 *5곳 편집 강제* — `_turnCtx` 필드 + `resetTurnContext` + `buildAstraModeSystemPrompt` 인자 + casual ternary + template literal 위치. 5번 반복 (conflict/cove/intent/citation/terminology). 이 패턴이 god-file 만드는 길. + +- [agent.ts](src/agent.ts) `_turnCtx`: 5 named `string` 필드 → 1 `dynamicBlocks: Map` +- [agent.ts](src/agent.ts) `resetTurnContext()`: 5 reset → 1 `.clear()` +- [agent.ts](src/agent.ts) `buildAstraModeSystemPrompt` 호출: 5 named param → 1 `dynamicBlocks` param +- [buildAstraModeSystemPrompt.ts](src/agent/handlePrompt/buildAstraModeSystemPrompt.ts): 5 ternary gate + 5 위치 삽입 → 1 for-loop join +- [memoryContext.ts](src/lib/contextBuilders/memoryContext.ts): 5 `turnCtx.X = build...` → 5 `blocks.set('id', build...)` (한 줄씩, 새 블록은 여기서 set 한 줄만 추가) + +**효과 측정:** +- 새 블록 추가 = **25 → 1** edit 지점 (5 파일 × 5 step → 1 set call) +- 옛 5 ternary `(!isCasualConversation && X && X.trim()) ? '\n\n' + X : ''` → 1 loop, casual gate 1번 +- 블록 순서 변경 = memoryContext 의 set 순서 변경만 (옛: template literal 재배치) + +**아키텍처 감사 후속 작업 (예정):** +- v2.2.196 — slashRouter 4,174줄 handler 도메인별 분할 (HIGH 1, 미완) +- v2.2.197 — PostAnswerHook registry (DevilRebuttal/SelfCheck/TermValidator 패턴 통합) + mtimeFileCache 공유 유틸 (terminologyBlock + termValidator 캐시 통합) + +**기능 변경 없음 — 순수 구조 변화.** 모든 verification 블록 동작 동일, 모든 store 동작 동일. + +**신규 패키징:** `astra-2.2.195.vsix`. + +--- + + + ## v2.2.194 (2026-05-29) ### 🔤 Post-gen Term Validator — 결정론적 글로서리 검증 (엔진 변경) v2.2.192 의 Terminology Dictionary 가 *instructional* (LLM 에게 "표준 표기 사용" 지시) 였다면, 이건 *deterministic* — LLM 이 지시를 안 따랐을 때 결정론적 정규식 스캔으로 catch. diff --git a/docs/records/ConnectAI/bugs/BUG-0015-짚어둘-관찰-사항-참고용-face-api-js-환경-face-api-js는-원래-브라우저용입니다-node-단.md b/docs/records/ConnectAI/bugs/BUG-0015-짚어둘-관찰-사항-참고용-face-api-js-환경-face-api-js는-원래-브라우저용입니다-node-단.md new file mode 100644 index 0000000..e97d14f --- /dev/null +++ b/docs/records/ConnectAI/bugs/BUG-0015-짚어둘-관찰-사항-참고용-face-api-js-환경-face-api-js는-원래-브라우저용입니다-node-단.md @@ -0,0 +1,16 @@ +# Bug: 짚어둘 관찰 사항 (참고용) face-api.js 환경: face-api.js는 원래 브라우저용입니다. Node 단독 실행 시 @tensorfl... + +## Date +2026-06-01 + +## Symptom +짚어둘 관찰 사항 (참고용) face-api.js 환경: face-api.js는 원래 브라우저용입니다. Node 단독 실행 시 @tensorflow/tfjs-node + canvas 네이티브 바인딩이 필요해 빌드가 까다롭습니다. Electron 렌더러(브라우저 환경)에서 돌리면 가장 자연스럽습니다 → 아래 1번 질문과 연결됩니다. 정확도 KPI ≥98%: 유사도 임계값(threshold)과 얼굴당 reference 사진 수에 크게 좌우됩니다. reference를 인물당 여러 장 등록받는 걸 권장합니다(평균 descriptor). 데이터 무결성 0 Error: '이동' 중 실패 대비 — 이동은 사실상 "복사 후 검증 후 원본 삭제" 순서로 구현해야 안전합니다. 이 부분은 제가 기본값으로 안전하게 설계하겠습니다. Move vs Copy 3인 케이스: PRD는 2인까지만 예시. 3명 모두 포함된 사진이면 "1번=이동, 2·3번=복사"로 일반화하면 됩니다(아래 4번에서 확정). 아래 4가지... + +## Cause +Captured automatically from the current conversation. Confirm root cause during follow-up review if needed. + +## Fix +결론: 제공해주신 관찰 사항은 기술적 구현의 난이도와 데이터 안정성을 결정짓는 매우 핵심적인 가이드라인입니다. 특히 `face-api.js`의 실행 환경(Electron vs Node)에 대한 판단과, 데이터 무결성을 위해 '복사 후 검증 후 삭제'라는 안전장치를 설계 원칙으로 세우신 점은 아키텍처의 신뢰도를 높이는 매우 탁월한 결정입니다. 제시해주신 4가지 사항을 바탕으로, 제가 설계를 진행하기 위해 확정해야 할 최종 의사결정 항목들을 정리하였습니다. 이 중 결정된 내용을 바탕으로 즉시 상세 설계에 착수하겠습니다. 1. 실행 환경 (Runtime Environment) - 현재: Electron 렌더러(브라우저 환경)를 권장하며, Node 단독 실행 시의 복잡성을 인지함. - 결정 필요: 타겟 플랫폼을 'Electron 기반 데스크톱 앱'으로 한정할 것인지, 아니면 서버 사이드(Node.js) 로직이 반드시 포함된 구조로 가져갈 것인지 확정해야 합니다. 2. 인식 정확도 전략 (A... + +## Prevention +Keep automatic records tied to the active project and verify the relevant test or reproduction path. diff --git a/docs/records/ConnectAI/chronicle.config.json b/docs/records/ConnectAI/chronicle.config.json index b0d6a99..10bbf00 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-29T07:30:36.702Z" + "updatedAt": "2026-06-01T02:30:37.893Z" } diff --git a/docs/records/ConnectAI/timeline.md b/docs/records/ConnectAI/timeline.md index 257ca5d..a6a709c 100644 --- a/docs/records/ConnectAI/timeline.md +++ b/docs/records/ConnectAI/timeline.md @@ -231,3 +231,6 @@ ## 2026-05-29 - Auto discussion record created: discussions\2026-05-29_진행해.md + +## 2026-06-01 +- Auto bug record created: bugs\BUG-0015-짚어둘-관찰-사항-참고용-face-api-js-환경-face-api-js는-원래-브라우저용입니다-node-단.md diff --git a/package-lock.json b/package-lock.json index c2c918e..4433408 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "astra", - "version": "2.2.194", + "version": "2.2.201", "license": "MIT", "dependencies": { "@lmstudio/sdk": "^1.5.0", diff --git a/package.json b/package.json index a28b522..9f99688 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.194", + "version": "2.2.201", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", diff --git a/src/agent.ts b/src/agent.ts index a496f06..c7c0e1e 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -164,8 +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 { validateTermUsage, formatTermValidatorFooter } from './agent/termValidator'; +import { runPostAnswerHooks } from './agent/postAnswerHooks'; import { applyAutoContinuation } from './agent/handlePrompt/applyAutoContinuation'; export interface ChatMessage { @@ -287,28 +286,26 @@ 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; + /** + * 동적 시스템 프롬프트 블록 레지스트리 — turn 마다 memoryContext 가 채우고 + * buildAstraModeSystemPrompt 가 iterate 해서 prompt 에 주입. + * + * 옛 구조: conflictWarnings/coveChecklist/intentClarification/citationTrace/terminology + * 5개 named field + 5개 reset + 5개 named param + 5개 ternary gate (총 25곳 edit). + * 새 구조: 1 Map. 새 블록 추가 = 1 set call. + * + * Key 는 디버그·재정의용 id (예: 'conflict-warnings'). Value 는 이미 빌드된 + * 블록 본문 — 빈 문자열이면 주입 안 함. casual mode 게이팅은 호출자가 처리. + */ + dynamicBlocks: Map; /** 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: '', + dynamicBlocks: new Map(), selfCheckSources: [], - terminology: '', }; /** Per-turn state 일괄 정리. turn 시작/abort/load session 시 호출. */ @@ -316,12 +313,8 @@ 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.dynamicBlocks.clear(); this._turnCtx.selfCheckSources = []; - this._turnCtx.terminology = ''; } private readonly options: AgentExecutorOptions; @@ -673,11 +666,7 @@ 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, + dynamicBlocks: this._turnCtx.dynamicBlocks, }); // Context budget computation → src/agent/handlePrompt/computeBudgetedRequest.ts const imageCount = (reqMessages as any[]) @@ -1221,25 +1210,21 @@ export class AgentExecutor { memoryLayers: this._turnCtx.retrieval?.usedMemoryLayers ?? [], note: `continuations=${continuationCount} historyDropped=${reqMessages.length - budgetedHistory.length}`, }); - // ── Devil Agent (도현) — 비활성 시 silent skip. 활성 시 별도 LLM 호출로 반박 카드 emit. ── - // 비동기 — main turn 완료에 영향 없음. 실패해도 main 답변은 보존됨. - void this._maybeEmitDevilRebuttal({ + // ── Post-answer hooks (v2.2.197) — Devil + SelfCheck + TermValidator 통합 레지스트리. ── + // 새 hook 추가 = `src/agent/postAnswerHooks/index.ts` 에 한 객체 push. + // 안전 fallback 내장 — 한 hook 실패가 다른 hook / main turn 영향 없음. + runPostAnswerHooks({ userPrompt: prompt || '', assistantAnswer: finalAssistantContent, baseUrl: ollamaUrl, modelName: actualModel, contextLength: ctxLimits.contextLength, engine, + selfCheckSources: this._turnCtx.selfCheckSources, + callNonStreaming: (p) => this.callNonStreaming(p), + getAbortSignal: () => this.abortController?.signal, + getWebview: () => this.webview, }); - // ── Post-hoc Self-Check (v2.2.191) — 별도 LLM 호출 1회로 답변 사후 검증. ── - // 비동기 — Devil 과 동일 패턴. 결과를 footer 한 줄로 append. - void this._maybePostHocSelfCheck({ - userPrompt: prompt || '', - assistantAnswer: finalAssistantContent, - }); - // ── Term Validator (v2.2.194) — 결정론적 정규식 스캔. LLM 호출 없음, 즉시 실행. ── - // 글로서리 forbidden 단어가 답변에 등장 시 footer flag. 위반 없으면 ✓. - this._maybeRunTermValidator(finalAssistantContent); } else { this.webview.postMessage({ type: 'streamChunk', value: finalAssistantContent }); } @@ -1394,73 +1379,6 @@ export class AgentExecutor { * "lock() request could not be registered" error this method is helping * to avoid. */ - /** - * Devil Agent 반박 emit — main turn 완료 직후 호출 (fire-and-forget). - * 비활성 시 즉시 return. 활성 시 별도 LLM 호출 (callNonStreaming 재사용) 로 짧은 비판 생성. - * 성공 시 webview 에 'devilRebuttal' 메시지 전송 → UI 가 카드로 렌더. - */ - private async _maybeEmitDevilRebuttal(opts: { - userPrompt: string; - assistantAnswer: string; - baseUrl: string; - modelName: string; - contextLength: number; - engine: 'lmstudio' | 'ollama'; - }): Promise { - return maybeEmitDevilRebuttalFn({ - getAbortSignal: () => this.abortController?.signal, - callNonStreaming: (p) => this.callNonStreaming(p), - getWebview: () => this.webview, - }, 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 */ } - } - - /** - * Post-gen Term Validator — 글로서리 forbidden 단어가 답변에 등장하는지 결정론적 스캔. - * LLM 호출 없음, 동기 실행 (수 ms). 글로서리 없거나 disabled 면 silent no-op. - */ - private _maybeRunTermValidator(answer: string): void { - try { - const cfg = getConfig(); - if (cfg.termValidatorEnabled === false) return; - if (!answer || !answer.trim()) return; - const result = validateTermUsage(answer, cfg.glossaryPath || '.astra/glossary.md'); - if (!result.ran || result.dictionarySize === 0) return; // 글로서리 없거나 비어 있음 → 표시 안 함 - const footer = formatTermValidatorFooter(result); - if (footer) this.webview?.postMessage({ type: 'streamChunk', value: footer }); - } catch { /* swallow — validator never breaks the turn */ } - } private async callNonStreaming(params: { baseUrl: string; diff --git a/src/agent/handlePrompt/buildAstraModeSystemPrompt.ts b/src/agent/handlePrompt/buildAstraModeSystemPrompt.ts index d90fc8a..287d70d 100644 --- a/src/agent/handlePrompt/buildAstraModeSystemPrompt.ts +++ b/src/agent/handlePrompt/buildAstraModeSystemPrompt.ts @@ -23,27 +23,12 @@ export interface BuildAstraModeSystemPromptInput { /** From this._turnCtx.knowledgeMix — pass null when absent. */ knowledgeMix: any; /** - * [CONFLICT WARNINGS] 블록 — buildConflictWarningsBlock 산출. 빈 문자열이면 충돌 없음 → 주입 안 함. - * v4 정책 텍스트의 "[CONFLICT WARNING] 플래그" 참조를 실제 데이터로 뒷받침. + * 동적 시스템 프롬프트 블록 Map (id → 본문). memoryContext 가 채움. + * 옛 named param 5개 (conflictWarningsCtx/coveChecklistCtx/intentClarificationCtx/ + * citationTraceCtx/terminologyCtx) 를 통합. casual 모드는 자동 skip. + * 등록 순서대로 [CONTEXT] *밖* 에 join 되어 주입. */ - 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; + dynamicBlocks?: Map; } export function buildAstraModeSystemPrompt(input: BuildAstraModeSystemPromptInput): string { @@ -62,11 +47,7 @@ export function buildAstraModeSystemPrompt(input: BuildAstraModeSystemPromptInpu isCasualConversation, localPathContext, knowledgeMix, - conflictWarningsCtx, - coveChecklistCtx, - intentClarificationCtx, - citationTraceCtx, - terminologyCtx, + dynamicBlocks, } = input; // 기존 Astra 모드 (에이전트 미선택) @@ -105,29 +86,15 @@ export function buildAstraModeSystemPrompt(input: BuildAstraModeSystemPromptInpu // priorConclusionCtx 는 modeBridgeCtx 와 같은 위치 (base systemPrompt 직후) — 모델이 // 자기 직전 결론을 anchor 로 잡고 사용자의 follow-up 을 그 결론에 대한 정정으로 해석하게. const priorConclusionBlock = priorConclusionCtx ? '\n\n' + priorConclusionCtx : ''; - // [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}`; + // 동적 블록 join — [CONTEXT] *밖* 에 주입돼 token-truncation 시 보호. Casual 모드면 + // RAG context 자체를 안 쓰므로 동적 블록도 의미 없음 → 일괄 skip. + // 등록 순서대로 join (memoryContext 가 메모리 호출 순으로 set — 현재: intent → + // terminology → conflict → cove → citation). 빈 본문 entry 는 자동 제외. + let dynamicBlocksJoined = ''; + if (!isCasualConversation && dynamicBlocks && dynamicBlocks.size > 0) { + for (const body of dynamicBlocks.values()) { + if (body && body.trim()) dynamicBlocksJoined += '\n\n' + body; + } + } + return `${systemPrompt}${modeBridgeCtx ? '\n\n' + modeBridgeCtx : ''}${priorConclusionBlock}${designerCtx}${projectArchitectureCtx}${localProjectKnowledgeCtx}${thinkingPartnerCtx}${astraStanceCtx}${secondBrainTraceCtx}${v4PolicyCtx}${knowledgeMixCtx}${casualCtx}${dynamicBlocksJoined}\n\n[CONTEXT]\n${memoryCtx}\n${knowledgeContextForPrompt}\n${contextBlock}\n[/CONTEXT]\n${negativeCtx}`; } diff --git a/src/agent/postAnswerHooks/index.ts b/src/agent/postAnswerHooks/index.ts new file mode 100644 index 0000000..b7930b4 --- /dev/null +++ b/src/agent/postAnswerHooks/index.ts @@ -0,0 +1,96 @@ +/** + * Post-answer hook registry — 답변 완료 후 실행되는 부가 작업 모음. + * + * 새 hook 추가 = 1 객체 push. agent.ts 는 이 배열을 iterate 만 함. + * + * 현재 등록 순서 (v2.2.197): + * 1. devilRebuttal — Devil Agent 반박 카드 (비활성 시 silent skip) + * 2. postHocSelfCheck — 답변 검증 LLM 호출 (opt-in, 기본 OFF) + * 3. termValidator — 결정론적 글로서리 forbidden 검사 (기본 ON) + */ + +import type { PostAnswerHook, PostAnswerHookContext } from './types'; +import { maybeEmitDevilRebuttal as maybeEmitDevilRebuttalFn } from '../llm/devilRebuttal'; +import { postHocSelfCheck, formatSelfCheckFooter, DEFAULT_SELF_CHECK_OPTIONS } from '../postHocSelfCheck'; +import { validateTermUsage, formatTermValidatorFooter } from '../termValidator'; +import { getConfig } from '../../config'; + +const devilRebuttalHook: PostAnswerHook = { + id: 'devil-rebuttal', + runAsync: true, + async run(ctx: PostAnswerHookContext): Promise { + await maybeEmitDevilRebuttalFn( + { + getAbortSignal: ctx.getAbortSignal, + callNonStreaming: ctx.callNonStreaming, + // agent.ts 에서 vscode.Webview 를 통과시키므로 실런타임 호환. 타입 cast 로 hook 일반화. + getWebview: ctx.getWebview as any, + }, + { + userPrompt: ctx.userPrompt, + assistantAnswer: ctx.assistantAnswer, + baseUrl: ctx.baseUrl, + modelName: ctx.modelName, + contextLength: ctx.contextLength, + engine: ctx.engine, + }, + ); + }, +}; + +const postHocSelfCheckHook: PostAnswerHook = { + id: 'self-check', + runAsync: true, + async run(ctx: PostAnswerHookContext): Promise { + const cfg = getConfig(); + if (!cfg.selfCheckEnabled) return; + if (!ctx.userPrompt.trim() || !ctx.assistantAnswer.trim()) return; + const model = (cfg.selfCheckModel || '').trim() || cfg.defaultModel; + if (!model || !cfg.ollamaUrl) return; + + const result = await postHocSelfCheck(ctx.userPrompt, ctx.assistantAnswer, ctx.selfCheckSources, { + ollamaUrl: cfg.ollamaUrl, + model, + timeoutMs: (cfg.selfCheckTimeoutSec ?? 6) * 1000, + excerptLength: DEFAULT_SELF_CHECK_OPTIONS.excerptLength, + maxSources: DEFAULT_SELF_CHECK_OPTIONS.maxSources, + }); + const footer = formatSelfCheckFooter(result, model); + ctx.getWebview()?.postMessage({ type: 'streamChunk', value: footer }); + }, +}; + +const termValidatorHook: PostAnswerHook = { + id: 'term-validator', + runAsync: false, + run(ctx: PostAnswerHookContext): void { + const cfg = getConfig(); + if (cfg.termValidatorEnabled === false) return; + if (!ctx.assistantAnswer || !ctx.assistantAnswer.trim()) return; + const result = validateTermUsage(ctx.assistantAnswer, cfg.glossaryPath || '.astra/glossary.md'); + if (!result.ran || result.dictionarySize === 0) return; + const footer = formatTermValidatorFooter(result); + if (footer) ctx.getWebview()?.postMessage({ type: 'streamChunk', value: footer }); + }, +}; + +export const POST_ANSWER_HOOKS: PostAnswerHook[] = [ + devilRebuttalHook, + postHocSelfCheckHook, + termValidatorHook, +]; + +/** 모든 hook 을 안전하게 실행 — 한 hook 의 throw 가 다른 hook 막지 않음. */ +export function runPostAnswerHooks(ctx: PostAnswerHookContext): void { + for (const hook of POST_ANSWER_HOOKS) { + try { + if (hook.runAsync) { + void Promise.resolve(hook.run(ctx)).catch(() => { /* swallow */ }); + } else { + hook.run(ctx); + } + } catch { /* hook never breaks the turn */ } + } +} + +export type { PostAnswerHook, PostAnswerHookContext } from './types'; diff --git a/src/agent/postAnswerHooks/types.ts b/src/agent/postAnswerHooks/types.ts new file mode 100644 index 0000000..b145216 --- /dev/null +++ b/src/agent/postAnswerHooks/types.ts @@ -0,0 +1,48 @@ +/** + * Post-Answer Hook 인터페이스 — 답변 streaming 완료 후 실행되는 부가 작업. + * + * 옛 구조: `agent.ts` 의 `_maybeEmitDevilRebuttal`, `_maybePostHocSelfCheck`, + * `_maybeRunTermValidator` 3개 private method. 새 hook 추가 시 (1) method 정의 + * (2) import (3) call site `void this._maybeX(...)` — 3곳 편집. + * + * 새 구조: 각 hook 이 `PostAnswerHook` 객체로 자기 module 에. agent.ts 는 1 loop. + * 새 hook = 1 파일 + index.ts 에 1 push. + */ + +/** + * Hook 이 webview 에 postMessage 만 하면 되므로 vscode.Webview 또는 slashRouter 의 + * 간이 Webview 둘 다 만족하는 최소 인터페이스로 정의. + */ +interface PostMessageWebview { + postMessage(msg: any): Thenable | boolean; +} + +export interface PostAnswerHookContext { + userPrompt: string; + assistantAnswer: string; + /** LLM 호출용 — Devil/SelfCheck 가 사용. */ + baseUrl: string; + modelName: string; + contextLength: number; + engine: 'lmstudio' | 'ollama'; + /** Self-check 용 출처 미리보기. memoryContext 가 turnCtx 에 채움. */ + selfCheckSources: Array<{ title: string; excerpt: string }>; + /** Devil Agent 가 호출 — non-streaming LLM. */ + callNonStreaming: (params: any) => Promise<{ text: string; stopReason?: string }>; + /** Abort signal accessor. */ + getAbortSignal: () => AbortSignal | undefined; + /** Webview accessor — hook 결과 streamChunk 송출. vscode.Webview / 간이 Webview 호환. */ + getWebview: () => PostMessageWebview | undefined; +} + +export interface PostAnswerHook { + /** 디버그·중복 방지용. */ + id: string; + /** + * true → fire-and-forget (async, main turn 영향 없음). + * false → 동기 실행 (LLM 호출 없는 결정론적 hook, 예: termValidator). + */ + runAsync: boolean; + /** 실행 본문. throw 해도 다른 hook 영향 없음 (caller 가 try/catch 로 감쌈). */ + run(ctx: PostAnswerHookContext): void | Promise; +} diff --git a/src/agent/termValidator.ts b/src/agent/termValidator.ts index 37ad281..ee9c8f0 100644 --- a/src/agent/termValidator.ts +++ b/src/agent/termValidator.ts @@ -21,6 +21,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; +import { createMtimeFileCache } from '../lib/mtimeFileCache'; const DEFAULT_GLOSSARY_REL_PATH = '.astra/glossary.md'; @@ -30,15 +31,11 @@ interface ForbiddenEntry { source: 'x-marker' | 'banned-section'; } -interface ValidatorCache { - mtime: number; - entries: ForbiddenEntry[]; -} - -const _cache = new Map(); +/** Parsed forbidden entries 캐시 — mtime 기반, 파일 편집 시 자동 재read+parse. */ +const _parsedCache = createMtimeFileCache('term-validator', (raw) => parseGlossaryRaw(raw)); export function clearTermValidatorCache(): void { - _cache.clear(); + _parsedCache.clear(); } function getGlossaryFilePath(relPath: string): string | null { @@ -62,80 +59,64 @@ function isValidForbiddenToken(token: string): boolean { } /** - * Glossary markdown 파싱. mtime 캐시. + * Glossary markdown raw → forbidden entries. mtime 캐시는 mtimeFileCache 가 담당. */ -function parseGlossary(filePath: string): ForbiddenEntry[] { - try { - if (!fs.existsSync(filePath)) return []; - const st = fs.statSync(filePath); - const cached = _cache.get(filePath); - if (cached && cached.mtime === st.mtimeMs) return cached.entries; +function parseGlossaryRaw(content: string): ForbiddenEntry[] { + const entries: ForbiddenEntry[] = []; - const content = fs.readFileSync(filePath, 'utf-8'); - const entries: ForbiddenEntry[] = []; - - // ─── Pattern 1: **Canonical** (X: typo1, typo2, ...) ─── - // `**ASTRA** (X: astra, Astra 외)` — Astra 외 같은 description 은 후보 필터로 제거. - const xPattern = /\*\*([^*]+)\*\*\s*\(X:\s*([^)]+)\)/g; - let m: RegExpExecArray | null; - while ((m = xPattern.exec(content)) !== null) { - const canonical = m[1].trim(); - const variants = m[2].split(',').map((v) => v.trim()); - for (const v of variants) { - if (isValidForbiddenToken(v)) { - entries.push({ forbidden: v, suggested: canonical, source: 'x-marker' }); - } + // ─── Pattern 1: **Canonical** (X: typo1, typo2, ...) ─── + // `**ASTRA** (X: astra, Astra 외)` — Astra 외 같은 description 은 후보 필터로 제거. + const xPattern = /\*\*([^*]+)\*\*\s*\(X:\s*([^)]+)\)/g; + let m: RegExpExecArray | null; + while ((m = xPattern.exec(content)) !== null) { + const canonical = m[1].trim(); + const variants = m[2].split(',').map((v) => v.trim()); + for (const v of variants) { + if (isValidForbiddenToken(v)) { + entries.push({ forbidden: v, suggested: canonical, source: 'x-marker' }); } } - - // ─── Pattern 2: 금지 섹션 내 `- ❌ "..."` 또는 `- ❌ ...` ─── - // H2/H3 제목에 "금지" 또는 "비추" 포함된 섹션 본문만 스캔. - const sectionRegex = /^(#{2,3})\s+(.+)$/gm; - const sections: { headerEnd: number; nextHeaderStart: number; title: string }[] = []; - let sm: RegExpExecArray | null; - let lastIdx = 0; - const matches: { idx: number; level: number; title: string }[] = []; - while ((sm = sectionRegex.exec(content)) !== null) { - matches.push({ idx: sm.index + sm[0].length, level: sm[1].length, title: sm[2].trim() }); - } - for (let i = 0; i < matches.length; i++) { - const cur = matches[i]; - const next = matches[i + 1]; - sections.push({ - headerEnd: cur.idx, - nextHeaderStart: next ? next.idx : content.length, - title: cur.title, - }); - } - for (const sec of sections) { - if (!/금지|비추|forbidden|avoid|don'?t/i.test(sec.title)) continue; - const body = content.slice(sec.headerEnd, sec.nextHeaderStart); - const itemRe = /^-\s*❌\s*(?:"([^"]+)"|'([^']+)'|([^\n—]+?))(?:\s*[—–-].*)?$/gm; - let im: RegExpExecArray | null; - while ((im = itemRe.exec(body)) !== null) { - const phrase = (im[1] || im[2] || im[3] || '').trim(); - if (isValidForbiddenToken(phrase)) { - entries.push({ forbidden: phrase, source: 'banned-section' }); - } - } - } - - // Dedup - const seen = new Set(); - const deduped = entries.filter((e) => { - const k = `${e.source}::${e.forbidden.toLowerCase()}`; - if (seen.has(k)) return false; - seen.add(k); - return true; - }); - - _cache.set(filePath, { mtime: st.mtimeMs, entries: deduped }); - lastIdx = 0; // suppress lint unused - void lastIdx; - return deduped; - } catch { - return []; } + + // ─── Pattern 2: 금지 섹션 내 `- ❌ "..."` 또는 `- ❌ ...` ─── + // H2/H3 제목에 "금지" 또는 "비추" 포함된 섹션 본문만 스캔. + const sectionRegex = /^(#{2,3})\s+(.+)$/gm; + const sections: { headerEnd: number; nextHeaderStart: number; title: string }[] = []; + const matches: { idx: number; level: number; title: string }[] = []; + let sm: RegExpExecArray | null; + while ((sm = sectionRegex.exec(content)) !== null) { + matches.push({ idx: sm.index + sm[0].length, level: sm[1].length, title: sm[2].trim() }); + } + for (let i = 0; i < matches.length; i++) { + const cur = matches[i]; + const next = matches[i + 1]; + sections.push({ + headerEnd: cur.idx, + nextHeaderStart: next ? next.idx : content.length, + title: cur.title, + }); + } + for (const sec of sections) { + if (!/금지|비추|forbidden|avoid|don'?t/i.test(sec.title)) continue; + const body = content.slice(sec.headerEnd, sec.nextHeaderStart); + const itemRe = /^-\s*❌\s*(?:"([^"]+)"|'([^']+)'|([^\n—]+?))(?:\s*[—–-].*)?$/gm; + let im: RegExpExecArray | null; + while ((im = itemRe.exec(body)) !== null) { + const phrase = (im[1] || im[2] || im[3] || '').trim(); + if (isValidForbiddenToken(phrase)) { + entries.push({ forbidden: phrase, source: 'banned-section' }); + } + } + } + + // Dedup + const seen = new Set(); + return entries.filter((e) => { + const k = `${e.source}::${e.forbidden.toLowerCase()}`; + if (seen.has(k)) return false; + seen.add(k); + return true; + }); } export interface TermViolation { @@ -181,7 +162,7 @@ export function validateTermUsage( if (!fp || !fs.existsSync(fp)) { return { ran: false, dictionarySize: 0, violations: [], totalViolations: 0 }; } - const entries = parseGlossary(fp); + const entries = _parsedCache.read(fp) ?? []; if (entries.length === 0) { return { ran: true, dictionarySize: 0, violations: [], totalViolations: 0 }; } diff --git a/src/extension.ts b/src/extension.ts index afd0c12..8c01430 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,6 +1,11 @@ import * as vscode from 'vscode'; import * as fs from 'fs'; import * as path from 'path'; +// TeamOps + System + Datacollect handler 자기 등록 — side-effect import. slashRouter +// 가 이미 로드된 후 등록되도록 entry point 에서. (v2.2.196~201 도메인별 파일 분리) +import './features/teamops/handlers'; +import './features/system/handlers'; +import './features/datacollect/handlers'; // axios removed in favor of native fetch import { _getBrainDir, diff --git a/src/features/_shared/eventSourcedStore.ts b/src/features/_shared/eventSourcedStore.ts new file mode 100644 index 0000000..4392bd3 --- /dev/null +++ b/src/features/_shared/eventSourcedStore.ts @@ -0,0 +1,83 @@ +/** + * Generic event-sourced store — append-only `.jsonl` 파일 1개를 읽고/쓰는 공통 기반. + * + * 배경: customers, hire, runway, feedback 4개 store 가 같은 패턴 4번 반복 + * (getXFilePath / readX / appendX / countX) — byte-for-byte 중복 ~240줄. + * 한 곳에서 잡으면 BOM/인코딩 edge case 등 fix 도 한 번에 전파. + * + * 사용: + * const store = createEventStore({ + * relPath: '.astra/customers.jsonl', + * validate: (e) => typeof e.id === 'string' && typeof e.customerId === 'string', + * }); + * store.read(); store.append(event); store.count(); store.getFilePath(); + * + * 도메인별 로직 (computeStates 등) 은 그대로 도메인 파일에 남음 — 본 모듈은 + * I/O 만 추상화. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +export interface EventStoreOptions { + /** workspace root 기준 상대 경로. 예: '.astra/customers.jsonl'. */ + relPath: string; + /** 파싱된 객체가 유효한 event 인지 판정. false 면 malformed 로 skip. */ + validate: (e: unknown) => e is E; +} + +export interface EventStore { + getFilePath(): string | null; + read(): E[]; + append(event: E): { ok: true; filePath: string } | { ok: false; error: string }; + count(): number; +} + +export function createEventStore(opts: EventStoreOptions): EventStore { + function getFilePath(): string | null { + const folders = vscode.workspace.workspaceFolders; + if (!folders || folders.length === 0) return null; + return path.join(folders[0].uri.fsPath, opts.relPath); + } + + function read(): E[] { + const fp = getFilePath(); + if (!fp || !fs.existsSync(fp)) return []; + const out: E[] = []; + let content = ''; + try { content = fs.readFileSync(fp, 'utf-8'); } catch { return []; } + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const parsed = JSON.parse(trimmed); + if (opts.validate(parsed)) out.push(parsed); + } catch { /* skip malformed — append-only 라 손상 1줄이 전체 무력화하면 안 됨 */ } + } + return out; + } + + function append(event: E): { ok: true; filePath: string } | { ok: false; error: string } { + const fp = getFilePath(); + if (!fp) return { ok: false, error: '워크스페이스 폴더가 없어 저장 불가. VS Code 에서 폴더를 열어 주세요.' }; + try { + fs.mkdirSync(path.dirname(fp), { recursive: true }); + fs.appendFileSync(fp, JSON.stringify(event) + '\n', 'utf-8'); + return { ok: true, filePath: fp }; + } catch (e: any) { + return { ok: false, error: e?.message || String(e) }; + } + } + + function count(): number { + const fp = getFilePath(); + if (!fp || !fs.existsSync(fp)) return 0; + try { + const content = fs.readFileSync(fp, 'utf-8'); + return content.split('\n').filter((l) => l.trim()).length; + } catch { return 0; } + } + + return { getFilePath, read, append, count }; +} diff --git a/src/features/customers/customersStore.ts b/src/features/customers/customersStore.ts index deed63a..f791c5c 100644 --- a/src/features/customers/customersStore.ts +++ b/src/features/customers/customersStore.ts @@ -11,9 +11,7 @@ * 민감 정보(고객사 이름, 매출) 포함되므로 외부로 안 보냄 — 로컬 only. */ -import * as fs from 'fs'; -import * as path from 'path'; -import * as vscode from 'vscode'; +import { createEventStore } from '../_shared/eventSourcedStore'; const STORE_REL_PATH = '.astra/customers.jsonl'; @@ -55,47 +53,22 @@ export interface CustomerState { 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); -} +const _store = createEventStore({ + relPath: STORE_REL_PATH, + validate: (e): e is CustomerEvent => !!e + && typeof (e as any).id === 'string' + && typeof (e as any).customerId === 'string' + && typeof (e as any).type === 'string', +}); + +export const getCustomersFilePath = _store.getFilePath; +export const readEvents = _store.read; +export const appendEvent = _store.append; 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 별 현재 상태 도출. * diff --git a/src/features/datacollect/handlers.ts b/src/features/datacollect/handlers.ts new file mode 100644 index 0000000..c50dba5 --- /dev/null +++ b/src/features/datacollect/handlers.ts @@ -0,0 +1,744 @@ +/** + * Datacollect handlers — /research · /benchmark · /youtube · /blog · /wikify · /meet. + * + * v2.2.201 에서 slashRouter.ts 에서 분리. Datacollect bridge (port 3002) 통합 + * 슬래시 명령 클러스터 — NotebookLM Deep Research · Playwright 웹 스캔 · + * yt-dlp YouTube 자막 추출 · 본문 wikify · 회의록 합성. + * + * callLmSynthesis / repairKoreanGlitches 는 slashRouter 에 남음 (communication + * 핸들러도 사용하는 일반 LLM 호출 인프라). 본 파일은 *bridge 호출 시퀀스* 만 담당. + */ + +import * as vscode from 'vscode'; +import { promises as fsp } from 'fs'; +import { registerSlashCommand, chunk, type Webview } from './slashRouter'; +import { callLmSynthesis } from './llm'; +import { bridgeFetch, BRIDGE_API } from './bridgeClient'; +import { type SynthesisPart, buildSynthesisPrompt } from './prompts/synthesisPrompt'; +import { + type YoutubeAnalysisMode, + formatHms, + fullScriptFromSegments, + buildInfoExtractionPrompt, + build4LensPrompt, +} from './prompts/youtubePrompts'; +import { buildWikifyPrompt } from './prompts/wikifyPrompt'; +import { buildMeetPrompt } from './prompts/meetPrompt'; +import { createCalendarEvent, createTask, readCalendarConfig } from '../calendar'; +import { + addBusinessDays, + toYmd, + extractMeetingDate, + resolveTaskDate, + parseActionItems, +} from './scheduling/calendarHelpers'; + +// ───────────────────────────── /research ───────────────────────────── + +async function runResearch(topic: string, view: Webview | undefined): Promise { + if (!topic) { + chunk(view, `사용법: \`/research <주제>\`\n주제를 입력해 NotebookLM Deep Research를 시작합니다.\n`); + return true; + } + + chunk(view, `🚀 **Research 시작**: \`${topic}\`\n\n`); + const start = await bridgeFetch<{ success: boolean; notebookId: string; taskId: string }>( + BRIDGE_API.research.start, + { method: 'POST', body: JSON.stringify({ topic }) }, + { timeoutMs: 60_000 }, + ); + chunk(view, `- notebookId: \`${start.notebookId}\`\n- taskId: \`${start.taskId}\`\n\n⏳ 상태 polling (5초 간격, 최대 10분)…\n`); + + const deadline = Date.now() + 10 * 60_000; + const HEARTBEAT_MS = 30_000; + const MAX_CONSECUTIVE_FAILS = 5; + const COMPLETED_SET = new Set(['completed', 'done', 'success', 'finished']); + const FAILED_SET = new Set(['failed', 'error', 'cancelled', 'canceled', 'aborted']); + + let lastStatus = ''; + let lastChangeAt = Date.now(); + let consecutiveFails = 0; + let pollCount = 0; + let researchOk = false; + while (Date.now() < deadline) { + await new Promise(r => setTimeout(r, 5_000)); + pollCount++; + let st: { success: boolean; result: any } | undefined; + try { + st = await bridgeFetch<{ success: boolean; result: any }>( + `${BRIDGE_API.research.status}?notebookId=${encodeURIComponent(start.notebookId)}&taskId=${encodeURIComponent(start.taskId)}`, + { method: 'GET' }, + { timeoutMs: 60_000 }, + ); + consecutiveFails = 0; + } catch (e: any) { + consecutiveFails++; + if (consecutiveFails >= MAX_CONSECUTIVE_FAILS) { + chunk(view, `\n❌ Status polling 연속 실패 ${consecutiveFails}회 — bridge 가 응답하지 않습니다. 중단합니다.\n(원인: ${e?.message || String(e)})\n`); + return true; + } + chunk(view, `\n · status 호출 실패 ${consecutiveFails}/${MAX_CONSECUTIVE_FAILS} (${e?.message || 'unknown'})\n`); + continue; + } + const status = String(st.result?.status || st.result || '').trim().toLowerCase(); + if (status && status !== lastStatus) { + chunk(view, ` · ${status}\n`); + lastStatus = status; + lastChangeAt = Date.now(); + } else if (Date.now() - lastChangeAt > HEARTBEAT_MS) { + chunk(view, ` · ⏳ 대기 중 (${Math.round((Date.now() - lastChangeAt) / 1000)}s, 폴링 ${pollCount}회)\n`); + lastChangeAt = Date.now(); + } + if (COMPLETED_SET.has(status)) { researchOk = true; break; } + if (FAILED_SET.has(status)) { + chunk(view, `\n❌ Research 실패: ${JSON.stringify(st.result).slice(0, 400)}\n`); + return true; + } + } + + if (!researchOk) { + chunk(view, `\n❌ 10분 polling 후에도 완료 신호가 오지 않았습니다 (마지막 status: \`${lastStatus || '(없음)'}\`). 중단합니다.\n`); + return true; + } + + chunk(view, `\n📥 import…\n`); + await bridgeFetch(BRIDGE_API.research.import, { + method: 'POST', + body: JSON.stringify({ notebookId: start.notebookId, taskId: start.taskId }), + }, { + timeoutMs: 300_000, + onHeartbeat: (elapsedMs) => chunk(view, ` · import 진행 중 (${Math.round(elapsedMs / 1000)}s)\n`), + }); + + chunk(view, `🧪 synthesize…\n\n`); + const synth = await bridgeFetch<{ success: boolean; markdown?: string; result?: string }>( + BRIDGE_API.research.synthesize, + { + method: 'POST', + body: JSON.stringify({ notebookId: start.notebookId, topic, rootTopic: topic, includeKnowledgeConnections: true }), + }, + { + timeoutMs: 600_000, + onHeartbeat: (elapsedMs) => chunk(view, ` · synthesize LLM 작업 중 (${Math.round(elapsedMs / 1000)}s)\n`), + }, + ); + const md = synth.markdown || synth.result || '(빈 응답)'; + chunk(view, `---\n\n${md}\n`); + return true; +} + +// ───────────────────────────── /benchmark ───────────────────────────── + +async function runBenchmark(arg: string, view: Webview | undefined): Promise { + const tokens = arg.trim().split(/\s+/).filter(Boolean); + let url = ''; + let depthArg: number | undefined; + let pagesArg: number | undefined; + const restParts: string[] = []; + for (const t of tokens) { + const m = /^(depth|pages)=(\d+)$/i.exec(t); + if (m) { + if (m[1].toLowerCase() === 'depth') depthArg = Number(m[2]); + else pagesArg = Number(m[2]); + } else if (!url) { + url = t; + } else { + restParts.push(t); + } + } + if (!url) { + chunk(view, `사용법: \`/benchmark [depth=N] [pages=N] [보조 설명]\`\n예: \`/benchmark https://example.com depth=2 pages=12\`\n`); + return true; + } + const userContent = restParts.join(' '); + + const cfg = vscode.workspace.getConfiguration('g1nation'); + const crawlDepth = depthArg ?? (cfg.get('datacollectCrawlDepth', 1) ?? 1); + const maxPages = pagesArg ?? (cfg.get('datacollectMaxPages', 8) ?? 8); + + chunk(view, `🔍 **Web Benchmark 스캔**: ${url}\n(crawlDepth ${crawlDepth} · 최대 ${maxPages}페이지 · Playwright)\n\n⏳ 브라우저 기동 · 페이지 크롤링 · 디자인 토큰 추출 중…`); + + const t0 = Date.now(); + const heartbeat = setInterval(() => { + chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`); + }, 4000); + const scan = await bridgeFetch<{ success: boolean; scan: any; slug?: string }>( + BRIDGE_API.web.benchmarkScan, + { + method: 'POST', + body: JSON.stringify({ url, captureScreenshots: false, maxPages, crawlDepth }), + }, + { timeoutMs: 6 * 60_000 }, + ).finally(() => clearInterval(heartbeat)); + const s = scan.scan; + chunk(view, `\n✅ **스캔 완료** (${Math.round((Date.now() - t0) / 1000)}s · ${s?.sitemap?.totalPages ?? 1}페이지)\n\n`); + + const looksEmpty = !s?.meta?.title + && !(s?.design?.colors?.palette?.length) + && !s?.microcopy?.headline; + if (looksEmpty) { + chunk(view, `⚠️ **스캔 결과가 비어 있습니다.** URL이 정확한지, 사이트가 접근 가능한지(봇 차단·JS 전용 렌더링 포함) 확인하세요. 스캔한 URL: \`${url}\`\n\n`); + } + + const palette = s?.design?.colors?.palette?.slice(0, 5) || []; + const rawReport = [ + `### 메타`, + `- **title**: ${s?.meta?.title || '(없음)'}`, + `- **description**: ${s?.meta?.description || '(없음)'}`, + `- **lang**: ${s?.meta?.lang || '(없음)'}`, + ``, + `### 디자인 토큰 (상위)`, + `- **컬러 팔레트 Top 5**: ${palette.map((p: any) => `\`${p.value}\` (×${p.count})`).join(', ') || '(없음)'}`, + `- **컬러 비율**: ${s?.design?.colors?.composition?.ratioLabel || '(없음)'}`, + `- **Primary Font**: \`${s?.design?.typography?.primaryFont || '(없음)'}\``, + ``, + `### 사이트맵 (${s?.sitemap?.totalPages ?? 1}페이지, depth ${s?.sitemap?.crawlDepth ?? 0})`, + '```', + s?.sitemap?.ascii || '(없음)', + '```', + ].join('\n'); + + let finalReport: string; + if (looksEmpty) { + chunk(view, `(스캔이 비어 LLM 합성을 건너뜁니다.)\n\n`); + finalReport = rawReport; + } else { + const model = (cfg.get('defaultModel', '') || 'gemma4:e2b').trim(); + chunk(view, `🧪 **LLM 4-렌즈 합성** (3단계 · 모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…\n`); + try { + const parts: string[] = []; + for (const part of [1, 2, 3] as const) { + chunk(view, `\n · 합성 ${part}/3 진행 중…`); + const partT0 = Date.now(); + const out = await callLmSynthesis(buildSynthesisPrompt(s, userContent, part)); + if (!out) throw new Error(`LLM ${part}/3 응답이 비어 있습니다.`); + parts.push(out); + chunk(view, ` ✓ (${Math.round((Date.now() - partT0) / 1000)}s)`); + } + finalReport = parts.join('\n\n---\n\n'); + chunk(view, `\n\n`); + } catch (e: any) { + chunk(view, `\n\n⚠️ LLM 합성 실패: ${e?.message || String(e)}\n원시 스캔 요약만 표시·저장합니다. (LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`); + finalReport = rawReport; + } + } + + chunk(view, finalReport + '\n\n'); + + try { + const today = new Date().toISOString().slice(0, 10); + let host = url; + try { host = new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`).host; } catch { /* keep raw url */ } + const title = `웹벤치마크 ${host} ${today}`; + const fileMarkdown = [ + `# ${title}`, + ``, + `- **원본 URL**: ${url}`, + `- **스캔 시각**: ${new Date().toISOString()}`, + `- **크롤 옵션**: depth ${crawlDepth} · 최대 ${maxPages}페이지`, + `- **생성**: Astra /benchmark · Datacollect web-benchmark`, + ``, + finalReport, + ``, + ].join('\n'); + const savePath = (cfg.get('datacollectSavePath', '') || '').trim(); + const body: Record = { title, content: fileMarkdown }; + if (savePath) body.saveDir = savePath; + const saved = await bridgeFetch<{ success: boolean; path?: string }>( + BRIDGE_API.wiki.save, + { method: 'POST', body: JSON.stringify(body) }, + { timeoutMs: 30_000 }, + ); + chunk(view, `💾 **결과물 저장 완료**: \`${saved?.path || '(경로 미확인)'}\`\n`); + } catch (e: any) { + chunk(view, `⚠️ 결과물 저장 실패: ${e?.message || String(e)}\n`); + } + + return true; +} + +// ───────────────────────────── /youtube ───────────────────────────── + +function _looksLikeYoutubeChannelUrl(url: string): boolean { + return /youtube\.com\/(channel\/|@|c\/|user\/|playlist\?list=|playlist\/)/i.test(url) + || /youtube\.com\/[^/?#]+\/(videos|shorts|streams)\b/i.test(url); +} + +function _normalizeYoutubeUrl(url: string): string { + try { + const u = new URL(url); + if (!/youtube\.com$|youtube\.com\.|youtu\.be$/i.test(u.hostname)) return url; + const p = u.pathname; + if (/\/(watch|shorts|playlist|videos|streams|featured|community|about)\b/i.test(p)) return url; + if (u.hostname.includes('youtu.be')) return url; + if (/^\/(@[^/]+|channel\/[^/]+|c\/[^/]+|user\/[^/]+)\/?$/i.test(p)) { + u.pathname = p.replace(/\/?$/, '/videos'); + return u.toString(); + } + return url; + } catch { + return url; + } +} + +const YOUTUBE_BATCH_MAX = 50; + +async function runYoutube(arg: string, view: Webview | undefined): Promise { + const BARE_MODE_KEYWORDS = new Set(['info', 'benchmark', 'both']); + const tokens = arg.trim().split(/\s+/).filter(Boolean); + const url = tokens[0] || ''; + let limitOverride: number | null = null; + let mode: YoutubeAnalysisMode = 'both'; + const contextTokens: string[] = []; + for (const tok of tokens.slice(1)) { + const nMatch = tok.match(/^n[:=](\d+)$/i); + if (nMatch) { + const n = parseInt(nMatch[1], 10); + if (Number.isFinite(n) && n > 0) limitOverride = Math.min(YOUTUBE_BATCH_MAX, n); + continue; + } + const modeMatch = tok.match(/^mode[:=](info|benchmark|both)$/i); + if (modeMatch) { mode = modeMatch[1].toLowerCase() as YoutubeAnalysisMode; continue; } + const lower = tok.toLowerCase(); + if (BARE_MODE_KEYWORDS.has(lower)) { mode = lower as YoutubeAnalysisMode; continue; } + contextTokens.push(tok); + } + const userContent = contextTokens.join(' '); + + if (!url) { + chunk(view, [ + `사용법:\n`, + `- 단일 영상: \`/youtube <영상URL> [info|benchmark|both] [컨텍스트]\`\n`, + `- 채널/플레이리스트: \`/youtube <채널URL> [n:30] [info|benchmark|both] [컨텍스트]\`\n`, + `\n**분석 모드** (생략 시 \`both\`):\n`, + `- \`info\` — 영상의 *내용*을 지식 카드로 추출 (튜토리얼·강의·뉴스·인터뷰)\n`, + `- \`benchmark\` — 대본 역기획서 4-렌즈 분석 (콘텐츠 제작 벤치마크용)\n`, + `- \`both\` — 둘 다 생성 (영상당 LLM 호출 2회)\n`, + `\n예시:\n`, + `- \`/youtube https://youtu.be/abc info\`\n`, + `- \`/youtube https://youtube.com/@somechannel n:20 info AI 학습 자료\`\n`, + `\n💡 \`mode:info\` / \`mode=info\` 같은 명시형도 그대로 동작 (백워드 호환).\n`, + ].join('')); + return true; + } + + const isChannel = _looksLikeYoutubeChannelUrl(url); + const limit = limitOverride ?? (isChannel ? 10 : 1); + const normalizedUrl = isChannel ? _normalizeYoutubeUrl(url) : url; + if (normalizedUrl !== url) { + chunk(view, `🔧 채널 URL 정규화: \`${url}\` → \`${normalizedUrl}\` (yt-dlp 영상 enumeration 을 위한 \`/videos\` 탭 명시)\n\n`); + } + + const modeLabel = mode === 'info' ? '📋 정보 추출 (지식 카드)' + : mode === 'benchmark' ? '🎬 벤치마킹 (4-렌즈 역기획서)' + : '📋 정보 추출 + 🎬 벤치마킹 (둘 다)'; + if (isChannel) { + const callsPerVideo = mode === 'both' ? 2 : 1; + chunk(view, `📺 **채널/플레이리스트 감지** → 최신 ${limit}개 영상을 1개씩 순차 분석·wiki화 합니다.\n` + + `분석 모드: **${modeLabel}** (영상당 LLM ${callsPerVideo}회 호출)\n` + + `각 영상은 자막추출 → LLM 분석 → wiki 저장 순으로 처리되며, 영상당 보통 30~${120 * callsPerVideo}초.\n` + + `중간에 멈추려면 Astra 사이드바의 ⏹ Stop 을 누르세요.\n\n`); + } else { + chunk(view, `📊 **분석 모드**: ${modeLabel}\n\n`); + } + + chunk(view, `🎬 **YouTube 추출**: ${normalizedUrl}\n(자막 + 메타데이터${limit > 1 ? `, ${limit}개 영상` : ''})\n\n⏳ Python 추출기 기동 · 자막/메타 추출 중…`); + const t0 = Date.now(); + const heartbeat = setInterval(() => { + chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`); + }, 4000); + const extractTimeoutMs = Math.max(5 * 60_000, limit * 60_000); + const data = await bridgeFetch<{ success: boolean; videos?: any[]; totalVideos?: number }>( + BRIDGE_API.youtube.extract, + { method: 'POST', body: JSON.stringify({ source: normalizedUrl, withMetadata: true, limit }) }, + { + timeoutMs: extractTimeoutMs, + onHeartbeat: limit > 1 + ? (elapsedMs) => chunk(view, `\n · 추출 진행 중 (${Math.round(elapsedMs / 1000)}s, ${limit}개 영상)\n`) + : undefined, + }, + ).finally(() => clearInterval(heartbeat)); + + const okVideos = (data.videos || []).filter((v: any) => v?.status === 'ok'); + chunk(view, `\n✅ **추출 완료** (${Math.round((Date.now() - t0) / 1000)}s · ${okVideos.length}/${data.totalVideos ?? (data.videos || []).length}개 영상)\n\n`); + if (okVideos.length === 0) { + chunk(view, `⚠️ 자막 추출에 성공한 영상이 없습니다. 자막이 없거나 비공개 영상일 수 있습니다.\n`); + return true; + } + + const cfg = vscode.workspace.getConfiguration('g1nation'); + const model = (cfg.get('defaultModel', '') || 'gemma4:e2b').trim(); + const sysInfo = '당신은 영상 콘텐츠를 지식 카드로 변환하는 정보 큐레이터입니다. 자막에 명시된 사실만 인용하세요.'; + const sysBench = '당신은 유튜브 콘텐츠 시니어 PD입니다. 데이터에 근거한 제작 가이드만 제공하세요.'; + + type Section = { label: string; body: string }; + async function runOneAnalysis(video: any, prompt: string, system: string, sectionLabel: string, progressTag: string): Promise
{ + chunk(view, `🧪 **${sectionLabel}**${progressTag} (모델 \`${model}\`)…`); + try { + const t = Date.now(); + const body = await callLmSynthesis(prompt, system); + if (!body) throw new Error('LLM 응답이 비어 있습니다.'); + chunk(view, ` ✓ (${Math.round((Date.now() - t) / 1000)}s)\n\n`); + chunk(view, body + '\n\n'); + return { label: sectionLabel, body }; + } catch (e: any) { + chunk(view, `\n\n⚠️ ${sectionLabel} 실패${progressTag}: ${e?.message || String(e)}\n`); + return null; + } + } + + const total = okVideos.length; + let analyzedOk = 0; + let analyzedFail = 0; + let savedOk = 0; + let savedFail = 0; + const batchT0 = Date.now(); + for (let i = 0; i < okVideos.length; i++) { + const video = okVideos[i]; + const vTitle = video?.metadata?.title || video?.title || video?.video_id || '(제목 없음)'; + const progressTag = total > 1 ? ` [${i + 1}/${total}]` : ''; + + if (total > 1) chunk(view, `\n━━━ **${progressTag.trim()} ${vTitle}** ━━━\n\n`); + + const script = fullScriptFromSegments(video?.segments); + chunk(view, `## 📜 전체 스크립트 (Full Script)\n\n${script}\n\n---\n\n`); + + const sections: Section[] = []; + if (mode === 'info' || mode === 'both') { + const sec = await runOneAnalysis(video, buildInfoExtractionPrompt(video, userContent), sysInfo, '📋 정보 추출 (지식 카드)', progressTag); + if (sec) sections.push(sec); + } + if (mode === 'benchmark' || mode === 'both') { + const sec = await runOneAnalysis(video, build4LensPrompt(video, userContent), sysBench, '🎬 벤치마킹 (4-렌즈 역기획서)', progressTag); + if (sec) sections.push(sec); + } + + if (sections.length === 0) { + analyzedFail++; + chunk(view, `(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`); + continue; + } + analyzedOk++; + + try { + const today = new Date().toISOString().slice(0, 10); + const videoUrl = video?.metadata?.webpage_url || `https://www.youtube.com/watch?v=${video?.video_id}`; + const modeSuffix = mode === 'info' ? ' (정보)' : mode === 'benchmark' ? ' (벤치마크)' : ''; + const title = `유튜브분석 ${vTitle}${modeSuffix} ${today}`; + const sectionDivider = sections.length > 1 ? `\n\n---\n\n` : ''; + const fileMarkdown = [ + `# ${title}`, + ``, + `- **영상 URL**: ${videoUrl}`, + `- **분석 시각**: ${new Date().toISOString()}`, + `- **분석 모드**: ${mode}`, + `- **생성**: Astra /youtube · Datacollect youtube insight`, + ``, + `## 📜 전체 스크립트 (Full Script)`, + ``, + script, + ``, + `---`, + ``, + sections.map((s) => s.body).join(sectionDivider), + ``, + ].join('\n'); + const savePath = (cfg.get('datacollectSavePath', '') || '').trim(); + const body: Record = { title, content: fileMarkdown }; + if (savePath) body.saveDir = savePath; + const saved = await bridgeFetch<{ success: boolean; path?: string }>( + BRIDGE_API.wiki.save, + { method: 'POST', body: JSON.stringify(body) }, + { timeoutMs: 30_000 }, + ); + savedOk++; + chunk(view, `💾 **결과물 저장 완료**${progressTag}: \`${saved?.path || '(경로 미확인)'}\`\n\n`); + } catch (e: any) { + savedFail++; + chunk(view, `⚠️ 결과물 저장 실패${progressTag}: ${e?.message || String(e)}\n\n`); + } + } + + if (total > 1) { + const batchSec = Math.round((Date.now() - batchT0) / 1000); + chunk(view, `\n━━━━━━━━━━━━━━━━━━━━\n` + + `🏁 **배치 완료** (총 ${batchSec}s · ${total}개 영상)\n` + + `- 분석: ✅ ${analyzedOk} / ❌ ${analyzedFail}\n` + + `- 저장: 💾 ${savedOk} / ⚠️ ${savedFail}\n`); + } + + return true; +} + +// ───────────────────────────── /blog ───────────────────────────── + +async function runBlog(keyword: string, view: Webview | undefined): Promise { + const target = 'http://127.0.0.1:8787/blog/'; + chunk(view, `🖋️ **Blog Pipeline**\n\n`); + if (keyword) chunk(view, `요청 키워드: \`${keyword}\`\n\n`); + chunk(view, [ + `현재 Blog Pipeline은 Datacollect의 별도 정적 페이지(`, + `[${target}](${target})`, + `)에서 단계별로 실행됩니다. Bridge(3002)에 대응 API가 없어 채팅에서 직접`, + ` 트리거할 수 없어, 다음 두 가지 중 하나를 선택해 주세요:\n\n`, + ].join('')); + chunk(view, `1. 위 링크에서 키워드/참고 자료/경험담을 입력해 직접 파이프라인 실행\n`); + chunk(view, `2. Bridge에 \`/api/blog/run\` 같은 endpoint를 노출하면 \`/blog\`도 end-to-end 실행 가능 — 원하시면 추가 작업으로 진행\n`); + + try { await vscode.env.openExternal(vscode.Uri.parse(target)); } catch { /* best-effort */ } + return true; +} + +// ───────────────────────────── /wikify ───────────────────────────── + +type WikifyResult = { ok: true } | { ok: false; reason: string }; +async function wikifyOne(url: string, userContent: string, view: Webview | undefined): Promise { + const cfg = vscode.workspace.getConfiguration('g1nation'); + + chunk(view, `⏳ 본문 추출 중…`); + const t0 = Date.now(); + const heartbeat = setInterval(() => { + chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`); + }, 4000); + const data = await bridgeFetch<{ success: boolean; url: string; title?: string; description?: string; lang?: string; headings?: string[]; text?: string; textLength?: number; truncated?: boolean }>( + BRIDGE_API.web.extract, + { method: 'POST', body: JSON.stringify({ url }) }, + { timeoutMs: 3 * 60_000 }, + ).finally(() => clearInterval(heartbeat)); + chunk(view, `\n✅ 본문 추출 (${Math.round((Date.now() - t0) / 1000)}s · ${(data.textLength ?? 0).toLocaleString()}자${data.truncated ? ', 일부 잘림' : ''})\n\n`); + + if (!data.text || data.text.trim().length < 50) { + const reason = `본문 빈약 (${data.textLength ?? 0}자 — JS 전용 렌더링 또는 콘텐츠 없음)`; + chunk(view, `⚠️ 추출된 본문이 거의 없어 건너뜁니다. (${reason})\n`); + return { ok: false, reason }; + } + + const model = (cfg.get('defaultModel', '') || 'gemma4:e2b').trim(); + const wikiSystem = '당신은 지식 큐레이터입니다. 제공된 웹사이트 본문을 P-Reinforce v3.0 규격의 고밀도 위키 문서로 정리합니다. 본문에 없는 내용은 절대 지어내지 않으며, 모든 문서는 한국어로 작성합니다.'; + chunk(view, `🧪 P-Reinforce 위키 합성 (모델 \`${model}\`)…`); + let report: string; + try { + const synthT0 = Date.now(); + report = await callLmSynthesis(buildWikifyPrompt(data, userContent), wikiSystem); + if (!report) throw new Error('LLM 응답이 비어 있습니다.'); + report = report.replace(/\[\[([^\[\]]+?)\](?!\])/g, '[[$1]]'); + chunk(view, ` ✓ (${Math.round((Date.now() - synthT0) / 1000)}s)\n\n`); + } catch (e: any) { + const reason = `LLM 합성 실패: ${e?.message || String(e)}`; + chunk(view, `\n⚠️ 위키 합성 실패: ${e?.message || String(e)}\n(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`); + return { ok: false, reason }; + } + chunk(view, report + '\n\n'); + + try { + const today = new Date().toISOString().slice(0, 10); + let host = url; + try { host = new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`).host; } catch { /* keep raw url */ } + const title = `위키 ${(userContent.trim() || data.title || host)} ${today}`; + const savePath = (cfg.get('datacollectSavePath', '') || '').trim(); + const body: Record = { title, content: report }; + if (savePath) body.saveDir = savePath; + const saved = await bridgeFetch<{ success: boolean; path?: string }>( + BRIDGE_API.wiki.save, + { method: 'POST', body: JSON.stringify(body) }, + { timeoutMs: 30_000 }, + ); + chunk(view, `💾 위키 문서 저장 완료: \`${saved?.path || '(경로 미확인)'}\`\n`); + } catch (e: any) { + chunk(view, `⚠️ 위키 문서 저장 실패: ${e?.message || String(e)}\n`); + } + return { ok: true }; +} + +async function runWikify(arg: string, view: Webview | undefined): Promise { + const isUrl = (t: string) => /^https?:\/\//i.test(t) || /^[a-z0-9-]+(\.[a-z0-9-]+)+/i.test(t); + const tokens = arg.trim().split(/\s+/).filter(Boolean); + const urls = tokens.filter(isUrl); + const userContent = tokens.filter((t) => !isUrl(t)).join(' '); + if (urls.length === 0) { + chunk(view, `사용법: \`/wikify [url2 url3 …] [주제명]\`\n예: \`/wikify https://example.com\`\n여러 링크를 공백으로 구분해 한 번에 넣으면 1개씩 순차 위키화합니다.\n`); + return true; + } + + if (urls.length === 1) { + chunk(view, `📚 **위키화**: ${urls[0]}\n(본문 추출 → P-Reinforce v3.0 위키 문서 합성)\n\n`); + const result = await wikifyOne(urls[0], userContent, view); + if (!result.ok) chunk(view, `\n실패 사유: ${result.reason}\n`); + return true; + } + + chunk(view, `📚 **위키화 배치**: 총 ${urls.length}개 링크를 순차 처리합니다.\n`); + const batchT0 = Date.now(); + let okCount = 0; + const failures: Array<{ url: string; reason: string }> = []; + for (let i = 0; i < urls.length; i++) { + chunk(view, `\n---\n\n### [${i + 1}/${urls.length}] ${urls[i]}\n\n`); + try { + const result = await wikifyOne(urls[i], userContent, view); + if (result.ok) okCount++; + else failures.push({ url: urls[i], reason: result.reason }); + } catch (e: any) { + const reason = `처리 오류: ${e?.message || String(e)}`; + chunk(view, `❌ [${i + 1}/${urls.length}] ${reason}\n`); + failures.push({ url: urls[i], reason }); + } + } + chunk(view, `\n---\n\n🏁 **배치 완료**: ${okCount}/${urls.length}개 성공 (${Math.round((Date.now() - batchT0) / 1000)}s 소요)\n`); + if (failures.length > 0) { + chunk(view, `\n**실패 ${failures.length}건 사유**:\n`); + for (const f of failures) chunk(view, `- ${f.url} — ${f.reason}\n`); + } + return true; +} + +// ───────────────────────────── /meet ───────────────────────────── + +async function runMeet(arg: string, view: Webview | undefined, context?: vscode.ExtensionContext): Promise { + const trimmed = arg.trim(); + let filePath = ''; + let metadata = ''; + if (trimmed.startsWith('"')) { + const end = trimmed.indexOf('"', 1); + if (end > 0) { + filePath = trimmed.slice(1, end); + metadata = trimmed.slice(end + 1).trim(); + } + } + if (!filePath) { + const sp = trimmed.indexOf(' '); + if (sp === -1) filePath = trimmed; + else { filePath = trimmed.slice(0, sp); metadata = trimmed.slice(sp + 1).trim(); } + } + if (!filePath) { + chunk(view, `사용법: \`/meet [참석자·날짜 등 메타데이터]\`\n예: \`/meet c:\\doc\\0101.txt\`\n경로에 공백이 있으면 따옴표로 감싸세요: \`/meet "c:\\my docs\\0101.txt"\`\n`); + return true; + } + + chunk(view, `📝 **회의록 작성**: ${filePath}\n\n⏳ 녹취 파일 읽는 중…`); + + let transcript: string; + try { + transcript = await fsp.readFile(filePath, 'utf-8'); + } catch (e: any) { + chunk(view, `\n\n❌ 파일을 읽을 수 없습니다: ${e?.message || String(e)}\n경로가 정확한지 확인하세요.\n`); + return true; + } + if (!transcript || transcript.trim().length < 20) { + chunk(view, `\n\n⚠️ 파일 내용이 거의 비어 있습니다.\n`); + return true; + } + const MAX = 60000; + const truncated = transcript.length > MAX; + if (truncated) transcript = transcript.slice(0, MAX); + chunk(view, `\n✅ 파일 읽기 완료 (${transcript.length.toLocaleString()}자${truncated ? ', 상한 초과로 일부 잘림' : ''})\n\n`); + + const cfg = vscode.workspace.getConfiguration('g1nation'); + const model = (cfg.get('defaultModel', '') || 'gemma4:e2b').trim(); + const meetSystem = '당신은 회의록 작성 전문가입니다. 제공된 녹취록과 메타데이터만 근거로 사실 기반의 구조화된 회의록을 작성하며, 외부 지식이나 추측을 절대 섞지 않습니다. 모든 출력은 한국어로 작성합니다.'; + chunk(view, `🧪 **회의록 합성** (모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…`); + let report: string; + try { + const t0 = Date.now(); + report = await callLmSynthesis(buildMeetPrompt(transcript, metadata), meetSystem); + if (!report) throw new Error('LLM 응답이 비어 있습니다.'); + chunk(view, ` ✓ (${Math.round((Date.now() - t0) / 1000)}s)\n\n`); + } catch (e: any) { + chunk(view, `\n\n⚠️ 회의록 합성 실패: ${e?.message || String(e)}\n(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`); + return true; + } + chunk(view, report + '\n\n'); + + try { + const today = new Date().toISOString().slice(0, 10); + const baseName = filePath.replace(/^.*[\\/]/, '').replace(/\.[^.]+$/, '') || 'meeting'; + const title = `회의록 ${baseName} ${today}`; + const savePath = (cfg.get('datacollectSavePath', '') || '').trim(); + const body: Record = { title, content: report }; + if (savePath) body.saveDir = savePath; + const saved = await bridgeFetch<{ success: boolean; path?: string }>( + BRIDGE_API.wiki.save, + { method: 'POST', body: JSON.stringify(body) }, + { timeoutMs: 30_000 }, + ); + chunk(view, `💾 **회의록 저장 완료**: \`${saved?.path || '(경로 미확인)'}\`\n`); + } catch (e: any) { + chunk(view, `⚠️ 회의록 저장 실패: ${e?.message || String(e)}\n`); + } + + if (context) { + try { + const calCfg = readCalendarConfig(context); + if (!calCfg.refreshToken) { + chunk(view, `\nℹ️ 캘린더 자동 등록을 건너뜁니다 — Google Calendar OAuth(쓰기)가 연결되지 않았습니다. (Astra Settings → Google 섹션에서 연결)\n`); + } else { + const tasks = parseActionItems(report); + if (tasks.length === 0) { + chunk(view, `\nℹ️ 액션 아이템이 없어 캘린더에 등록할 항목이 없습니다.\n`); + } else { + const today = new Date(); + const meetingDate = extractMeetingDate(report, today); + const titleMatch = report.match(/^#\s+(.+)$/m); + const meetTitle = titleMatch + ? titleMatch[1].replace(/^\[회의 제목\]\s*/, '').trim() + : '회의'; + const gCfg = vscode.workspace.getConfiguration('g1nation'); + const meetUsesTasks = gCfg.get('meetUsesTasks', true); + const meetUsesCalendar = gCfg.get('meetUsesCalendar', true); + if (!meetUsesTasks && !meetUsesCalendar) { + chunk(view, `\nℹ️ Google Tasks·Calendar 등록이 모두 꺼져 있어 액션 아이템 자동 등록을 건너뜁니다. (Settings 의 \`g1nation.meetUsesTasks\` / \`g1nation.meetUsesCalendar\` 확인)\n`); + } else { + const destLabel = [meetUsesTasks && 'Tasks', meetUsesCalendar && 'Calendar'].filter(Boolean).join(' + '); + chunk(view, `\n📝 **Google ${destLabel} 등록**: 액션 아이템 ${tasks.length}건…\n`); + let tasksOk = 0; + let calendarOk = 0; + let tentativeCount = 0; + for (const task of tasks) { + const { date, tentative } = resolveTaskDate(task.due, meetingDate, today); + if (tentative) tentativeCount++; + const evTitle = tentative ? `${task.work} (미확정)` : task.work; + const notes = `회의록: ${meetTitle}\n담당: ${task.owner}\n기한 표기: ${task.due || '(없음)'}\nAstra /meet 자동 등록`; + + const successes: string[] = []; + const failures: string[] = []; + if (meetUsesTasks) { + const r = await createTask(context, { title: evTitle, due: date, notes }); + if (r.ok) { tasksOk++; successes.push('Tasks'); } + else { failures.push(`Tasks: ${r.error}`); } + } + if (meetUsesCalendar) { + const r = await createCalendarEvent(context, { + title: evTitle, start: date, allDay: true, description: notes, + }); + if (r.ok) { calendarOk++; successes.push('Calendar'); } + else { failures.push(`Calendar: ${r.error}`); } + } + + if (failures.length === 0) { + chunk(view, ` · ${date} — ${evTitle} (${successes.join(' + ')})\n`); + } else { + chunk(view, ` · ${date} — ${evTitle}${successes.length ? ` (✅ ${successes.join(' + ')})` : ''}\n`); + for (const f of failures) chunk(view, ` ⚠️ ${f}\n`); + } + } + const summary: string[] = []; + if (meetUsesTasks) summary.push(`Tasks ${tasksOk}/${tasks.length}`); + if (meetUsesCalendar) summary.push(`Calendar ${calendarOk}/${tasks.length}`); + chunk(view, `✅ 등록 완료 — ${summary.join(' · ')}${tentativeCount > 0 ? ` · 미확정 ${tentativeCount}건` : ''}\n`); + } + } + } + } catch (e: any) { + chunk(view, `\n⚠️ 캘린더 등록 중 오류: ${e?.message || String(e)}\n`); + } + } + return true; +} + +// ─── 등록 ───────────────────────────────────────────────────────────────── + +registerSlashCommand({ name: '/research', description: 'NotebookLM Deep Research 호출 후 위키 합성', handler: runResearch }); +registerSlashCommand({ name: '/benchmark', description: 'Playwright 웹 벤치마크 + 4-렌즈 LLM 분석', handler: runBenchmark }); +registerSlashCommand({ name: '/youtube', description: 'YouTube 단일 영상 또는 채널/플레이리스트 분석', handler: runYoutube }); +registerSlashCommand({ name: '/blog', description: 'Blog Pipeline 안내 (Datacollect 별도 흐름)', handler: runBlog }); +registerSlashCommand({ name: '/wikify', description: '웹 URL → P-Reinforce v3.0 위키 합성·저장', handler: runWikify }); +registerSlashCommand({ name: '/meet', description: '회의 transcript → 회의록 합성 + 캘린더·task 등록', handler: runMeet }); diff --git a/src/features/datacollect/llm.ts b/src/features/datacollect/llm.ts new file mode 100644 index 0000000..6b8038f --- /dev/null +++ b/src/features/datacollect/llm.ts @@ -0,0 +1,122 @@ +/** + * Datacollect LLM 호출 인프라 — bridge `/api/lm` 프록시 통해 OpenAI 호환 chat + * completion 단발 호출. + * + * v2.2.201 에서 slashRouter.ts 에서 분리. 옛 위치는 slashRouter.callLmSynthesis + * 였으나 datacollect handlers + teamops/communication 양쪽이 import 하므로 + * 별도 인프라 모듈로. + * + * `repairKoreanGlitches` 는 callLmSynthesis 출력 위생용 — 모델이 한·영 혼합 + * 토큰 깨짐 (예: "핵ess") 을 뱉으면 LLM 1회 추가 호출로 교정. + */ + +import * as vscode from 'vscode'; +import { bridgeFetch, BRIDGE_API } from './bridgeClient'; + +/** + * Bridge `/api/lm` 프록시로 OpenAI 호환 chat completion 1회 호출. + * LLM 서버/모델은 Astra 설정(g1nation.ollamaUrl / defaultModel)을 사용한다. + */ +export async function callLmSynthesis(prompt: string, systemPrompt?: string): Promise { + const cfg = vscode.workspace.getConfiguration('g1nation'); + const lmUrl = (cfg.get('ollamaUrl', 'http://127.0.0.1:11434') || 'http://127.0.0.1:11434').replace(/\/$/, ''); + const model = (cfg.get('defaultModel', '') || 'gemma4:e2b').trim(); + const temperature = Math.max(0, Math.min(2, cfg.get('datacollectSynthesisTemperature', 0.1) ?? 0.1)); + const baseSys = systemPrompt + || '당신은 시니어 UX/UI 분석가이자 프론트엔드 아키텍트다. 모든 보고서는 한국어로, 제공된 JSON 스캔 데이터의 구체적 수치를 인용해 작성한다. 원본 레퍼런스 사이트를 처음부터 다시 만들기 위한 명세를 작성하는 것이 미션이며, 다른 서비스로 재해석·확장하지 않는다.'; + const sys = baseSys + '\n\n[출력 위생 규칙 — 반드시 준수]\n' + + '- 자연스러운 한국어로 작성하고, 한 단어 안에 한글과 영문 알파벳을 섞지 마시오 ("결ently", "인orp" 같은 깨진 합성 표기 절대 금지).\n' + + '- 외래어·기술 용어는 완전한 한글 표기 또는 완전한 영문 단어 중 하나로 일관되게 쓰시오.\n' + + '- [Self-Reflector Check], Consistency/Completeness/Accuracy 같은 내부 검증·체크 로그 블록을 출력에 절대 포함하지 마시오. 최종 사용자 결과물만 출력하시오.'; + const res = await bridgeFetch(BRIDGE_API.lm.proxy, { + method: 'POST', + body: JSON.stringify({ + url: `${lmUrl}/v1/chat/completions`, + payload: { + model, + messages: [ + { role: 'system', content: sys }, + { role: 'user', content: prompt }, + ], + temperature, + top_p: 0.85, + top_k: 20, + repeat_penalty: 1.1, + }, + }), + }, { timeoutMs: 120_000 }); + const content = res?.choices?.[0]?.message?.content + ?? res?.choices?.[0]?.text + ?? res?.answer + ?? res?.response + ?? ''; + let out = String(content) + .replace(/\n*(?:```[\w]*\s*)?\[Self-Reflector Check\][\s\S]*$/i, '') + .trim(); + if (/[가-힣][a-z]{2,}/.test(out)) { + out = await repairKoreanGlitches(out, lmUrl, model); + } + return out; +} + +/** + * 한글+영문이 한 단어로 깨진 표기(LLM 토큰 꼬임)를 LLM 1회 호출로 교정. + */ +async function repairKoreanGlitches(text: string, lmUrl: string, model: string): Promise { + try { + const res = await bridgeFetch(BRIDGE_API.lm.proxy, { + method: 'POST', + body: JSON.stringify({ + url: `${lmUrl}/v1/chat/completions`, + payload: { + model, + messages: [ + { role: 'system', content: '당신은 한국어 교정기입니다. 입력 텍스트에서 한글과 영문 알파벳이 한 단어로 잘못 합쳐진 깨진 표기(예: "핵ess"→"핵심", "결ently"→"결국")만 자연스러운 한국어로 교정합니다. 그 외 내용·문장·마크다운 구조·표·정상적인 영문 용어(API, JSON 등)는 한 글자도 바꾸지 않으며, 교정된 전체 텍스트만 그대로 출력합니다(설명·주석 금지).' }, + { role: 'user', content: text }, + ], + temperature: 0, + top_p: 0.7, + top_k: 20, + }, + }), + }, { timeoutMs: 120_000 }); + const fixed = String( + res?.choices?.[0]?.message?.content + ?? res?.choices?.[0]?.text + ?? res?.answer ?? res?.response ?? '', + ).replace(/\n*(?:```[\w]*\s*)?\[Self-Reflector Check\][\s\S]*$/i, '').trim(); + if (!fixed || fixed.length < text.length * 0.7) return text; + return fixed; + } catch { + return text; + } +} + +/** + * Datacollect bridge 가 자주 뱉는 환경 의존성 에러(Python 패키지 미설치, Python + * 자체 부재 등) 를 패턴 매칭해서 사용자에게 *해결 명령까지* 알려주는 가이드 텍스트. + * 없으면 빈 문자열 반환. slashRouter 의 catch 블록에서 일반 에러 메시지 뒤에 append. + */ +export function bridgeErrorRemedy(rawMsg: string): string { + const msg = String(rawMsg || ''); + const pkgMatch = msg.match(/필수 패키지가 없습니다?[:\s]+([\w\-,\s.]+)/i) + || msg.match(/missing (?:python )?packages?[:\s]+([\w\-,\s.]+)/i); + if (pkgMatch) { + const pkgs = pkgMatch[1].split(/[,\s]+/).map((s) => s.trim()).filter(Boolean).join(' '); + return `\n\n💡 **해결**: Datacollect bridge 가 도는 환경에서 아래 명령으로 누락된 Python 패키지를 설치하세요.\n\n` + + '```bash\n' + + `# macOS (homebrew Python — PEP 668 보호 우회):\n` + + `python3 -m pip install --user --break-system-packages ${pkgs}\n\n` + + `# 또는 가상환경(venv) 사용 시 그 venv 활성화 후:\n` + + `pip install ${pkgs}\n` + + '```\n\n' + + `설치 후 **bridge 재시작은 보통 불필요** — bridge 는 Python 을 child process 로 spawn 하므로 다음 호출이 바로 새 패키지를 인식합니다. 그래도 안 되면 \`npm run bridge\` 재시작.\n`; + } + if (/Python 3이 설치돼 있지 않거나 PATH/i.test(msg) || /command not found.*python/i.test(msg)) { + return `\n\n💡 **해결**: Python 3 이 설치돼 있어야 합니다. https://www.python.org 에서 설치 후 터미널에서 \`python3 --version\` 으로 확인하세요. 이미 설치돼 있으면 PATH 설정 확인 필요.`; + } + if (/ECONNREFUSED|fetch failed/i.test(msg) || /연결할 수 없습니다/i.test(msg)) { + return `\n\n💡 **해결**: Datacollect bridge 가 떠 있지 않습니다. \`Datacollector_MAC\` 프로젝트에서 \`npm run bridge\` 실행 후 다시 시도하세요.`; + } + return ''; +} diff --git a/src/features/datacollect/slashRouter.ts b/src/features/datacollect/slashRouter.ts index 2599ff7..7b34817 100644 --- a/src/features/datacollect/slashRouter.ts +++ b/src/features/datacollect/slashRouter.ts @@ -1,25 +1,7 @@ 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, 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'; -import { clearTermValidatorCache } from '../../agent/termValidator'; +import { getBridgeBaseUrl } from './bridgeClient'; +import { bridgeErrorRemedy } from './llm'; /** * Datacollect "라디오" slash 명령 라우터. @@ -35,7 +17,7 @@ import { clearTermValidatorCache } from '../../agent/termValidator'; * 명령이 처리되면 true 반환 → chatHandlers가 일반 LLM 흐름으로 안 내려가게. */ -interface Webview { +export interface Webview { postMessage(msg: any): Thenable | boolean; } @@ -116,7 +98,7 @@ export function isSlashCommand(input: string): boolean { return REGISTRY.has(head); } -function chunk(view: Webview | undefined, value: string) { +export function chunk(view: Webview | undefined, value: string) { view?.postMessage({ type: 'streamChunk', value }); } @@ -170,7 +152,7 @@ export async function handleSlashCommand( logInfo(`[SLASH] handleSlashCommand error head=${head}: ${errMsg}`); chunk(view, `\n\n> ❌ **에러**: ${errMsg}\n`); // 자주 발생하는 환경 의존성 에러는 사용자가 즉시 해결할 수 있게 명령 가이드 자동 첨부. - const remedy = _bridgeErrorRemedy(errMsg); + const remedy = bridgeErrorRemedy(errMsg); if (remedy) chunk(view, remedy); // Python 패키지 미설치 패턴이면 한 클릭 설치 notification 도 같이 띄움. // 채팅 텍스트만 보면 사용자가 명령 팔레트로 가기 귀찮으니까 actionable 버튼 제공. @@ -190,999 +172,6 @@ export async function handleSlashCommand( } } -// ───────────────────────────── /research ───────────────────────────── - -async function runResearch(topic: string, view: Webview | undefined): Promise { - if (!topic) { - chunk(view, `사용법: \`/research <주제>\`\n주제를 입력해 NotebookLM Deep Research를 시작합니다.\n`); - return true; - } - - chunk(view, `🚀 **Research 시작**: \`${topic}\`\n\n`); - const start = await bridgeFetch<{ success: boolean; notebookId: string; taskId: string }>( - BRIDGE_API.research.start, - { method: 'POST', body: JSON.stringify({ topic }) }, - { timeoutMs: 60_000 }, - ); - chunk(view, `- notebookId: \`${start.notebookId}\`\n- taskId: \`${start.taskId}\`\n\n⏳ 상태 polling (5초 간격, 최대 10분)…\n`); - - // Deep research는 보통 1~5분. 5초 polling, 최대 120회(10분). - // - // hang 방어 3겹: - // (1) Bridge status 가 5회 연속 실패하면 polling 포기 — bridge 가 죽은 거. - // (2) heartbeat — 30초마다 진행 상태가 안 바뀌면 "⏳" 한 줄 흘려 사용자가 - // "멈춰 있나?" 느끼지 않게. - // (3) status 비교는 트림 + 소문자 — bridge 가 "Completed " 식으로 흘려도 잡힘. - const deadline = Date.now() + 10 * 60_000; - const HEARTBEAT_MS = 30_000; - const MAX_CONSECUTIVE_FAILS = 5; - const COMPLETED_SET = new Set(['completed', 'done', 'success', 'finished']); - const FAILED_SET = new Set(['failed', 'error', 'cancelled', 'canceled', 'aborted']); - - let lastStatus = ''; - let lastChangeAt = Date.now(); - let consecutiveFails = 0; - let pollCount = 0; - let researchOk = false; - while (Date.now() < deadline) { - await new Promise(r => setTimeout(r, 5_000)); - pollCount++; - // status 한 번 호출이 30s를 넘는 사례(stale MCP 자식)가 보고돼 60s로 완화. - let st: { success: boolean; result: any } | undefined; - try { - st = await bridgeFetch<{ success: boolean; result: any }>( - `${BRIDGE_API.research.status}?notebookId=${encodeURIComponent(start.notebookId)}&taskId=${encodeURIComponent(start.taskId)}`, - { method: 'GET' }, - { timeoutMs: 60_000 }, - ); - consecutiveFails = 0; - } catch (e: any) { - consecutiveFails++; - if (consecutiveFails >= MAX_CONSECUTIVE_FAILS) { - chunk(view, `\n❌ Status polling 연속 실패 ${consecutiveFails}회 — bridge 가 응답하지 않습니다. 중단합니다.\n(원인: ${e?.message || String(e)})\n`); - return true; - } - chunk(view, `\n · status 호출 실패 ${consecutiveFails}/${MAX_CONSECUTIVE_FAILS} (${e?.message || 'unknown'})\n`); - continue; - } - const status = String(st.result?.status || st.result || '').trim().toLowerCase(); - if (status && status !== lastStatus) { - chunk(view, ` · ${status}\n`); - lastStatus = status; - lastChangeAt = Date.now(); - } else if (Date.now() - lastChangeAt > HEARTBEAT_MS) { - // 30초간 status 변화 없음 — 사용자에게 살아있다는 신호. - chunk(view, ` · ⏳ 대기 중 (${Math.round((Date.now() - lastChangeAt) / 1000)}s, 폴링 ${pollCount}회)\n`); - lastChangeAt = Date.now(); - } - if (COMPLETED_SET.has(status)) { researchOk = true; break; } - if (FAILED_SET.has(status)) { - chunk(view, `\n❌ Research 실패: ${JSON.stringify(st.result).slice(0, 400)}\n`); - return true; - } - } - - if (!researchOk) { - chunk(view, `\n❌ 10분 polling 후에도 완료 신호가 오지 않았습니다 (마지막 status: \`${lastStatus || '(없음)'}\`). 중단합니다.\n`); - return true; - } - - chunk(view, `\n📥 import…\n`); - // import는 deep research 결과를 노트북 소스로 옮기는 단계. 큰 리포트는 2~5분 - // 걸리는 경우가 흔해 120s에서 TRANSIENT_TIMEOUT으로 떨어지는 사례 보고됨. 300s로 늘림. - // heartbeat — 30초마다 진행 표시 흘려 사용자가 "멈췄나?" 의심하지 않게. - await bridgeFetch(BRIDGE_API.research.import, { - method: 'POST', - body: JSON.stringify({ notebookId: start.notebookId, taskId: start.taskId }), - }, { - timeoutMs: 300_000, - onHeartbeat: (elapsedMs) => chunk(view, ` · import 진행 중 (${Math.round(elapsedMs / 1000)}s)\n`), - }); - - chunk(view, `🧪 synthesize…\n\n`); - // synthesize는 LLM이 노트북 전체를 합성 — 큰 노트북은 5~10분. 600s로 cap. - // heartbeat 필수: LLM 단일 호출이 수 분 걸리므로 hang 의심 방지. - const synth = await bridgeFetch<{ success: boolean; markdown?: string; result?: string }>( - BRIDGE_API.research.synthesize, - { - method: 'POST', - body: JSON.stringify({ notebookId: start.notebookId, topic, rootTopic: topic, includeKnowledgeConnections: true }), - }, - { - timeoutMs: 600_000, - onHeartbeat: (elapsedMs) => chunk(view, ` · synthesize LLM 작업 중 (${Math.round(elapsedMs / 1000)}s)\n`), - }, - ); - const md = synth.markdown || synth.result || '(빈 응답)'; - chunk(view, `---\n\n${md}\n`); - return true; -} - -// ───────────────────────────── /benchmark ───────────────────────────── - -// SynthesisPart type + buildSynthesisPrompt → `src/features/datacollect/prompts/synthesisPrompt.ts` -import { type SynthesisPart, buildSynthesisPrompt } from './prompts/synthesisPrompt'; - -/** - * Bridge `/api/lm` 프록시로 OpenAI 호환 chat completion 1회 호출. - * LLM 서버/모델은 Astra 설정(g1nation.ollamaUrl / defaultModel)을 사용한다. - */ -async function callLmSynthesis(prompt: string, systemPrompt?: string): Promise { - const cfg = vscode.workspace.getConfiguration('g1nation'); - const lmUrl = (cfg.get('ollamaUrl', 'http://127.0.0.1:11434') || 'http://127.0.0.1:11434').replace(/\/$/, ''); - const model = (cfg.get('defaultModel', '') || 'gemma4:e2b').trim(); - // temperature는 설정값(g1nation.datacollectSynthesisTemperature). 낮을수록 환각· - // 깨진 문자가 줄어든다. 0~2 범위로 클램프, 기본 0.1. - const temperature = Math.max(0, Math.min(2, cfg.get('datacollectSynthesisTemperature', 0.1) ?? 0.1)); - const baseSys = systemPrompt - || '당신은 시니어 UX/UI 분석가이자 프론트엔드 아키텍트다. 모든 보고서는 한국어로, 제공된 JSON 스캔 데이터의 구체적 수치를 인용해 작성한다. 원본 레퍼런스 사이트를 처음부터 다시 만들기 위한 명세를 작성하는 것이 미션이며, 다른 서비스로 재해석·확장하지 않는다.'; - // 로컬 모델 출력 위생 — 한영 토큰 깨짐 방지 + 내부 검증 로그 유출 차단. - const sys = baseSys + '\n\n[출력 위생 규칙 — 반드시 준수]\n' - + '- 자연스러운 한국어로 작성하고, 한 단어 안에 한글과 영문 알파벳을 섞지 마시오 ("결ently", "인orp" 같은 깨진 합성 표기 절대 금지).\n' - + '- 외래어·기술 용어는 완전한 한글 표기 또는 완전한 영문 단어 중 하나로 일관되게 쓰시오.\n' - + '- [Self-Reflector Check], Consistency/Completeness/Accuracy 같은 내부 검증·체크 로그 블록을 출력에 절대 포함하지 마시오. 최종 사용자 결과물만 출력하시오.'; - const res = await bridgeFetch(BRIDGE_API.lm.proxy, { - method: 'POST', - body: JSON.stringify({ - url: `${lmUrl}/v1/chat/completions`, - payload: { - model, - messages: [ - { role: 'system', content: sys }, - { role: 'user', content: prompt }, - ], - temperature, - // 깨진 저확률 토큰(한·영 혼합 등) 샘플링 억제 — top_p/top_k로 후보를 - // 좁히고 repeat_penalty로 반복 깨짐을 누른다. - top_p: 0.85, - top_k: 20, - repeat_penalty: 1.1, - }, - }), - }, { timeoutMs: 120_000 }); - const content = res?.choices?.[0]?.message?.content - ?? res?.choices?.[0]?.text - ?? res?.answer - ?? res?.response - ?? ''; - // 안전망 — 모델이 그래도 내부 검증 로그를 붙이면 잘라낸다. [Self-Reflector Check] - // 블록은 항상 답변 맨 끝에 오므로 그 지점부터 끝까지 제거한다. - let out = String(content) - .replace(/\n*(?:```[\w]*\s*)?\[Self-Reflector Check\][\s\S]*$/i, '') - .trim(); - // 한·영 깨짐(예: "핵ess" — 한글 음절 + 영문 소문자 조각)이 감지되면 교정 패스를 - // 1회 돈다. 깨짐이 없으면(대부분) 추가 호출 없이 그대로 반환한다. - if (/[가-힣][a-z]{2,}/.test(out)) { - out = await repairKoreanGlitches(out, lmUrl, model); - } - return out; -} - -/** - * 한글+영문이 한 단어로 깨진 표기(LLM 토큰 꼬임)를 LLM 1회 호출로 교정. - * 깨진 토큰만 고치고 나머지 내용·구조는 보존한다. 실패하거나 결과가 비정상적으로 - * 짧으면(LLM이 내용을 잘라먹은 경우) 원본을 그대로 반환한다. - */ -async function repairKoreanGlitches(text: string, lmUrl: string, model: string): Promise { - try { - const res = await bridgeFetch(BRIDGE_API.lm.proxy, { - method: 'POST', - body: JSON.stringify({ - url: `${lmUrl}/v1/chat/completions`, - payload: { - model, - messages: [ - { role: 'system', content: '당신은 한국어 교정기입니다. 입력 텍스트에서 한글과 영문 알파벳이 한 단어로 잘못 합쳐진 깨진 표기(예: "핵ess"→"핵심", "결ently"→"결국")만 자연스러운 한국어로 교정합니다. 그 외 내용·문장·마크다운 구조·표·정상적인 영문 용어(API, JSON 등)는 한 글자도 바꾸지 않으며, 교정된 전체 텍스트만 그대로 출력합니다(설명·주석 금지).' }, - { role: 'user', content: text }, - ], - temperature: 0, - top_p: 0.7, - top_k: 20, - }, - }), - }, { timeoutMs: 120_000 }); - const fixed = String( - res?.choices?.[0]?.message?.content - ?? res?.choices?.[0]?.text - ?? res?.answer ?? res?.response ?? '', - ).replace(/\n*(?:```[\w]*\s*)?\[Self-Reflector Check\][\s\S]*$/i, '').trim(); - // 안전장치 — 교정 결과가 원본의 70% 미만이면 LLM이 내용을 잘라먹은 것이므로 원본 사용. - if (!fixed || fixed.length < text.length * 0.7) return text; - return fixed; - } catch { - return text; // 교정 실패는 비치명적 — 원본 그대로 반환. - } -} - -async function runBenchmark(arg: string, view: Webview | undefined): Promise { - // 인자 파싱: 첫 비-옵션 토큰=URL, `depth=N`·`pages=N`=크롤 옵션, 나머지=보조 컨텍스트. - // 예) `/benchmark caliverse.io depth=2 pages=12 우리 랜딩 참고용` - // URL 토큰만 떼어내므로 "분석해줘" 같은 자연어가 섞여도 안전하다. - const tokens = arg.trim().split(/\s+/).filter(Boolean); - let url = ''; - let depthArg: number | undefined; - let pagesArg: number | undefined; - const restParts: string[] = []; - for (const t of tokens) { - const m = /^(depth|pages)=(\d+)$/i.exec(t); - if (m) { - if (m[1].toLowerCase() === 'depth') depthArg = Number(m[2]); - else pagesArg = Number(m[2]); - } else if (!url) { - url = t; - } else { - restParts.push(t); - } - } - if (!url) { - chunk(view, `사용법: \`/benchmark [depth=N] [pages=N] [보조 설명]\`\n예: \`/benchmark https://example.com depth=2 pages=12\`\n`); - return true; - } - const userContent = restParts.join(' '); - - // 크롤 옵션 우선순위: 명령 인자 > Settings 설정값 > 기본값(depth 1 / 8페이지). - const cfg = vscode.workspace.getConfiguration('g1nation'); - const crawlDepth = depthArg ?? (cfg.get('datacollectCrawlDepth', 1) ?? 1); - const maxPages = pagesArg ?? (cfg.get('datacollectMaxPages', 8) ?? 8); - - chunk(view, `🔍 **Web Benchmark 스캔**: ${url}\n(crawlDepth ${crawlDepth} · 최대 ${maxPages}페이지 · Playwright)\n\n⏳ 브라우저 기동 · 페이지 크롤링 · 디자인 토큰 추출 중…`); - - // 1) scan — 진행 중 멈춘 줄로 오해하지 않도록 4초마다 경과 시간을 누적 표시. - const t0 = Date.now(); - const heartbeat = setInterval(() => { - chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`); - }, 4000); - const scan = await bridgeFetch<{ success: boolean; scan: any; slug?: string }>( - BRIDGE_API.web.benchmarkScan, - { - method: 'POST', - body: JSON.stringify({ url, captureScreenshots: false, maxPages, crawlDepth }), - }, - { timeoutMs: 6 * 60_000 }, - ).finally(() => clearInterval(heartbeat)); - const s = scan.scan; - chunk(view, `\n✅ **스캔 완료** (${Math.round((Date.now() - t0) / 1000)}s · ${s?.sitemap?.totalPages ?? 1}페이지)\n\n`); - - // 스캔이 에러 없이 끝나도(잘못된 URL·봇 차단·JS 렌더링 등) 알맹이가 빌 수 있다. - const looksEmpty = !s?.meta?.title - && !(s?.design?.colors?.palette?.length) - && !s?.microcopy?.headline; - if (looksEmpty) { - chunk(view, `⚠️ **스캔 결과가 비어 있습니다.** URL이 정확한지, 사이트가 접근 가능한지(봇 차단·JS 전용 렌더링 포함) 확인하세요. 스캔한 URL: \`${url}\`\n\n`); - } - - // raw 스캔 요약 — LLM 합성이 실패하거나 스캔이 비었을 때의 fallback 본문. - const palette = s?.design?.colors?.palette?.slice(0, 5) || []; - const rawReport = [ - `### 메타`, - `- **title**: ${s?.meta?.title || '(없음)'}`, - `- **description**: ${s?.meta?.description || '(없음)'}`, - `- **lang**: ${s?.meta?.lang || '(없음)'}`, - ``, - `### 디자인 토큰 (상위)`, - `- **컬러 팔레트 Top 5**: ${palette.map((p: any) => `\`${p.value}\` (×${p.count})`).join(', ') || '(없음)'}`, - `- **컬러 비율**: ${s?.design?.colors?.composition?.ratioLabel || '(없음)'}`, - `- **Primary Font**: \`${s?.design?.typography?.primaryFont || '(없음)'}\``, - ``, - `### 사이트맵 (${s?.sitemap?.totalPages ?? 1}페이지, depth ${s?.sitemap?.crawlDepth ?? 0})`, - '```', - s?.sitemap?.ascii || '(없음)', - '```', - ].join('\n'); - - // 2) synthesize — LLM 4-렌즈 합성 3단계 (Datacollect 웹앱과 동일한 리포트). - // 스캔이 비었거나 합성이 실패하면 raw 요약으로 fallback. - let finalReport: string; - if (looksEmpty) { - chunk(view, `(스캔이 비어 LLM 합성을 건너뜁니다.)\n\n`); - finalReport = rawReport; - } else { - const model = (cfg.get('defaultModel', '') || 'gemma4:e2b').trim(); - chunk(view, `🧪 **LLM 4-렌즈 합성** (3단계 · 모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…\n`); - try { - const parts: string[] = []; - for (const part of [1, 2, 3] as const) { - chunk(view, `\n · 합성 ${part}/3 진행 중…`); - const partT0 = Date.now(); - const out = await callLmSynthesis(buildSynthesisPrompt(s, userContent, part)); - if (!out) throw new Error(`LLM ${part}/3 응답이 비어 있습니다.`); - parts.push(out); - chunk(view, ` ✓ (${Math.round((Date.now() - partT0) / 1000)}s)`); - } - finalReport = parts.join('\n\n---\n\n'); - chunk(view, `\n\n`); - } catch (e: any) { - chunk(view, `\n\n⚠️ LLM 합성 실패: ${e?.message || String(e)}\n원시 스캔 요약만 표시·저장합니다. (LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`); - finalReport = rawReport; - } - } - - chunk(view, finalReport + '\n\n'); - - // 3) save — 저장 위치 우선순위: g1nation.datacollectSavePath > Bridge WIKI_RAW_PATH. - // 어느 쪽이든 Astra 코드에는 절대경로가 하드코딩되지 않는다. - try { - const today = new Date().toISOString().slice(0, 10); - let host = url; - try { host = new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`).host; } catch { /* keep raw url */ } - const title = `웹벤치마크 ${host} ${today}`; - const fileMarkdown = [ - `# ${title}`, - ``, - `- **원본 URL**: ${url}`, - `- **스캔 시각**: ${new Date().toISOString()}`, - `- **크롤 옵션**: depth ${crawlDepth} · 최대 ${maxPages}페이지`, - `- **생성**: Astra /benchmark · Datacollect web-benchmark`, - ``, - finalReport, - ``, - ].join('\n'); - const savePath = (cfg.get('datacollectSavePath', '') || '').trim(); - const body: Record = { title, content: fileMarkdown }; - if (savePath) body.saveDir = savePath; - const saved = await bridgeFetch<{ success: boolean; path?: string }>( - BRIDGE_API.wiki.save, - { method: 'POST', body: JSON.stringify(body) }, - { timeoutMs: 30_000 }, - ); - chunk(view, `💾 **결과물 저장 완료**: \`${saved?.path || '(경로 미확인)'}\`\n`); - } catch (e: any) { - chunk(view, `⚠️ 결과물 저장 실패: ${e?.message || String(e)}\n`); - } - - return true; -} - -// ───────────────────────────── /youtube ───────────────────────────── - -// formatHms / fullScriptFromSegments / bucketSegments / YoutubeAnalysisMode / buildInfoExtractionPrompt / build4LensPrompt -// → `src/features/datacollect/prompts/youtubePrompts.ts` -import { - type YoutubeAnalysisMode, - formatHms, - fullScriptFromSegments, - buildInfoExtractionPrompt, - build4LensPrompt, -} from './prompts/youtubePrompts'; - -/** - * URL 이 *채널/플레이리스트* 처럼 보이는지 휴리스틱. yt-dlp 는 채널 URL 을 - * 그대로 받아 영상 목록을 enumerate 하므로, 우리는 채널일 때 default limit - * 만 다르게 잡아주면 된다(단일 영상은 1, 채널은 10). - */ -function _looksLikeYoutubeChannelUrl(url: string): boolean { - return /youtube\.com\/(channel\/|@|c\/|user\/|playlist\?list=|playlist\/)/i.test(url) - || /youtube\.com\/[^/?#]+\/(videos|shorts|streams)\b/i.test(url); -} - -/** - * 채널 URL 을 yt-dlp 가 *영상 목록* 으로 정확히 enumerate 하는 형태로 정규화. - * - * 의도: `https://www.youtube.com/@handle` 같은 채널 "루트" 를 그냥 yt-dlp 에 - * 넘기면 영상 ID 대신 채널 ID(`UC...`) 가 영상 entry 로 잘못 돌아오는 사례 - * 발견 (Deno-AI 채널 케이스). `/videos` 탭을 명시하면 정상 enumerate. - * - * 규칙: - * - 이미 `/videos`, `/shorts`, `/streams`, `/playlist` 가 path 에 있으면 그대로 - * - 단일 영상 URL (`watch?v=`, `youtu.be/`, `/shorts/`) 는 그대로 - * - 그 외 채널 패턴 (`@handle`, `channel/UC..`, `c/name`, `user/name`) 만 - * `/videos` 를 append (query 가 있으면 path 뒤에 끼움) - */ -function _normalizeYoutubeUrl(url: string): string { - try { - const u = new URL(url); - if (!/youtube\.com$|youtube\.com\.|youtu\.be$/i.test(u.hostname)) return url; - const p = u.pathname; - // 이미 영상 단위거나 탭/플레이리스트가 명시된 경우는 손대지 말 것. - if (/\/(watch|shorts|playlist|videos|streams|featured|community|about)\b/i.test(p)) return url; - if (u.hostname.includes('youtu.be')) return url; // youtu.be/ 는 영상 short link - // 채널 루트 패턴 — `/videos` 를 append (이미 끝 슬래시 있으면 정리). - if (/^\/(@[^/]+|channel\/[^/]+|c\/[^/]+|user\/[^/]+)\/?$/i.test(p)) { - u.pathname = p.replace(/\/?$/, '/videos'); - return u.toString(); - } - return url; - } catch { - return url; // URL 파싱 실패 시 손대지 않음 - } -} - -/** - * Datacollect bridge 가 자주 뱉는 환경 의존성 에러(Python 패키지 미설치, Python - * 자체 부재 등) 를 패턴 매칭해서 사용자에게 *해결 명령까지* 알려주는 가이드 텍스트. - * 없으면 빈 문자열 반환. slashRouter 의 catch 블록에서 일반 에러 메시지 뒤에 - * append 하는 안전망. - */ -function _bridgeErrorRemedy(rawMsg: string): string { - const msg = String(rawMsg || ''); - // 패턴 1 — Python 패키지 미설치 (bridge 가 명시적으로 알려줌). - const pkgMatch = msg.match(/필수 패키지가 없습니다?[:\s]+([\w\-,\s.]+)/i) - || msg.match(/missing (?:python )?packages?[:\s]+([\w\-,\s.]+)/i); - if (pkgMatch) { - const pkgs = pkgMatch[1].split(/[,\s]+/).map((s) => s.trim()).filter(Boolean).join(' '); - return `\n\n💡 **해결**: Datacollect bridge 가 도는 환경에서 아래 명령으로 누락된 Python 패키지를 설치하세요.\n\n` - + '```bash\n' - + `# macOS (homebrew Python — PEP 668 보호 우회):\n` - + `python3 -m pip install --user --break-system-packages ${pkgs}\n\n` - + `# 또는 가상환경(venv) 사용 시 그 venv 활성화 후:\n` - + `pip install ${pkgs}\n` - + '```\n\n' - + `설치 후 **bridge 재시작은 보통 불필요** — bridge 는 Python 을 child process 로 spawn 하므로 다음 호출이 바로 새 패키지를 인식합니다. 그래도 안 되면 \`npm run bridge\` 재시작.\n`; - } - // 패턴 2 — Python 자체가 없거나 PATH 에 없음. - if (/Python 3이 설치돼 있지 않거나 PATH/i.test(msg) || /command not found.*python/i.test(msg)) { - return `\n\n💡 **해결**: Python 3 이 설치돼 있어야 합니다. https://www.python.org 에서 설치 후 터미널에서 \`python3 --version\` 으로 확인하세요. 이미 설치돼 있으면 PATH 설정 확인 필요.`; - } - // 패턴 3 — bridge 자체에 연결 실패. - if (/ECONNREFUSED|fetch failed/i.test(msg) || /연결할 수 없습니다/i.test(msg)) { - return `\n\n💡 **해결**: Datacollect bridge 가 떠 있지 않습니다. \`Datacollector_MAC\` 프로젝트에서 \`npm run bridge\` 실행 후 다시 시도하세요.`; - } - return ''; -} - -/** - * 채널/플레이리스트 처리 시 한 번에 너무 많이 돌려 사용자가 후회하지 않도록 cap. - * 영상 1건당 LLM 분석에 보통 30~120s 걸리는 점을 감안. - */ -const YOUTUBE_BATCH_MAX = 50; - -async function runYoutube(arg: string, view: Webview | undefined): Promise { - // 토큰 파싱 — URL 뒤로는 두 가지 형태의 키워드 + 자유 컨텍스트 텍스트. - // - // n:<숫자> → 채널일 때 가져올 영상 개수 - // mode: → 분석 모드 (key:value 형) - // info / benchmark / both → 같은 모드의 bare keyword 형 (더 짧고 직관적) - // - // bare keyword 가 작동하는 이유: `info`/`benchmark`/`both` 는 영어 단어이고 - // 한국어 사용자가 컨텍스트로 쓸 가능성이 매우 낮아 충돌 위험 적음. 사용자가 - // 진짜 이 단어들을 컨텍스트로 넣고 싶으면 `mode:` 접두사를 빼지 말고 명시 - // (이 경우 일반 단어도 컨텍스트로 같이 넣을 수 있음). - // - // 위 패턴 중 하나도 매칭 안 되는 토큰은 모두 사용자 컨텍스트로 join. - const BARE_MODE_KEYWORDS = new Set(['info', 'benchmark', 'both']); - const tokens = arg.trim().split(/\s+/).filter(Boolean); - const url = tokens[0] || ''; - let limitOverride: number | null = null; - let mode: YoutubeAnalysisMode = 'both'; - const contextTokens: string[] = []; - for (const tok of tokens.slice(1)) { - const nMatch = tok.match(/^n[:=](\d+)$/i); - if (nMatch) { - const n = parseInt(nMatch[1], 10); - if (Number.isFinite(n) && n > 0) { - limitOverride = Math.min(YOUTUBE_BATCH_MAX, n); - } - continue; - } - const modeMatch = tok.match(/^mode[:=](info|benchmark|both)$/i); - if (modeMatch) { - mode = modeMatch[1].toLowerCase() as YoutubeAnalysisMode; - continue; - } - // Bare keyword 형 — `info` / `benchmark` / `both` 자체를 토큰으로. - const lower = tok.toLowerCase(); - if (BARE_MODE_KEYWORDS.has(lower)) { - mode = lower as YoutubeAnalysisMode; - continue; - } - contextTokens.push(tok); - } - const userContent = contextTokens.join(' '); - - if (!url) { - chunk(view, [ - `사용법:\n`, - `- 단일 영상: \`/youtube <영상URL> [info|benchmark|both] [컨텍스트]\`\n`, - `- 채널/플레이리스트: \`/youtube <채널URL> [n:30] [info|benchmark|both] [컨텍스트]\`\n`, - `\n**분석 모드** (생략 시 \`both\`):\n`, - `- \`info\` — 영상의 *내용*을 지식 카드로 추출 (튜토리얼·강의·뉴스·인터뷰)\n`, - `- \`benchmark\` — 대본 역기획서 4-렌즈 분석 (콘텐츠 제작 벤치마크용)\n`, - `- \`both\` — 둘 다 생성 (영상당 LLM 호출 2회)\n`, - `\n예시:\n`, - `- \`/youtube https://youtu.be/abc info\`\n`, - `- \`/youtube https://youtube.com/@somechannel n:20 info AI 학습 자료\`\n`, - `\n💡 \`mode:info\` / \`mode=info\` 같은 명시형도 그대로 동작 (백워드 호환).\n`, - ].join('')); - return true; - } - - // 채널 URL 감지 → 기본 10개. 단일 영상은 1개. 사용자가 `n:N` 으로 명시했으면 그 값. - const isChannel = _looksLikeYoutubeChannelUrl(url); - const limit = limitOverride ?? (isChannel ? 10 : 1); - - // yt-dlp 가 영상 목록을 enumerate 할 수 있도록 채널 루트 URL 에 `/videos` 탭을 - // 자동 append. 그렇지 않으면 채널 ID(UC...)가 영상 ID 로 잘못 들어가는 사고. - const normalizedUrl = isChannel ? _normalizeYoutubeUrl(url) : url; - if (normalizedUrl !== url) { - chunk(view, `🔧 채널 URL 정규화: \`${url}\` → \`${normalizedUrl}\` (yt-dlp 영상 enumeration 을 위한 \`/videos\` 탭 명시)\n\n`); - } - - const modeLabel = mode === 'info' ? '📋 정보 추출 (지식 카드)' - : mode === 'benchmark' ? '🎬 벤치마킹 (4-렌즈 역기획서)' - : '📋 정보 추출 + 🎬 벤치마킹 (둘 다)'; - if (isChannel) { - const callsPerVideo = mode === 'both' ? 2 : 1; - chunk(view, `📺 **채널/플레이리스트 감지** → 최신 ${limit}개 영상을 1개씩 순차 분석·wiki화 합니다.\n` + - `분석 모드: **${modeLabel}** (영상당 LLM ${callsPerVideo}회 호출)\n` + - `각 영상은 자막추출 → LLM 분석 → wiki 저장 순으로 처리되며, 영상당 보통 30~${120 * callsPerVideo}초.\n` + - `중간에 멈추려면 Astra 사이드바의 ⏹ Stop 을 누르세요.\n\n`); - } else { - chunk(view, `📊 **분석 모드**: ${modeLabel}\n\n`); - } - - chunk(view, `🎬 **YouTube 추출**: ${normalizedUrl}\n(자막 + 메타데이터${limit > 1 ? `, ${limit}개 영상` : ''})\n\n⏳ Python 추출기 기동 · 자막/메타 추출 중…`); - // 1) extract — Bridge는 `source` 필드를 기대한다(`url`이 아님). - const t0 = Date.now(); - const heartbeat = setInterval(() => { - chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`); - }, 4000); - // 채널은 영상 수에 비례해 yt-dlp 시간이 늘어남 — limit 비례 timeout 으로 완화. - const extractTimeoutMs = Math.max(5 * 60_000, limit * 60_000); - const data = await bridgeFetch<{ success: boolean; videos?: any[]; totalVideos?: number }>( - BRIDGE_API.youtube.extract, - { method: 'POST', body: JSON.stringify({ source: normalizedUrl, withMetadata: true, limit }) }, - { - timeoutMs: extractTimeoutMs, - onHeartbeat: limit > 1 - ? (elapsedMs) => chunk(view, `\n · 추출 진행 중 (${Math.round(elapsedMs / 1000)}s, ${limit}개 영상)\n`) - : undefined, - }, - ).finally(() => clearInterval(heartbeat)); - - const okVideos = (data.videos || []).filter((v: any) => v?.status === 'ok'); - chunk(view, `\n✅ **추출 완료** (${Math.round((Date.now() - t0) / 1000)}s · ${okVideos.length}/${data.totalVideos ?? (data.videos || []).length}개 영상)\n\n`); - if (okVideos.length === 0) { - chunk(view, `⚠️ 자막 추출에 성공한 영상이 없습니다. 자막이 없거나 비공개 영상일 수 있습니다.\n`); - return true; - } - - const cfg = vscode.workspace.getConfiguration('g1nation'); - const model = (cfg.get('defaultModel', '') || 'gemma4:e2b').trim(); - // 시스템 프롬프트는 모드별로 분리 — info 는 *큐레이터* 톤, benchmark 는 *PD* 톤. - // 작은 모델일수록 system prompt 의 역할 정의가 출력 품질을 크게 좌우. - const sysInfo = '당신은 영상 콘텐츠를 지식 카드로 변환하는 정보 큐레이터입니다. 자막에 명시된 사실만 인용하세요.'; - const sysBench = '당신은 유튜브 콘텐츠 시니어 PD입니다. 데이터에 근거한 제작 가이드만 제공하세요.'; - - // 각 영상의 분석을 mode 에 따라 1회 또는 2회 LLM 호출. - // 결과는 (라벨, 보고서 본문) 의 배열로 모아 chat 출력 + wiki 저장에 같은 데이터 사용. - type Section = { label: string; body: string }; - async function runOneAnalysis(video: any, prompt: string, system: string, sectionLabel: string, progressTag: string): Promise
{ - chunk(view, `🧪 **${sectionLabel}**${progressTag} (모델 \`${model}\`)…`); - try { - const t = Date.now(); - const body = await callLmSynthesis(prompt, system); - if (!body) throw new Error('LLM 응답이 비어 있습니다.'); - chunk(view, ` ✓ (${Math.round((Date.now() - t) / 1000)}s)\n\n`); - chunk(view, body + '\n\n'); - return { label: sectionLabel, body }; - } catch (e: any) { - chunk(view, `\n\n⚠️ ${sectionLabel} 실패${progressTag}: ${e?.message || String(e)}\n`); - return null; - } - } - - // 2) 영상마다 LLM 분석 → wiki 저장. **queue 처럼 1개씩 순차** — - // 채널 N개면 i/N 진행 표시. 하나가 실패해도 다음으로 계속 (continue 로 - // skip), 다 끝나면 마지막에 통계 요약을 한 줄로 흘림. - const total = okVideos.length; - let analyzedOk = 0; - let analyzedFail = 0; - let savedOk = 0; - let savedFail = 0; - const batchT0 = Date.now(); - for (let i = 0; i < okVideos.length; i++) { - const video = okVideos[i]; - const vTitle = video?.metadata?.title || video?.title || video?.video_id || '(제목 없음)'; - const progressTag = total > 1 ? ` [${i + 1}/${total}]` : ''; - - if (total > 1) { - chunk(view, `\n━━━ **${progressTag.trim()} ${vTitle}** ━━━\n\n`); - } - - // 보고서 앞에 영상 전체 스크립트를 먼저 출력 — 분석과 원문 대본을 함께 보도록. - const script = fullScriptFromSegments(video?.segments); - chunk(view, `## 📜 전체 스크립트 (Full Script)\n\n${script}\n\n---\n\n`); - - // mode 분기 — info / benchmark / both 에 맞게 0~2회 LLM 호출. - const sections: Section[] = []; - if (mode === 'info' || mode === 'both') { - const sec = await runOneAnalysis(video, buildInfoExtractionPrompt(video, userContent), sysInfo, '📋 정보 추출 (지식 카드)', progressTag); - if (sec) sections.push(sec); - } - if (mode === 'benchmark' || mode === 'both') { - const sec = await runOneAnalysis(video, build4LensPrompt(video, userContent), sysBench, '🎬 벤치마킹 (4-렌즈 역기획서)', progressTag); - if (sec) sections.push(sec); - } - - if (sections.length === 0) { - analyzedFail++; - chunk(view, `(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`); - continue; - } - analyzedOk++; - - // 3) save — benchmark와 동일하게 /api/wiki/save (datacollectSavePath > WIKI_RAW_PATH). - // wiki 본문은 위에서 LLM 호출한 sections 를 그대로 한 파일에 이어붙여 보관. - try { - const today = new Date().toISOString().slice(0, 10); - const videoUrl = video?.metadata?.webpage_url || `https://www.youtube.com/watch?v=${video?.video_id}`; - // mode 별로 파일명 접미사 — 같은 영상의 info / benchmark / both 가 한 폴더에서 구분되도록. - const modeSuffix = mode === 'info' ? ' (정보)' - : mode === 'benchmark' ? ' (벤치마크)' - : ''; - const title = `유튜브분석 ${vTitle}${modeSuffix} ${today}`; - const sectionDivider = sections.length > 1 ? `\n\n---\n\n` : ''; - const fileMarkdown = [ - `# ${title}`, - ``, - `- **영상 URL**: ${videoUrl}`, - `- **분석 시각**: ${new Date().toISOString()}`, - `- **분석 모드**: ${mode}`, - `- **생성**: Astra /youtube · Datacollect youtube insight`, - ``, - `## 📜 전체 스크립트 (Full Script)`, - ``, - script, - ``, - `---`, - ``, - sections.map((s) => s.body).join(sectionDivider), - ``, - ].join('\n'); - const savePath = (cfg.get('datacollectSavePath', '') || '').trim(); - const body: Record = { title, content: fileMarkdown }; - if (savePath) body.saveDir = savePath; - const saved = await bridgeFetch<{ success: boolean; path?: string }>( - BRIDGE_API.wiki.save, - { method: 'POST', body: JSON.stringify(body) }, - { timeoutMs: 30_000 }, - ); - savedOk++; - chunk(view, `💾 **결과물 저장 완료**${progressTag}: \`${saved?.path || '(경로 미확인)'}\`\n\n`); - } catch (e: any) { - savedFail++; - chunk(view, `⚠️ 결과물 저장 실패${progressTag}: ${e?.message || String(e)}\n\n`); - } - } - - // 배치 처리(=채널/플레이리스트) 끝나면 통계 한 줄로 마무리. 단일 영상은 위에서 이미 끝. - if (total > 1) { - const batchSec = Math.round((Date.now() - batchT0) / 1000); - chunk(view, `\n━━━━━━━━━━━━━━━━━━━━\n` - + `🏁 **배치 완료** (총 ${batchSec}s · ${total}개 영상)\n` - + `- 분석: ✅ ${analyzedOk} / ❌ ${analyzedFail}\n` - + `- 저장: 💾 ${savedOk} / ⚠️ ${savedFail}\n`); - } - - return true; -} - -// ───────────────────────────── /blog ───────────────────────────── - -async function runBlog(keyword: string, view: Webview | undefined): Promise { - // Blog Pipeline은 Datacollect의 별도 흐름(blog/app.js + local_platform_server 8787)으로 - // 실행된다. Bridge 3002에는 대응 endpoint가 없어 Astra가 직접 호출할 경로가 없음. - // MVP에서는 사용자가 그쪽 UI로 빠르게 갈 수 있도록 안내만. - const target = 'http://127.0.0.1:8787/blog/'; - chunk(view, `🖋️ **Blog Pipeline**\n\n`); - if (keyword) { - chunk(view, `요청 키워드: \`${keyword}\`\n\n`); - } - chunk(view, [ - `현재 Blog Pipeline은 Datacollect의 별도 정적 페이지(`, - `[${target}](${target})`, - `)에서 단계별로 실행됩니다. Bridge(3002)에 대응 API가 없어 채팅에서 직접`, - ` 트리거할 수 없어, 다음 두 가지 중 하나를 선택해 주세요:\n\n`, - ].join('')); - chunk(view, `1. 위 링크에서 키워드/참고 자료/경험담을 입력해 직접 파이프라인 실행\n`); - chunk(view, `2. Bridge에 \`/api/blog/run\` 같은 endpoint를 노출하면 \`/blog\`도 end-to-end 실행 가능 — 원하시면 추가 작업으로 진행\n`); - - try { - await vscode.env.openExternal(vscode.Uri.parse(target)); - } catch { /* best-effort */ } - return true; -} - -// ───────────────────────────── /wikify ───────────────────────────── - -// buildWikifyPrompt → `src/features/datacollect/prompts/wikifyPrompt.ts` -import { buildWikifyPrompt } from './prompts/wikifyPrompt'; - -/** - * URL 한 개를 위키화 — 본문 추출 → P-Reinforce v3.0 합성 → 저장. - * 다중 링크 배치 시 runWikify가 이 함수를 순차 반복 호출한다. - * - * @returns `{ ok: true }` 시 위키 문서 생성·저장 성공. `{ ok: false, reason }` 시 - * 실패 원인 (본문 빈약 / 합성 실패 / 저장 실패) 을 문자열로 surface. - * 옛 boolean 반환은 다중 URL 배치에서 사용자가 *어디서 깨졌는지* 모름 ─ - * 이번 변경으로 배치 끝 통계에 실패 사유까지 표시 가능. - * (extract 자체 실패는 throw — 호출자가 잡는다.) - */ -type WikifyResult = { ok: true } | { ok: false; reason: string }; -async function wikifyOne(url: string, userContent: string, view: Webview | undefined): Promise { - const cfg = vscode.workspace.getConfiguration('g1nation'); - - // 1) extract — Bridge가 Playwright로 main/article 본문 텍스트를 추출. - chunk(view, `⏳ 본문 추출 중…`); - const t0 = Date.now(); - const heartbeat = setInterval(() => { - chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`); - }, 4000); - const data = await bridgeFetch<{ success: boolean; url: string; title?: string; description?: string; lang?: string; headings?: string[]; text?: string; textLength?: number; truncated?: boolean }>( - BRIDGE_API.web.extract, - { method: 'POST', body: JSON.stringify({ url }) }, - { timeoutMs: 3 * 60_000 }, - ).finally(() => clearInterval(heartbeat)); - chunk(view, `\n✅ 본문 추출 (${Math.round((Date.now() - t0) / 1000)}s · ${(data.textLength ?? 0).toLocaleString()}자${data.truncated ? ', 일부 잘림' : ''})\n\n`); - - if (!data.text || data.text.trim().length < 50) { - const reason = `본문 빈약 (${data.textLength ?? 0}자 — JS 전용 렌더링 또는 콘텐츠 없음)`; - chunk(view, `⚠️ 추출된 본문이 거의 없어 건너뜁니다. (${reason})\n`); - return { ok: false, reason }; - } - - // 2) synthesize — P-Reinforce v3.0 위키 문서로 LLM 합성. - const model = (cfg.get('defaultModel', '') || 'gemma4:e2b').trim(); - const wikiSystem = '당신은 지식 큐레이터입니다. 제공된 웹사이트 본문을 P-Reinforce v3.0 규격의 고밀도 위키 문서로 정리합니다. 본문에 없는 내용은 절대 지어내지 않으며, 모든 문서는 한국어로 작성합니다.'; - chunk(view, `🧪 P-Reinforce 위키 합성 (모델 \`${model}\`)…`); - let report: string; - try { - const synthT0 = Date.now(); - report = await callLmSynthesis(buildWikifyPrompt(data, userContent), wikiSystem); - if (!report) throw new Error('LLM 응답이 비어 있습니다.'); - // LLM이 위키링크 [[ ]]의 닫는 대괄호를 하나 빠뜨리는 깨짐을 자동 교정. - report = report.replace(/\[\[([^\[\]]+?)\](?!\])/g, '[[$1]]'); - chunk(view, ` ✓ (${Math.round((Date.now() - synthT0) / 1000)}s)\n\n`); - } catch (e: any) { - const reason = `LLM 합성 실패: ${e?.message || String(e)}`; - chunk(view, `\n⚠️ 위키 합성 실패: ${e?.message || String(e)}\n(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`); - return { ok: false, reason }; - } - chunk(view, report + '\n\n'); - - // 3) save — /api/wiki/save (datacollectSavePath > WIKI_RAW_PATH). - // report 자체가 frontmatter 포함 완결 위키 문서이므로 그대로 저장한다. - try { - const today = new Date().toISOString().slice(0, 10); - let host = url; - try { host = new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`).host; } catch { /* keep raw url */ } - const title = `위키 ${(userContent.trim() || data.title || host)} ${today}`; - const savePath = (cfg.get('datacollectSavePath', '') || '').trim(); - const body: Record = { title, content: report }; - if (savePath) body.saveDir = savePath; - const saved = await bridgeFetch<{ success: boolean; path?: string }>( - BRIDGE_API.wiki.save, - { method: 'POST', body: JSON.stringify(body) }, - { timeoutMs: 30_000 }, - ); - chunk(view, `💾 위키 문서 저장 완료: \`${saved?.path || '(경로 미확인)'}\`\n`); - } catch (e: any) { - // 저장 실패해도 위키 문서 생성 자체는 성공했으므로 ok 로 본다. 다만 사용자 - // 에게 디스크 접근 문제 알림. - chunk(view, `⚠️ 위키 문서 저장 실패: ${e?.message || String(e)}\n`); - } - return { ok: true }; -} - -async function runWikify(arg: string, view: Webview | undefined): Promise { - // 토큰을 URL과 비-URL로 분류. URL처럼 생긴 토큰은 모두 처리 대상, 나머지는 공통 주제명. - // 여러 링크를 공백으로 구분해 넣으면 1개씩 순차적으로 위키화한다. - const isUrl = (t: string) => /^https?:\/\//i.test(t) || /^[a-z0-9-]+(\.[a-z0-9-]+)+/i.test(t); - const tokens = arg.trim().split(/\s+/).filter(Boolean); - const urls = tokens.filter(isUrl); - const userContent = tokens.filter((t) => !isUrl(t)).join(' '); - if (urls.length === 0) { - chunk(view, `사용법: \`/wikify [url2 url3 …] [주제명]\`\n예: \`/wikify https://example.com\`\n여러 링크를 공백으로 구분해 한 번에 넣으면 1개씩 순차 위키화합니다.\n`); - return true; - } - - // 단일 링크 — 그대로 처리. - if (urls.length === 1) { - chunk(view, `📚 **위키화**: ${urls[0]}\n(본문 추출 → P-Reinforce v3.0 위키 문서 합성)\n\n`); - const result = await wikifyOne(urls[0], userContent, view); - if (!result.ok) { - chunk(view, `\n실패 사유: ${result.reason}\n`); - } - return true; - } - - // 다중 링크 — 1개씩 순차 처리. 한 건이 실패해도 나머지는 계속 진행. - // 실패한 건은 *사유* 까지 모아 마지막에 표시 → 사용자가 어느 URL 이 왜 깨졌는지 즉시 파악. - chunk(view, `📚 **위키화 배치**: 총 ${urls.length}개 링크를 순차 처리합니다.\n`); - const batchT0 = Date.now(); - let okCount = 0; - const failures: Array<{ url: string; reason: string }> = []; - for (let i = 0; i < urls.length; i++) { - chunk(view, `\n---\n\n### [${i + 1}/${urls.length}] ${urls[i]}\n\n`); - try { - const result = await wikifyOne(urls[i], userContent, view); - if (result.ok) okCount++; - else failures.push({ url: urls[i], reason: result.reason }); - } catch (e: any) { - const reason = `처리 오류: ${e?.message || String(e)}`; - chunk(view, `❌ [${i + 1}/${urls.length}] ${reason}\n`); - failures.push({ url: urls[i], reason }); - } - } - chunk(view, `\n---\n\n🏁 **배치 완료**: ${okCount}/${urls.length}개 성공 (${Math.round((Date.now() - batchT0) / 1000)}s 소요)\n`); - if (failures.length > 0) { - chunk(view, `\n**실패 ${failures.length}건 사유**:\n`); - for (const f of failures) chunk(view, `- ${f.url} — ${f.reason}\n`); - } - return true; -} - -// ───────────────────────────── /meet ───────────────────────────── - -// buildMeetPrompt → `src/features/datacollect/prompts/meetPrompt.ts` -import { buildMeetPrompt } from './prompts/meetPrompt'; - -async function runMeet(arg: string, view: Webview | undefined, context?: vscode.ExtensionContext): Promise { - // 경로 파싱 — 따옴표로 감싸면 공백 포함 경로 허용, 아니면 첫 공백 전까지가 경로. - const trimmed = arg.trim(); - let filePath = ''; - let metadata = ''; - if (trimmed.startsWith('"')) { - const end = trimmed.indexOf('"', 1); - if (end > 0) { - filePath = trimmed.slice(1, end); - metadata = trimmed.slice(end + 1).trim(); - } - } - if (!filePath) { - const sp = trimmed.indexOf(' '); - if (sp === -1) { - filePath = trimmed; - } else { - filePath = trimmed.slice(0, sp); - metadata = trimmed.slice(sp + 1).trim(); - } - } - if (!filePath) { - chunk(view, `사용법: \`/meet [참석자·날짜 등 메타데이터]\`\n예: \`/meet c:\\doc\\0101.txt\`\n경로에 공백이 있으면 따옴표로 감싸세요: \`/meet "c:\\my docs\\0101.txt"\`\n`); - return true; - } - - chunk(view, `📝 **회의록 작성**: ${filePath}\n\n⏳ 녹취 파일 읽는 중…`); - - // 1) 로컬 txt 파일 읽기 — ASTRA가 직접 (로컬 파일이라 Bridge 불필요). - let transcript: string; - try { - transcript = await fsp.readFile(filePath, 'utf-8'); - } catch (e: any) { - chunk(view, `\n\n❌ 파일을 읽을 수 없습니다: ${e?.message || String(e)}\n경로가 정확한지 확인하세요.\n`); - return true; - } - if (!transcript || transcript.trim().length < 20) { - chunk(view, `\n\n⚠️ 파일 내용이 거의 비어 있습니다.\n`); - return true; - } - // LLM 입력 폭주 방지 — 60000자 상한. - const MAX = 60000; - const truncated = transcript.length > MAX; - if (truncated) transcript = transcript.slice(0, MAX); - chunk(view, `\n✅ 파일 읽기 완료 (${transcript.length.toLocaleString()}자${truncated ? ', 상한 초과로 일부 잘림' : ''})\n\n`); - - // 2) LLM 회의록 합성. - const cfg = vscode.workspace.getConfiguration('g1nation'); - const model = (cfg.get('defaultModel', '') || 'gemma4:e2b').trim(); - const meetSystem = '당신은 회의록 작성 전문가입니다. 제공된 녹취록과 메타데이터만 근거로 사실 기반의 구조화된 회의록을 작성하며, 외부 지식이나 추측을 절대 섞지 않습니다. 모든 출력은 한국어로 작성합니다.'; - chunk(view, `🧪 **회의록 합성** (모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…`); - let report: string; - try { - const t0 = Date.now(); - report = await callLmSynthesis(buildMeetPrompt(transcript, metadata), meetSystem); - if (!report) throw new Error('LLM 응답이 비어 있습니다.'); - chunk(view, ` ✓ (${Math.round((Date.now() - t0) / 1000)}s)\n\n`); - } catch (e: any) { - chunk(view, `\n\n⚠️ 회의록 합성 실패: ${e?.message || String(e)}\n(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`); - return true; - } - chunk(view, report + '\n\n'); - - // 3) 저장 — /api/wiki/save (datacollectSavePath > WIKI_RAW_PATH). - // WIKI_RAW_PATH가 E:\Wiki\2nd\00_Raw 를 가리키므로 결과물이 그곳에 .md로 저장된다. - try { - const today = new Date().toISOString().slice(0, 10); - const baseName = filePath.replace(/^.*[\\/]/, '').replace(/\.[^.]+$/, '') || 'meeting'; - const title = `회의록 ${baseName} ${today}`; - const savePath = (cfg.get('datacollectSavePath', '') || '').trim(); - const body: Record = { title, content: report }; - if (savePath) body.saveDir = savePath; - const saved = await bridgeFetch<{ success: boolean; path?: string }>( - BRIDGE_API.wiki.save, - { method: 'POST', body: JSON.stringify(body) }, - { timeoutMs: 30_000 }, - ); - chunk(view, `💾 **회의록 저장 완료**: \`${saved?.path || '(경로 미확인)'}\`\n`); - } catch (e: any) { - chunk(view, `⚠️ 회의록 저장 실패: ${e?.message || String(e)}\n`); - } - - // 4) 캘린더 자동 등록 — 액션 아이템을 task별 종일 일정으로 Google Calendar에 등록. - if (context) { - try { - const calCfg = readCalendarConfig(context); - if (!calCfg.refreshToken) { - chunk(view, `\nℹ️ 캘린더 자동 등록을 건너뜁니다 — Google Calendar OAuth(쓰기)가 연결되지 않았습니다. (Astra Settings → Google 섹션에서 연결)\n`); - } else { - const tasks = parseActionItems(report); - if (tasks.length === 0) { - chunk(view, `\nℹ️ 액션 아이템이 없어 캘린더에 등록할 항목이 없습니다.\n`); - } else { - const today = new Date(); - const meetingDate = extractMeetingDate(report, today); - const titleMatch = report.match(/^#\s+(.+)$/m); - const meetTitle = titleMatch - ? titleMatch[1].replace(/^\[회의 제목\]\s*/, '').trim() - : '회의'; - // 액션 아이템 destination — Tasks·Calendar 독립 토글. 기본 둘 다 true. - // 양쪽 다 끄면 자동 등록 자체를 건너뛴다. - const gCfg = vscode.workspace.getConfiguration('g1nation'); - const meetUsesTasks = gCfg.get('meetUsesTasks', true); - const meetUsesCalendar = gCfg.get('meetUsesCalendar', true); - if (!meetUsesTasks && !meetUsesCalendar) { - chunk(view, `\nℹ️ Google Tasks·Calendar 등록이 모두 꺼져 있어 액션 아이템 자동 등록을 건너뜁니다. (Settings 의 \`g1nation.meetUsesTasks\` / \`g1nation.meetUsesCalendar\` 확인)\n`); - } else { - const destLabel = [meetUsesTasks && 'Tasks', meetUsesCalendar && 'Calendar'].filter(Boolean).join(' + '); - chunk(view, `\n📝 **Google ${destLabel} 등록**: 액션 아이템 ${tasks.length}건…\n`); - let tasksOk = 0; - let calendarOk = 0; - let tentativeCount = 0; - for (const task of tasks) { - const { date, tentative } = resolveTaskDate(task.due, meetingDate, today); - if (tentative) tentativeCount++; - const evTitle = tentative ? `${task.work} (미확정)` : task.work; - const notes = `회의록: ${meetTitle}\n담당: ${task.owner}\n기한 표기: ${task.due || '(없음)'}\nAstra /meet 자동 등록`; - - const successes: string[] = []; - const failures: string[] = []; - if (meetUsesTasks) { - const r = await createTask(context, { title: evTitle, due: date, notes }); - if (r.ok) { tasksOk++; successes.push('Tasks'); } - else { failures.push(`Tasks: ${r.error}`); } - } - if (meetUsesCalendar) { - const r = await createCalendarEvent(context, { - title: evTitle, - start: date, - allDay: true, - description: notes, - }); - if (r.ok) { calendarOk++; successes.push('Calendar'); } - else { failures.push(`Calendar: ${r.error}`); } - } - - if (failures.length === 0) { - chunk(view, ` · ${date} — ${evTitle} (${successes.join(' + ')})\n`); - } else { - chunk(view, ` · ${date} — ${evTitle}${successes.length ? ` (✅ ${successes.join(' + ')})` : ''}\n`); - for (const f of failures) chunk(view, ` ⚠️ ${f}\n`); - } - } - const summary: string[] = []; - if (meetUsesTasks) summary.push(`Tasks ${tasksOk}/${tasks.length}`); - if (meetUsesCalendar) summary.push(`Calendar ${calendarOk}/${tasks.length}`); - chunk(view, `✅ 등록 완료 — ${summary.join(' · ')}${tentativeCount > 0 ? ` · 미확정 ${tentativeCount}건` : ''}\n`); - } - } - } - } catch (e: any) { - chunk(view, `\n⚠️ 캘린더 등록 중 오류: ${e?.message || String(e)}\n`); - } - } - return true; -} - -// addBusinessDays / toYmd / extractMeetingDate / resolveTaskDate / parseActionItems -// → `src/features/datacollect/scheduling/calendarHelpers.ts` -import { - addBusinessDays, - toYmd, - extractMeetingDate, - resolveTaskDate, - parseActionItems, -} from './scheduling/calendarHelpers'; // ─── /task — 단발 작업을 Google Tasks + Calendar 양쪽에 등록 ───────────── // `/task <제목> <시작일> <완료일>` (range) 또는 `/task <제목> <날짜>` (단일일) @@ -1190,2950 +179,8 @@ import { // /meet 와 달리 사용자가 직접 호출한 명령이므로 *항상* 양쪽 등록(설정 무시). /** 유연한 한국 날짜 파서. YY/MM/DD · YYYY/MM/DD · YYYY-MM-DD 지원. 잘못된 입력은 null. */ -function parseFlexibleDate(s: string): string | null { - if (!s) return null; - let y: number, mo: number, d: number; - let m = s.match(/^(\d{2})\/(\d{1,2})\/(\d{1,2})$/); - if (m) { y = 2000 + Number(m[1]); mo = Number(m[2]); d = Number(m[3]); } - else if ((m = s.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})$/))) { y = Number(m[1]); mo = Number(m[2]); d = Number(m[3]); } - else if ((m = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/))) { y = Number(m[1]); mo = Number(m[2]); d = Number(m[3]); } - else return null; - if (mo < 1 || mo > 12 || d < 1 || d > 31) return null; - // 진짜 유효한 날짜인지 (예: 2/30 차단) Date 로 round-trip 검증. - const date = new Date(y, mo - 1, d); - if (Number.isNaN(date.getTime()) || date.getMonth() !== mo - 1 || date.getDate() !== d) return null; - return `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`; -} -async function runTask(arg: string, view: any, context?: vscode.ExtensionContext): Promise { - if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /task 실행 불가.\n'); return true; } - if (!arg.trim()) { - chunk(view, [ - '\n📋 **/task — Google Tasks + Calendar 동시 등록**', - '', - '사용법:', - ' `/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` (담당자 없음)', - '', - 'Tasks API + Calendar API 양쪽 등록. Tasks 는 마감일만(시작일은 노트), Calendar 는 기간 all-day 일정.', - '', - ].join('\n')); - return true; - } - 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; - let startYmd: string, endYmd: string, titleTokens: string[]; - if (lastDate && secondLastDate) { - startYmd = secondLastDate; - endYmd = lastDate; - titleTokens = tokens.slice(0, -2); - } else if (lastDate) { - startYmd = lastDate; - endYmd = lastDate; - titleTokens = tokens.slice(0, -1); - } else { - chunk(view, `\n❌ 마지막 토큰이 날짜 형식이 아닙니다 ("${tokens[tokens.length - 1]}"). 사용 가능 형식: YY/MM/DD · YYYY-MM-DD · YYYY/MM/DD.\n사용법: \`/task\` (인자 없이) 실행해 도움말 보기.\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}${owner ? ` · 담당: @${owner}` : ''}\n`); - - const notes = `${owner ? `담당: @${owner}\n` : ''}Astra /task 직접 등록\n기간: ${periodLabel}`; - const successes: string[] = []; - const failures: string[] = []; - let calLink: string | undefined; - - // Tasks — due = 완료일. Tasks 모델은 start 없음 → 시작일은 노트에 포함. - const taskNotes = isRange ? `${notes}\n(Tasks 는 마감일만 사용 — 시작일은 노트 참조)` : notes; - const taskResult = await createTask(context, { title, due: endYmd, notes: taskNotes }); - if (taskResult.ok) successes.push('Tasks'); - else failures.push(`Tasks: ${taskResult.error}`); - - // Calendar — all-day 일정. Google Calendar 의 all-day end 는 *exclusive* 라 - // 완료일을 포함시키려면 +1 일 해서 전달해야 함. - const calEnd = _addDaysDate(endYmd, 1); - const calResult = await createCalendarEvent(context, { - title, start: startYmd, end: calEnd, allDay: true, description: notes, - }); - if (calResult.ok) { - successes.push('Calendar'); - calLink = calResult.event.htmlLink; - } else { - failures.push(`Calendar: ${calResult.error}`); - } - - if (failures.length === 0) { - chunk(view, `✅ 등록 완료 — ${successes.join(' + ')}\n`); - } else if (successes.length > 0) { - chunk(view, `✅ 부분 성공 — ${successes.join(' + ')}\n`); - for (const f of failures) chunk(view, ` ⚠️ ${f}\n`); - } else { - chunk(view, `❌ 모두 실패\n`); - for (const f of failures) chunk(view, ` · ${f}\n`); - } - if (calLink) chunk(view, `🔗 Calendar 일정 열기: ${calLink}\n`); - 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(); - clearTermValidatorCache(); - chunk(view, '\n🔄 글로서리 캐시 비움 (system prompt + Term Validator 모두). 다음 채팅 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개 명령. 외부 플러그인 / 사용자 정의 명령은 동일하게 @@ -4144,31 +191,11 @@ async function runHelp(arg: string, view: any, _context?: vscode.ExtensionContex // registerSlashCommand({ name: '/my', description: '...', handler: runMyCmd }); // // 이 한 줄이면 isSlashCommand(input) 와 handleSlashCommand 가 자동으로 인식. -registerSlashCommand({ name: '/research', description: 'NotebookLM Deep Research 호출 후 위키 합성', handler: runResearch }); -registerSlashCommand({ name: '/benchmark', description: 'Playwright 웹 벤치마크 + 4-렌즈 LLM 분석', handler: runBenchmark }); -registerSlashCommand({ name: '/youtube', description: 'YouTube 단일 영상 또는 채널/플레이리스트 분석', handler: runYoutube }); -registerSlashCommand({ name: '/blog', description: 'Blog Pipeline 안내 (Datacollect 별도 흐름)', handler: runBlog }); -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 가 그 안에서 분기. import { handleStocksCommand } from '../stocks'; registerSlashCommand({ name: '/stocks', description: '종목 모니터링 — Yahoo 가격, Sheets 동기화, 텔레그램 보고서, LLM judge', handler: handleStocksCommand }); + +// TeamOps handlers — v2.2.196 부터 도메인별 파일로 분리. 배럴 import 는 entry point +// (`src/extension.ts`) 에서 처리 — 순환 import 피하기 위함. diff --git a/src/features/feedback/feedbackStore.ts b/src/features/feedback/feedbackStore.ts index b1ce8af..c438b33 100644 --- a/src/features/feedback/feedbackStore.ts +++ b/src/features/feedback/feedbackStore.ts @@ -12,9 +12,7 @@ * 위치 확인 가능. 민감 정보 포함 가능성 있으므로 외부로 안 보냄 — 로컬 only. */ -import * as fs from 'fs'; -import * as path from 'path'; -import * as vscode from 'vscode'; +import { createEventStore } from '../_shared/eventSourcedStore'; const STORE_REL_PATH = '.astra/customer-feedback.jsonl'; @@ -33,49 +31,14 @@ export interface FeedbackEntry { 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); -} +const _store = createEventStore({ + relPath: STORE_REL_PATH, + validate: (e): e is FeedbackEntry => !!e + && typeof (e as any).id === 'string' + && typeof (e as any).text === 'string', +}); -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; } -} +export const getFeedbackFilePath = _store.getFilePath; +export const readFeedback = _store.read; +export const appendFeedback = _store.append; +export const countFeedback = _store.count; diff --git a/src/features/hire/hireStore.ts b/src/features/hire/hireStore.ts index 0dba0ab..6ace8f1 100644 --- a/src/features/hire/hireStore.ts +++ b/src/features/hire/hireStore.ts @@ -13,7 +13,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as vscode from 'vscode'; +import { createEventStore } from '../_shared/eventSourcedStore'; const STORE_REL_PATH = '.astra/hire.jsonl'; @@ -53,47 +53,22 @@ export interface CandidateState { 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); -} +const _store = createEventStore({ + relPath: STORE_REL_PATH, + validate: (e): e is HireEvent => !!e + && typeof (e as any).id === 'string' + && typeof (e as any).candidateId === 'string' + && typeof (e as any).type === 'string', +}); + +export const getHireFilePath = _store.getFilePath; +export const readHireEvents = _store.read; +export const appendHireEvent = _store.append; 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(); diff --git a/src/features/runway/runwayStore.ts b/src/features/runway/runwayStore.ts index f92de30..7515071 100644 --- a/src/features/runway/runwayStore.ts +++ b/src/features/runway/runwayStore.ts @@ -11,9 +11,7 @@ * 민감 정보(현금 잔고) 포함되므로 외부로 안 보냄 — 로컬 only. */ -import * as fs from 'fs'; -import * as path from 'path'; -import * as vscode from 'vscode'; +import { createEventStore } from '../_shared/eventSourcedStore'; const STORE_REL_PATH = '.astra/runway.jsonl'; @@ -36,42 +34,17 @@ export interface RunwayEntry { 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); -} +const _store = createEventStore({ + relPath: STORE_REL_PATH, + validate: (e): e is RunwayEntry => !!e + && typeof (e as any).id === 'string' + && typeof (e as any).amount === 'number' + && typeof (e as any).type === 'string', +}); -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) }; - } -} +export const getRunwayFilePath = _store.getFilePath; +export const readRunway = _store.read; +export const appendRunway = _store.append; /** * 현재 상태 계산 — 최신 snapshot, 최근 30일 net burn, 명시적 burn 설정 중 우선순위. diff --git a/src/features/system/handlers.ts b/src/features/system/handlers.ts new file mode 100644 index 0000000..b99a217 --- /dev/null +++ b/src/features/system/handlers.ts @@ -0,0 +1,394 @@ +/** + * System handlers — /memory · /glossary · /help (인프라·관리·발견). + * + * v2.2.200 에서 slashRouter.ts 에서 분리. 4인 팀 운영 도메인 아닌 + * "ASTRA 자체 인프라" 슬래시 명령. teamops handlers 와 같은 패턴. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { registerSlashCommand, chunk, listSlashCommands } from '../datacollect/slashRouter'; +import { getConfig } from '../../config'; +import { + MemoryManager, + distillStaleEpisodes, + getLastDistillationRun, + recordDistillationRun, + type DistillationArchiveMode, +} from '../../memory'; +import { + getGlossaryFilePath, + GLOSSARY_TEMPLATE, + clearGlossaryCache, +} from '../../retrieval/terminologyBlock'; +import { clearTermValidatorCache } from '../../agent/termValidator'; + +// ─── /memory — 메모리 라이프사이클 관리 ───────────────────────────────── + +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; } + + 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`); + } + + 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): 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; +} + +// ─── /glossary — Terminology Dictionary 사용자 진입점 ─────────────────── + +async function runGlossary(arg: string, view: any): 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(); + clearTermValidatorCache(); + chunk(view, '\n🔄 글로서리 캐시 비움 (system prompt + Term Validator 모두). 다음 채팅 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`); + 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 — 카테고리별 슬래시 명령 브라우저 ───────────────────────────── + +interface HelpCategory { + title: string; + emoji: string; + 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): Promise { + const trimmed = arg.trim().toLowerCase(); + const allCommands = listSlashCommands(); + + 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`); + } + } + + 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; +} + +// ─── 등록 ───────────────────────────────────────────────────────────────── + +registerSlashCommand({ name: '/memory', description: '메모리 라이프사이클 — Temporal Markers (만료일) + Distillation Loop (stale episodes → LongTerm digest)', handler: runMemory }); +registerSlashCommand({ name: '/glossary', description: 'Terminology Dictionary — 표준 용어집 (.astra/glossary.md) 상태/생성/리로드. 다음 turn 부터 system prompt 자동 주입', handler: runGlossary }); +registerSlashCommand({ name: '/help', description: '슬래시 명령 전체 목록 (카테고리별) + ASTRA 추론 엔진 상태. `/help <명령>` 으로 특정 명령 정보.', handler: runHelp }); diff --git a/src/features/teamops/handlers/_shared.ts b/src/features/teamops/handlers/_shared.ts new file mode 100644 index 0000000..8cfe02b --- /dev/null +++ b/src/features/teamops/handlers/_shared.ts @@ -0,0 +1,84 @@ +/** + * TeamOps handlers 공통 헬퍼 — 4인 팀 운영 슬래시 명령 클러스터가 공유하는 함수·상수. + * + * 이전 위치: `src/features/datacollect/slashRouter.ts` 내부 module-local 함수 + * (v2.2.196 에서 분리). 핸들러 도메인별 분할 시 공통 항목만 여기에. + * + * 추후 datacollect / system 핸들러도 비슷한 공유 helpers 가 필요해지면 + * `src/features/_shared/` 로 promote 고려. + */ + +/** 한국식 KRW 숫자 포맷 — 만/억 단위 자동. 마이너스 부호 보존. */ +export 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')}`; +} + +/** 한국식 금액 토큰 파싱 — "5000만", "1.5억", "300000", "1,500,000", "10k", "5m" 모두 인식. */ +export function parseAmount(token: string): number | null { + if (!token) return null; + const s = token.replace(/[,_]/g, '').trim(); + 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; + let mul = 1; + 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; +} + +/** D-day 계산 — ISO 날짜 (YYYY-MM-DD) 받아 오늘부터 며칠 후인지. 음수면 지난 일수. */ +export 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)); +} + +export interface ParsedTaskOwner { owner: string | undefined; displayTitle: string; } + +/** + * Task 제목·notes 에서 owner 추출. 두 패턴 지원: + * 1. 제목 prefix `[멤버] 실제 제목` — /task, /meet 가 등록한 형식 + * 2. notes 의 `담당: @이름` 또는 `담당: 이름` — 일부 외부 등록 호환 + */ +export function parseTaskOwner(title: string, notes?: string): ParsedTaskOwner { + const titlePrefix = title.match(/^\[([^\]]+)\]\s*(.+)$/); + if (titlePrefix) return { owner: titlePrefix[1].trim(), displayTitle: titlePrefix[2].trim() }; + const notesMatch = (notes || '').match(/담당:\s*(?:@)?([\S]+)/); + if (notesMatch) return { owner: notesMatch[1].trim(), displayTitle: title }; + return { owner: undefined, displayTitle: title }; +} + +/** /hire 파이프라인 단계 정렬 가중치. inbox 1, hired 7, terminal 99. */ +export const STAGE_ORDER: Record = { + inbox: 1, screened: 2, interview: 3, final: 4, offer: 5, + accepted: 6, hired: 7, rejected: 99, declined: 99, +}; + +/** 종료된 후보 단계. 검색·통계에서 active 와 구분. */ +export const TERMINAL_STAGES = new Set(['hired', 'rejected', 'declined']); + +/** 단계별 emoji — UI 표시. */ +export 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 '•'; + } +} diff --git a/src/features/teamops/handlers/communication.ts b/src/features/teamops/handlers/communication.ts new file mode 100644 index 0000000..727176e --- /dev/null +++ b/src/features/teamops/handlers/communication.ts @@ -0,0 +1,255 @@ +/** + * TeamOps Communication — /draft · /feedback (외부 출력·기록). + * + * v2.2.199 에서 slashRouter.ts 에서 분리. (원래는 v2.2.200 예정이었으나 coordination + * 추출 시 register 라인이 인접해 묶여 함께 진행.) + */ + +import * as fs from 'fs'; +import * as vscode from 'vscode'; +import { registerSlashCommand, chunk } from '../../datacollect/slashRouter'; +import { callLmSynthesis } from '../../datacollect/llm'; +import { + appendFeedback, readFeedback, getFeedbackFilePath, countFeedback, + type FeedbackEntry, +} from '../../feedback/feedbackStore'; + +// ─── /draft — 외부 커뮤니케이션 초안 ───────────────────────────────────── + +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): 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 — 고객 피드백 누적 + 패턴 분석 ─────────────────────────── + +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; } + + 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`); + + chunk(view, '\n🤖 카테고리 자동 분류 중...\n'); + try { + const llmOut = await callLmSynthesis(text.trim(), FEEDBACK_CATEGORIZE_PROMPT); + 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; + const enriched: FeedbackEntry = { ...initialEntry, categories, sentiment }; + 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`); + 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`); + 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): 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; + } + + 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: await feedbackSave(trimmed, view); return true; + } +} + +// ─── 등록 ───────────────────────────────────────────────────────────────── + +registerSlashCommand({ name: '/draft', description: '외부 커뮤니케이션 초안 — email/slack/blog/newsletter/investor-update/proposal', handler: runDraft }); +registerSlashCommand({ name: '/feedback', description: '고객 피드백 누적 + 자동 카테고리 분류 + 패턴 분석 (로컬 .jsonl)', handler: runFeedback }); diff --git a/src/features/teamops/handlers/coordination.ts b/src/features/teamops/handlers/coordination.ts new file mode 100644 index 0000000..eff61ff --- /dev/null +++ b/src/features/teamops/handlers/coordination.ts @@ -0,0 +1,572 @@ +/** + * TeamOps Coordination — /task · /decisions · /onesie · /blocked · /standup. + * + * v2.2.199 에서 slashRouter.ts 에서 분리. 작업·결정·1:1·블로커·스탠드업 등 + * "팀 운영의 실시간 부분" 클러스터. + * + * 공통 헬퍼는 `./_shared.ts` 에서. 옛 slashRouter 의 local parseTaskOwner/ + * parseFlexibleDate 도 여기로 (parseFlexibleDate 는 /task 만 사용). + */ + +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as path from 'path'; +import { registerSlashCommand, chunk } from '../../datacollect/slashRouter'; +import { parseTaskOwner } from './_shared'; +import { createCalendarEvent, createTask, listTasks, _addDaysDate } from '../../calendar'; +import { ChronicleProjectStore } from '../../../sidebar/managers/chronicleProjectStore'; + +// ─── 공통 헬퍼 — /task 전용 ────────────────────────────────────────────── + +/** 유연한 한국 날짜 파서. YY/MM/DD · YYYY/MM/DD · YYYY-MM-DD 지원. */ +function parseFlexibleDate(s: string): string | null { + if (!s) return null; + let y: number, mo: number, d: number; + let m = s.match(/^(\d{2})\/(\d{1,2})\/(\d{1,2})$/); + if (m) { y = 2000 + Number(m[1]); mo = Number(m[2]); d = Number(m[3]); } + else if ((m = s.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})$/))) { y = Number(m[1]); mo = Number(m[2]); d = Number(m[3]); } + else if ((m = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/))) { y = Number(m[1]); mo = Number(m[2]); d = Number(m[3]); } + else return null; + if (mo < 1 || mo > 12 || d < 1 || d > 31) return null; + const date = new Date(y, mo - 1, d); + if (Number.isNaN(date.getTime()) || date.getMonth() !== mo - 1 || date.getDate() !== d) return null; + return `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`; +} + +// ─── /task — Google Tasks + Calendar 동시 등록 ────────────────────────── + +async function runTask(arg: string, view: any, context?: vscode.ExtensionContext): Promise { + if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /task 실행 불가.\n'); return true; } + if (!arg.trim()) { + chunk(view, [ + '\n📋 **/task — Google Tasks + Calendar 동시 등록**', + '', + '사용법:', + ' `/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` (담당자 없음)', + '', + 'Tasks API + Calendar API 양쪽 등록. Tasks 는 마감일만(시작일은 노트), Calendar 는 기간 all-day 일정.', + '', + ].join('\n')); + return true; + } + + const tokens = arg.trim().split(/\s+/); + if (tokens.length < 2) { chunk(view, '\n❌ 인자 부족 — 최소 제목 + 날짜 1개 필요.\n'); return true; } + + 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; } + } + + const lastDate = parseFlexibleDate(tokens[tokens.length - 1]); + const secondLastDate = tokens.length >= 3 ? parseFlexibleDate(tokens[tokens.length - 2]) : null; + let startYmd: string, endYmd: string, titleTokens: string[]; + if (lastDate && secondLastDate) { + startYmd = secondLastDate; endYmd = lastDate; titleTokens = tokens.slice(0, -2); + } else if (lastDate) { + startYmd = lastDate; endYmd = lastDate; titleTokens = tokens.slice(0, -1); + } else { + chunk(view, `\n❌ 마지막 토큰이 날짜 형식이 아닙니다 ("${tokens[tokens.length - 1]}"). 사용 가능 형식: YY/MM/DD · YYYY-MM-DD · YYYY/MM/DD.\n사용법: \`/task\` (인자 없이) 실행해 도움말 보기.\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; } + + 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}${owner ? ` · 담당: @${owner}` : ''}\n`); + + const notes = `${owner ? `담당: @${owner}\n` : ''}Astra /task 직접 등록\n기간: ${periodLabel}`; + const successes: string[] = []; + const failures: string[] = []; + let calLink: string | undefined; + + const taskNotes = isRange ? `${notes}\n(Tasks 는 마감일만 사용 — 시작일은 노트 참조)` : notes; + const taskResult = await createTask(context, { title, due: endYmd, notes: taskNotes }); + if (taskResult.ok) successes.push('Tasks'); + else failures.push(`Tasks: ${taskResult.error}`); + + const calEnd = _addDaysDate(endYmd, 1); + const calResult = await createCalendarEvent(context, { title, start: startYmd, end: calEnd, allDay: true, description: notes }); + if (calResult.ok) { successes.push('Calendar'); calLink = calResult.event.htmlLink; } + else failures.push(`Calendar: ${calResult.error}`); + + if (failures.length === 0) chunk(view, `✅ 등록 완료 — ${successes.join(' + ')}\n`); + else if (successes.length > 0) { + chunk(view, `✅ 부분 성공 — ${successes.join(' + ')}\n`); + for (const f of failures) chunk(view, ` ⚠️ ${f}\n`); + } else { + chunk(view, `❌ 모두 실패\n`); + for (const f of failures) chunk(view, ` · ${f}\n`); + } + if (calLink) chunk(view, `🔗 Calendar 일정 열기: ${calLink}\n`); + return true; +} + +// ─── /decisions — Chronicle ADR 검색 ───────────────────────────────────── + +async function runDecisions(arg: string, view: any, context?: vscode.ExtensionContext): Promise { + if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /decisions 실행 불가.\n'); return true; } + + 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; + } + + 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(); + + 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 미팅 준비 카드 ───────────────────────────────── + +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`); + + 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}`), + ); + + 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); + + 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); + + 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`); + + 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; +} + +// ─── /blocked — 전사 across 지연·블로커 한 화면 ────────────────────────── + +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); + } + 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 — 팀 스탠드업 카드 (슬랙 복붙 친화) ──────────────────────── + +async function runStandup(arg: string, view: any, context?: vscode.ExtensionContext): Promise { + if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /standup 실행 불가.\n'); return true; } + + 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)); + } + + if (byMember.size === 0) { + chunk(view, '\nℹ️ 이 기간에 활동이 있는 task 가 없습니다. `/task @<멤버> ...` 로 등록 시작.\n'); + return true; + } + + 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]); + }); + + 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; +} + +// ─── 등록 ───────────────────────────────────────────────────────────────── + +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: '/blocked', description: '전사 across 지연·블로커 한 화면 — 일일 아침 ritual', handler: runBlocked }); +registerSlashCommand({ name: '/standup', description: '팀 스탠드업 카드 (멤버별 완료/진행/블로커, 슬랙 복붙 친화)', handler: runStandup }); diff --git a/src/features/teamops/handlers/dashboards.ts b/src/features/teamops/handlers/dashboards.ts new file mode 100644 index 0000000..c2fbc11 --- /dev/null +++ b/src/features/teamops/handlers/dashboards.ts @@ -0,0 +1,806 @@ +/** + * TeamOps Dashboards — /morning · /evening · /cohort · /weekly (CEO 일·주 리듬). + * + * v2.2.198 에서 slashRouter.ts 에서 분리. cross-data 합성 (Tasks + customers + + * hire + runway + Chronicle ADR) 으로 일/주/월 단위 시야 제공. + * + * 공통 헬퍼는 `./_shared.ts` 에서. dashboards 전용 helper (_isoWeek, _aggregateWeek, + * _morningActions 등) 는 이 파일 안에. + */ + +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as path from 'path'; +import { registerSlashCommand, chunk } from '../../datacollect/slashRouter'; +import { + fmtKrw as _fmtKrw, daysUntil as _daysUntil, + parseTaskOwner, stageEmoji as _stageEmoji, + STAGE_ORDER, TERMINAL_STAGES, +} from './_shared'; +import { listTasks } from '../../calendar'; +import { ChronicleProjectStore } from '../../../sidebar/managers/chronicleProjectStore'; +import { + computeRunwayStatus, readRunway, type RunwayEntry, +} from '../../runway/runwayStore'; +import { + computeCustomerStates, readEvents as readCustomerEvents, + type CustomerEvent, type CustomerState, +} from '../../customers/customersStore'; +import { + computeCandidateStates, readHireEvents, + type HireEvent, type CandidateState, +} from '../../hire/hireStore'; + +// ─── /morning — 매일 아침 통합 대시보드 ────────────────────────────────── + +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`); + + const runway = computeRunwayStatus(); + const customerStates = computeCustomerStates(); + const customers = Array.from(customerStates.values()); + const candidateStates = computeCandidateStates(); + const candidates = Array.from(candidateStates.values()); + + 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); } + } + + const urgent: string[] = []; + 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)}원/월 노출)`); + } + 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}건**`); + 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) { + 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; + } + + 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'); + } + } + + 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`); + } + } + } + + 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`); + } + } + + 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`); + } + } + } + + 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; +} + +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}일 정체.`); + } + const inboxCount = candidates.filter((c) => c.stage === 'inbox').length; + if (inboxCount >= 5) actions.push(`📥 채용 inbox ${inboxCount}명 누적 — 스크리닝 시간 확보.`); + return actions; +} + +// ─── /evening — 하루 마무리 카드 ───────────────────────────────────────── + +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`); + + 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); } + } + + 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`); + } + } + } + + chunk(view, '\n## 🌅 내일 준비\n'); + 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`); + } + } + + 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`); + } + } + + 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'); + } + + 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; +} + +// ─── /cohort — MoM 추세 분석 ───────────────────────────────────────────── + +interface MonthlyBucket { + yearMonth: string; + newCustomers: number; + churnedCustomers: number; + renewals: number; + mrrDelta: number; + expenseTotal: number; + revenueTotal: number; + cashSnapshots: number[]; +} + +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: [], + }); + } + 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++; + else if (e.type === 'renew') b.renewals++; + } + 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`); + + 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`); + 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[] = []; + 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): 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 전략 시야) ──────────────────────────── + +function _isoWeek(d: Date): { year: number; week: number; label: string } { + 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; + endIso: string; + startMs: number; + endMs: number; + label: string; +} + +function _thisWeekWindow(now: Date = new Date()): WeeklyWindow { + const dayOfWeek = now.getDay(); + 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, + }; + 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); + } + 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++; + } + 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++; + } + 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; + } + 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(); + + const adrs: { date: string; title: string }[] = []; + try { + const folders = vscode.workspace.workspaceFolders; + if (folders && folders.length > 0 && context) { + const cs = new ChronicleProjectStore(context); + const projects = cs.getAll(); + for (const p of projects) { + const decisionsDir = path.join(p.recordRoot, '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); + + 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`); + } + + chunk(view, `\n## 🔄 지난 주 대비 (${priorWeek.label})\n`); + chunk(view, `- 작업: ${aggNow.taskCompleted}건 ${_deltaSymbol(aggNow.taskCompleted, aggPrev.taskCompleted)} _(이번 ${aggNow.taskCompleted} ← 지난 ${aggPrev.taskCompleted})_\n`); + chunk(view, `- 신규 고객: ${aggNow.customerNewCount} ${_deltaSymbol(aggNow.customerNewCount, aggPrev.customerNewCount)}\n`); + chunk(view, `- 이탈: ${aggNow.customerChurnCount} ${_deltaSymbol(aggNow.customerChurnCount, aggPrev.customerChurnCount)}\n`); + 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 ? '↓' : '→'; + chunk(view, `- 순 burn: ${_fmtKrw(burnNow)} ${arrow} _(지난 ${_fmtKrw(burnPrev)})_\n`); + } + chunk(view, `- 채용 이벤트: ${aggNow.hireEvents} ${_deltaSymbol(aggNow.hireEvents, aggPrev.hireEvents)}\n`); + + chunk(view, '\n## 🌅 다음 주 준비\n'); + 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`); + } + } + + 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; +} + +// ─── 등록 ───────────────────────────────────────────────────────────────── + +registerSlashCommand({ name: '/morning', description: '매일 아침 통합 대시보드 — runway/customers/tasks/hire 핵심 한 화면 + 오늘의 액션', handler: runMorning }); +registerSlashCommand({ name: '/evening', description: '하루 마무리 카드 — 오늘의 진척 + 내일 준비 + 회고 한 줄', handler: runEvening }); +registerSlashCommand({ name: '/cohort', description: 'MoM 추세 분석 — customers + runway events 월별 그룹핑 (신규/이탈/MRR/burn)', handler: runCohort }); +registerSlashCommand({ name: '/weekly', description: '주간 리뷰 카드 (대표용) — 이번 주 진척 + 지난 주 대비 + 다음 주 준비 + 회고', handler: runWeekly }); diff --git a/src/features/teamops/handlers/index.ts b/src/features/teamops/handlers/index.ts new file mode 100644 index 0000000..9367183 --- /dev/null +++ b/src/features/teamops/handlers/index.ts @@ -0,0 +1,14 @@ +/** + * TeamOps handlers 배럴 — extension activate 시 1회 import 로 모든 핸들러 등록. + * + * 각 핸들러 파일은 module scope 에서 `registerSlashCommand({...})` 호출하므로 + * import 만으로 등록 완료. 새 핸들러 파일은 여기에 한 줄 추가. + * + * v2.2.196 — trackers (runway/customers/hire) 분리. coordination/communication/ + * dashboards 는 다음 빌드. + */ + +import './trackers'; +import './dashboards'; +import './coordination'; +import './communication'; diff --git a/src/features/teamops/handlers/trackers.ts b/src/features/teamops/handlers/trackers.ts new file mode 100644 index 0000000..da45607 --- /dev/null +++ b/src/features/teamops/handlers/trackers.ts @@ -0,0 +1,704 @@ +/** + * TeamOps Trackers — /runway · /customers · /hire (event-sourced 트래커 3종). + * + * v2.2.196 에서 slashRouter.ts 에서 분리. 모두 `.astra/*.jsonl` event log 를 + * 읽고 (createEventStore via 각 store 모듈) 대시보드 / 수정 명령 제공. + * + * 공통 헬퍼 (fmtKrw / parseAmount / daysUntil / stageEmoji / STAGE_ORDER / + * TERMINAL_STAGES) 는 `./_shared.ts` 에서. + */ + +import { registerSlashCommand, chunk } from '../../datacollect/slashRouter'; +import { + fmtKrw, parseAmount, daysUntil, + stageEmoji, STAGE_ORDER, TERMINAL_STAGES, +} from './_shared'; +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 CustomerState, +} from '../../customers/customersStore'; +import { + appendHireEvent, readHireEvents, getHireFilePath, candidateIdFromName, + computeCandidateStates, type HireEvent, type CandidateState, +} from '../../hire/hireStore'; + +// ─── /runway ────────────────────────────────────────────────────────────── + +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): 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 ─────────────────────────────────────────────────────────── + +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`); + + 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`); + } + } + + 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) { + 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): 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; + } + + 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 ──────────────────────────────────────────────────────────────── + +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): 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; +} + +// ─── 등록 ───────────────────────────────────────────────────────────────── + +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 }); diff --git a/src/lib/contextBuilders/memoryContext.ts b/src/lib/contextBuilders/memoryContext.ts index f216e50..f5bc808 100644 --- a/src/lib/contextBuilders/memoryContext.ts +++ b/src/lib/contextBuilders/memoryContext.ts @@ -63,28 +63,16 @@ export interface TurnContextSink { lessons: string[]; knowledgeMix: ResolvedKnowledgeMix | null; /** - * [CONFLICT WARNINGS] 시스템 프롬프트 블록 — 빈 문자열이면 충돌 없음. - * buildAstraModeSystemPrompt 가 직접 prompt 에 주입. + * 동적 시스템 프롬프트 블록 — id → 본문. memoryContext 가 이 turn 에 채우고, + * buildAstraModeSystemPrompt 가 iterate 해서 [CONTEXT] 밖에 join 주입. + * Casual conversation 모드면 자동 skip. 빈 본문은 자동 제외. + * + * 등록 순서대로 prompt 에 join — `intent-clarification` → `terminology` → + * `conflict-warnings` → `cove-checklist` → `citation-trace`. */ - conflictWarnings: string; - /** - * [VERIFICATION CHECKLIST] Chain-of-Verification 블록 — 빈 문자열이면 CoVe 비활성/근거 없음. - * 모델이 답변 *작성 전* 그라운딩 체크하도록 instructional prompt 주입. - */ - coveChecklist: string; - /** - * [INTENT CLARIFICATION GUIDANCE] 블록 — 질의가 모호한 차원이 감지된 경우 LLM 에게 - * 답변보다 *역질문* 우선 지시. 빈 문자열이면 모호 차원 없음 또는 disable. - */ - intentClarification: string; - /** - * [CITATION TRACE] 블록 — 답변 끝에 사용 출처 한 줄 정리 지시. 검색 결과 있을 때만. - */ - citationTrace: string; + dynamicBlocks: Map; /** Post-hoc Self-Check 용 — selected chunks 의 (title, excerpt) 요약. */ selfCheckSources: Array<{ title: string; excerpt: string }>; - /** [TERMINOLOGY DICTIONARY] 시스템 프롬프트 블록 — 글로서리 파일 있을 때만 채워짐. */ - terminology: string; } export interface MemoryContextDeps { @@ -284,47 +272,46 @@ 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 = ''; - } + // 동적 시스템 프롬프트 블록 빌드 — 등록 순서대로 turnCtx.dynamicBlocks 에 set. + // 옛 named field 5개 (conflictWarnings/coveChecklist/intentClarification/citationTrace/ + // terminology) 통합. 새 블록 추가 = 여기서 setBlock 한 줄. + const blocks = deps.turnCtx.dynamicBlocks; - // 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. + // Intent Clarification — 답변보다 *역질문 우선*. 모호 아닐 때 빈 문자열 → join 시 자동 제외. 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 = ''; + blocks.set('intent-clarification', buildIntentClarificationBlock(ambig)); } - // Citation Trace — 답변 끝에 출처 한 줄 명시 지시. CoVe Strict 의 가벼운 형제. - // 검색 결과가 있을 때만 의미 있음. + // Terminology Dictionary — 사용자 편집 글로서리. 파일 없으면 빈 문자열. + if (config.glossaryEnabled !== false) { + blocks.set('terminology', buildTerminologyBlock({ + relPath: config.glossaryPath || '.astra/glossary.md', + maxBodyLength: config.glossaryMaxBodyLength ?? 4000, + })); + } + + // Conflict Surface — selectedChunks 의 per-doc conflictSeverity + 교차-문서 발산. + if (config.conflictHighlightingEnabled !== false) { + const threshold: ConflictThresholdSetting = (config.conflictSeverityThreshold || 'medium') as ConflictThresholdSetting; + blocks.set('conflict-warnings', buildConflictWarningsBlock(result.selectedChunks, { + selfFlagThreshold: threshold, + crossDivergenceEnabled: config.conflictCrossDocEnabled !== false, + })); + } + + // CoVe — 답변 *작성 전* 그라운딩 체크리스트. + if (config.coveEnabled !== false) { + blocks.set('cove-checklist', buildCoveChecklistBlock(result.selectedChunks, deps.currentPrompt, { + topSourcesCount: config.coveTopSourcesCount ?? 5, + strictMode: config.coveStrictMode === true, + })); + } + + // Citation Trace — 답변 끝 출처 한 줄. if (config.citationTraceEnabled !== false && result.selectedChunks.length > 0) { - deps.turnCtx.citationTrace = buildCitationTraceBlock(result.selectedChunks); - } else { - deps.turnCtx.citationTrace = ''; + blocks.set('citation-trace', buildCitationTraceBlock(result.selectedChunks)); } // Self-Check 용 source 미리보기 — agent.ts 가 post-stream 에서 사용. @@ -333,17 +320,6 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise ({ title: c.title, content: c.content }))); diff --git a/src/lib/mtimeFileCache.ts b/src/lib/mtimeFileCache.ts new file mode 100644 index 0000000..9209bf9 --- /dev/null +++ b/src/lib/mtimeFileCache.ts @@ -0,0 +1,72 @@ +/** + * mtime-keyed file cache utility — 파일을 *parse 결과* 까지 캐싱. + * + * 배경: terminologyBlock.ts 와 termValidator.ts 가 같은 글로서리 파일에 *별도* + * 캐시 2개를 보유. "함께 무효화" 가 사람이 손으로 보장하는 invariant — 한쪽만 + * 잊고 invalidate 안 하면 stale read 위험. + * + * 이 유틸은: + * - 같은 파일을 캐싱하는 모든 consumer 가 같은 cache 인스턴스 사용 + * - mtime 자동 체크 — 파일이 바뀌면 자동 재read + * - `invalidate(filePath)` 가 한 번 호출되면 그 파일의 *모든 parse 결과* 무효화 + * + * 사용: + * const cache = createMtimeFileCache('terminology', (raw) => parseMine(raw)); + * const data = cache.read('/path/to/file.md'); // 캐시 hit or read+parse + * cache.invalidate('/path/to/file.md'); // 캐시 비움 + */ + +import * as fs from 'fs'; + +interface CacheEntry { + mtimeMs: number; + parsed: T; +} + +export interface MtimeFileCache { + /** 파일 read + parse + 캐시. mtime 같으면 캐시 hit. 파일 없으면 null. */ + read(filePath: string): T | null; + /** 특정 파일 캐시 무효화. */ + invalidate(filePath: string): void; + /** 모든 캐시 비움. */ + clear(): void; + /** 디버그용 — 현재 캐시 사이즈. */ + size(): number; +} + +/** + * 새 mtime-keyed 캐시 생성. `name` 은 디버그용 라벨 (consumer 식별). + * `parse` 는 raw 파일 본문 → 파싱 결과. throw 시 read() 가 null 반환. + */ +export function createMtimeFileCache( + _name: string, + parse: (raw: string, filePath: string) => T, +): MtimeFileCache { + const cache = new Map>(); + + return { + read(filePath: string): T | null { + try { + if (!fs.existsSync(filePath)) return null; + const stat = fs.statSync(filePath); + const cached = cache.get(filePath); + if (cached && cached.mtimeMs === stat.mtimeMs) return cached.parsed; + const raw = fs.readFileSync(filePath, 'utf-8'); + const parsed = parse(raw, filePath); + cache.set(filePath, { mtimeMs: stat.mtimeMs, parsed }); + return parsed; + } catch { + return null; + } + }, + invalidate(filePath: string): void { + cache.delete(filePath); + }, + clear(): void { + cache.clear(); + }, + size(): number { + return cache.size; + }, + }; +} diff --git a/src/retrieval/terminologyBlock.ts b/src/retrieval/terminologyBlock.ts index 9cd1c82..7491290 100644 --- a/src/retrieval/terminologyBlock.ts +++ b/src/retrieval/terminologyBlock.ts @@ -16,14 +16,14 @@ * 캐시: 파일 mtime 기반 — 매 turn 디스크 read 안 함. */ -import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; +import { createMtimeFileCache } from '../lib/mtimeFileCache'; const DEFAULT_GLOSSARY_REL_PATH = '.astra/glossary.md'; -/** mtime-keyed cache — 사용자가 편집할 때만 다시 읽음. */ -const _cache = new Map(); +/** Raw 본문 캐시 — mtime 기반, 파일 편집 시 자동 재read. */ +const _rawCache = createMtimeFileCache('terminology-raw', (raw) => raw.trim()); export function getGlossaryFilePath(relPath: string = DEFAULT_GLOSSARY_REL_PATH): string | null { const folders = vscode.workspace.workspaceFolders; @@ -34,21 +34,11 @@ export function getGlossaryFilePath(relPath: string = DEFAULT_GLOSSARY_REL_PATH) 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 ''; - } + return _rawCache.read(fp) ?? ''; } export function clearGlossaryCache(): void { - _cache.clear(); + _rawCache.clear(); } export interface TerminologyBlockOptions {