refactor: v2.2.195-201 — slashRouter god-file 해체 (–95%) + 인프라 5개 추출

아키텍처 감사 결과 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<E>(opts) — 4 store (customers/hire/runway/feedback) I/O 240줄 중복 제거
  - _turnCtx 5 named string field → 1 Map<string, string> (새 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<T>(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 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 11:55:22 +09:00
parent 15a34e0889
commit 7bec20620a
40 changed files with 4784 additions and 4545 deletions
+18 -18
View File
@@ -3,15 +3,15 @@
<!-- ASTRA:AUTO-START -->
## 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/<br/>263 files"]
src["src/<br/>274 files"]
media["media/<br/>6 files"]
tests["tests/<br/>37 files"]
core_py["core_py/<br/>6 files"]
docs["docs/<br/>107 files"]
docs["docs/<br/>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`_
<!-- ASTRA:AUTO-END -->
## Purpose
+183 -46
View File
@@ -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 이벤트 로그를 재생해 후보자별 현재 단계 + 노트 누적 도출. 위치: <w",
"imports": []
"imports": [
"src/features/_shared/eventSourcedStore"
]
},
"src/features/projectArchitecture/index.ts": {
"mtimeMs": 1778720117531.2734,
@@ -1378,11 +1408,13 @@
"imports": []
},
"src/features/runway/runwayStore.ts": {
"mtimeMs": 1779935315716.9917,
"size": 6666,
"lines": 173,
"mtimeMs": 1780275853704.5698,
"size": 5511,
"lines": 146,
"role": "Runway / Cash 누적 저장소. 4인 기업 운영의 가장 중요한 숫자 — 현금 잔고 / 월 소진율 / 남은 개월수 — 를 한 명령 (/runway) 로 본다. 회계 시스템은 아니고, 대표가 머리에 가지고 있는 \"지금 통장에 얼마, 한 달에 얼마 나감\" 을 코드 옆에서 잡는 가벼운 트래커. 저장 형식: JSON Lines (.jsonl) — 한 줄 = ",
"imports": []
"imports": [
"src/features/_shared/eventSourcedStore"
]
},
"src/features/secondBrainTrace.ts": {
"mtimeMs": 1779411561816.5603,
@@ -1631,6 +1663,19 @@
"src/utils"
]
},
"src/features/system/handlers.ts": {
"mtimeMs": 1780280889224.8892,
"size": 20518,
"lines": 394,
"role": "System handlers — /memory · /glossary · /help (인프라·관리·발견). v2.2.200 에서 slashRouter.ts 에서 분리. 4인 팀 운영 도메인 아닌 \"ASTRA 자체 인프라\" 슬래시 명령. teamops handlers 와 같은 패턴.",
"imports": [
"src/features/datacollect/slashRouter",
"src/config",
"src/memory",
"src/retrieval/terminologyBlock",
"src/agent/termValidator"
]
},
"src/features/tasks/index.ts": {
"mtimeMs": 1779065453887.9194,
"size": 238,
@@ -1647,6 +1692,82 @@
"role": "Task tracker — .astra/company/shared/tasks.md 단일 파일. 설계 원칙: - 외부 DB 없이 사람이 읽을 수 있는 마크다운 테이블에 누적. git 으로 history 추적 가능. - 파싱은 regex 기반 (셀 구분자 |). 사용자가 손으로 편집해도 fault-tolerant. - 모든 task 는 안정적 id (t001,",
"imports": []
},
"src/features/teamops/handlers/communication.ts": {
"mtimeMs": 1780280515102.4714,
"size": 13781,
"lines": 254,
"role": "TeamOps Communication — /draft · /feedback (외부 출력·기록). v2.2.199 에서 slashRouter.ts 에서 분리. (원래는 v2.2.200 예정이었으나 coordination 추출 시 register 라인이 인접해 묶여 함께 진행.)",
"imports": [
"src/features/datacollect/slashRouter",
"src/features/feedback/feedbackStore"
]
},
"src/features/teamops/handlers/coordination.ts": {
"mtimeMs": 1780280322364.4475,
"size": 30738,
"lines": 572,
"role": "TeamOps Coordination — /task · /decisions · /onesie · /blocked · /standup. v2.2.199 에서 slashRouter.ts 에서 분리. 작업·결정·1:1·블로커·스탠드업 등 \"팀 운영의 실시간 부분\" 클러스터. 공통 헬퍼는 ./shared.ts 에서. 옛 slashRouter 의 local pars",
"imports": [
"src/features/datacollect/slashRouter",
"src/features/teamops/handlers/_shared",
"src/features/calendar",
"src/sidebar/managers/chronicleProjectStore"
]
},
"src/features/teamops/handlers/dashboards.ts": {
"mtimeMs": 1780279486170.9111,
"size": 42079,
"lines": 806,
"role": "TeamOps Dashboards — /morning · /evening · /cohort · /weekly (CEO 일·주 리듬). v2.2.198 에서 slashRouter.ts 에서 분리. cross-data 합성 (Tasks + customers + hire + runway + Chronicle ADR) 으로 일/주/월 단위 시야 제공. 공통 헬퍼는",
"imports": [
"src/features/datacollect/slashRouter",
"src/features/teamops/handlers/_shared",
"src/features/calendar",
"src/sidebar/managers/chronicleProjectStore",
"src/features/runway/runwayStore",
"src/features/customers/customersStore",
"src/features/hire/hireStore"
]
},
"src/features/teamops/handlers/index.ts": {
"mtimeMs": 1780280542425.257,
"size": 501,
"lines": 14,
"role": "TeamOps handlers 배럴 — extension activate 시 1회 import 로 모든 핸들러 등록. 각 핸들러 파일은 module scope 에서 registerSlashCommand({...}) 호출하므로 import 만으로 등록 완료. 새 핸들러 파일은 여기에 한 줄 추가. v2.2.196 — trackers (runway/custom",
"imports": [
"src/features/teamops/handlers/trackers",
"src/features/teamops/handlers/dashboards",
"src/features/teamops/handlers/coordination",
"src/features/teamops/handlers/communication"
]
},
"src/features/teamops/handlers/trackers.ts": {
"mtimeMs": 1780278032934.0896,
"size": 36032,
"lines": 704,
"role": "TeamOps Trackers — /runway · /customers · /hire (event-sourced 트래커 3종). v2.2.196 에서 slashRouter.ts 에서 분리. 모두 .astra/.jsonl event log 를 읽고 (createEventStore via 각 store 모듈) 대시보드 / 수정 명령 제공. 공통 헬퍼 (fmtK",
"imports": [
"src/features/datacollect/slashRouter",
"src/features/teamops/handlers/_shared",
"src/features/runway/runwayStore",
"src/features/customers/customersStore",
"src/features/hire/hireStore"
]
},
"src/features/teamops/handlers/_shared.ts": {
"mtimeMs": 1780277843419.9949,
"size": 3647,
"lines": 84,
"role": "TeamOps handlers 공통 헬퍼 — 4인 팀 운영 슬래시 명령 클러스터가 공유하는 함수·상수. 이전 위치: src/features/datacollect/slashRouter.ts 내부 module-local 함수 (v2.2.196 에서 분리). 핸들러 도메인별 분할 시 공통 항목만 여기에. 추후 datacollect / system 핸들러도 비슷한",
"imports": []
},
"src/features/_shared/eventSourcedStore.ts": {
"mtimeMs": 1780275610701.943,
"size": 3216,
"lines": 83,
"role": "Generic event-sourced store — append-only .jsonl 파일 1개를 읽고/쓰는 공통 기반. 배경: customers, hire, runway, feedback 4개 store 가 같은 패턴 4번 반복 (getXFilePath / readX / appendX / countX) — byte-for-byte 중복 ~240줄. 한 ",
"imports": []
},
"src/integrations/telegram/conversationHistory.ts": {
"mtimeMs": 1778720117535.294,
"size": 6427,
@@ -1797,9 +1918,9 @@
]
},
"src/lib/contextBuilders/memoryContext.ts": {
"mtimeMs": 1780033602782.2012,
"size": 18059,
"lines": 352,
"mtimeMs": 1780276328602.9944,
"size": 16862,
"lines": 328,
"role": "",
"imports": [
"src/agent",
@@ -1974,6 +2095,13 @@
"src/lib/engine"
]
},
"src/lib/mtimeFileCache.ts": {
"mtimeMs": 1780278539124.1875,
"size": 2553,
"lines": 72,
"role": "mtime-keyed file cache utility — 파일을 parse 결과 까지 캐싱. 배경: terminologyBlock.ts 와 termValidator.ts 가 같은 글로서리 파일에 별도 캐시 2개를 보유. \"함께 무효화\" 가 사람이 손으로 보장하는 invariant — 한쪽만 잊고 invalidate 안 하면 stale read 위험. 이 ",
"imports": []
},
"src/lib/paths.ts": {
"mtimeMs": 1778664139961.0637,
"size": 6567,
@@ -2249,11 +2377,13 @@
]
},
"src/retrieval/terminologyBlock.ts": {
"mtimeMs": 1780033577419.837,
"size": 5735,
"lines": 137,
"mtimeMs": 1780278609386.6382,
"size": 5450,
"lines": 127,
"role": "Terminology Dictionary — 프로젝트 표준 용어집을 시스템 프롬프트에 주입. 사용자 제안: \"표준 표기 강제 + 답변 내 표기 일관성 검증\". 예: runway vs 런웨이, P-Reinforce vs p-reinforce, Chronicle vs 크로니클. 설계 — 사용자 편집 markdown 파일: - 위치: <workspace>/.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": []
},
@@ -1,5 +1,5 @@
{
"result": "직답 결과 — single-pass mock 응답입니다.",
"createdAt": 1780039528659,
"createdAt": 1780282136586,
"modelVersion": "unknown"
}
@@ -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"
}
@@ -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": {
@@ -1,5 +1,5 @@
{
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
"createdAt": 1780039534874,
"createdAt": 1780282143243,
"modelVersion": "unknown"
}
@@ -1,5 +1,5 @@
{
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
"createdAt": 1780039534874,
"createdAt": 1780282143242,
"modelVersion": "unknown"
}
@@ -1,5 +1,5 @@
{
"result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
"createdAt": 1780039534870,
"createdAt": 1780282143239,
"modelVersion": "unknown"
}
@@ -1,5 +1,5 @@
{
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
"createdAt": 1780039534872,
"createdAt": 1780282143241,
"modelVersion": "unknown"
}
@@ -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": {
+341
View File
@@ -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<T>(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<E>({ 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<CustomerEvent>({ 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<string, string>`
- [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.
@@ -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.
+1 -1
View File
@@ -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"
}
+3
View File
@@ -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
+1 -1
View File
@@ -6,7 +6,7 @@
"packages": {
"": {
"name": "astra",
"version": "2.2.194",
"version": "2.2.201",
"license": "MIT",
"dependencies": {
"@lmstudio/sdk": "^1.5.0",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "astra",
"displayName": "Astra",
"description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.",
"version": "2.2.194",
"version": "2.2.201",
"publisher": "g1nation",
"license": "MIT",
"icon": "assets/icon.png",
+24 -106
View File
@@ -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<string, string>;
/** Self-check 용 — selected chunks 의 (title, content) 요약. memoryContext 가 채움. */
selfCheckSources: Array<{ title: string; excerpt: string }>;
/** [TERMINOLOGY DICTIONARY] — 사용자 편집 글로서리 + Term Check 지침. */
terminology: string;
} = {
retrieval: null,
lessons: [],
knowledgeMix: null,
conflictWarnings: '',
coveChecklist: '',
intentClarification: '',
citationTrace: '',
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<void> {
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<void> {
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;
@@ -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<string, string>;
}
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}`;
}
+96
View File
@@ -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<void> {
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<void> {
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';
+48
View File
@@ -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> | 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<void>;
}
+58 -77
View File
@@ -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<string, ValidatorCache>();
/** Parsed forbidden entries 캐시 — mtime 기반, 파일 편집 시 자동 재read+parse. */
const _parsedCache = createMtimeFileCache<ForbiddenEntry[]>('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<string>();
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<string>();
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 };
}
+5
View File
@@ -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,
+83
View File
@@ -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<CustomerEvent>({
* 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<E> {
/** workspace root 기준 상대 경로. 예: '.astra/customers.jsonl'. */
relPath: string;
/** 파싱된 객체가 유효한 event 인지 판정. false 면 malformed 로 skip. */
validate: (e: unknown) => e is E;
}
export interface EventStore<E> {
getFilePath(): string | null;
read(): E[];
append(event: E): { ok: true; filePath: string } | { ok: false; error: string };
count(): number;
}
export function createEventStore<E>(opts: EventStoreOptions<E>): EventStore<E> {
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 };
}
+12 -39
View File
@@ -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<CustomerEvent>({
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 .
*
+744
View File
@@ -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<boolean> {
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<boolean> {
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 <url> [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<number>('datacollectCrawlDepth', 1) ?? 1);
const maxPages = pagesArg ?? (cfg.get<number>('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<string>('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<string>('datacollectSavePath', '') || '').trim();
const body: Record<string, unknown> = { 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<boolean> {
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<string>('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<Section | null> {
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<string>('datacollectSavePath', '') || '').trim();
const body: Record<string, unknown> = { 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<boolean> {
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<WikifyResult> {
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<string>('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<string>('datacollectSavePath', '') || '').trim();
const body: Record<string, unknown> = { 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<boolean> {
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 <url> [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<boolean> {
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 <txt 파일 경로> [참석자·날짜 등 메타데이터]\`\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<string>('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<string>('datacollectSavePath', '') || '').trim();
const body: Record<string, unknown> = { 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<boolean>('meetUsesTasks', true);
const meetUsesCalendar = gCfg.get<boolean>('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 });
+122
View File
@@ -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<string> {
const cfg = vscode.workspace.getConfiguration('g1nation');
const lmUrl = (cfg.get<string>('ollamaUrl', 'http://127.0.0.1:11434') || 'http://127.0.0.1:11434').replace(/\/$/, '');
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim();
const temperature = Math.max(0, Math.min(2, cfg.get<number>('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<any>(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<string> {
try {
const res = await bridgeFetch<any>(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 '';
}
File diff suppressed because it is too large Load Diff
+11 -48
View File
@@ -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<FeedbackEntry>({
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;
+12 -37
View File
@@ -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<HireEvent>({
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<string, CandidateState> {
const events = readHireEvents().slice().sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
const states = new Map<string, CandidateState>();
+11 -38
View File
@@ -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<RunwayEntry>({
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 .
+394
View File
@@ -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<string, number>();
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 <id> <YYYY-MM-DD>\` · \`/memory list-expiring [days]\` · \`/memory help\`\n`);
}
async function runMemory(arg: string, view: any): Promise<boolean> {
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 <id-prefix> <YYYY-MM-DD>` — 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 <id-prefix> <YYYY-MM-DD>`\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<boolean> {
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<boolean> {
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<string>();
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 });
+84
View File
@@ -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<string, number> = {
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 '•';
}
}
@@ -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<string, { label: string; systemPrompt: string }> = {
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<boolean> {
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<string>('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<void> {
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<void> {
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<boolean> {
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건+ 누적 시)',
'',
'저장 위치: `<workspace>/.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 });
@@ -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<boolean> {
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<boolean> {
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 파일 (`<recordRoot>/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<boolean> {
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<boolean> {
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<string, { overdue: number; week: number }>();
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<boolean> {
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<string, MemberSlot>();
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 });
+806
View File
@@ -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<boolean> {
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<string, number>();
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<string, number>();
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<typeof computeRunwayStatus>,
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<boolean> {
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<string, any[]>();
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<string, MonthlyBucket> {
const map = new Map<string, MonthlyBucket>();
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<boolean> {
const trimmed = arg.trim().toLowerCase();
if (trimmed === 'help' || trimmed === '?') {
chunk(view, [
'\n📈 **/cohort — MoM 추세 분석**',
'',
'사용법:',
' `/cohort` — 최근 6개월 추세 (기본)',
' `/cohort yearly` — 최근 12개월',
' `/cohort <N>` — 최근 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<string, number>;
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<boolean> {
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 });
+14
View File
@@ -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';
+704
View File
@@ -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<RunwayEntryType, string> = {
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<boolean> {
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. 소수점·콤마 허용.',
'저장 위치: `<workspace>/.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<string, RunwayEntryType> = { 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<string, string> = { 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 <이름> <MRR> [갱신일] [요금제]`\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<boolean> {
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 <이름> <MRR> [갱신일] [요금제]` — 신규 등록',
' `/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`).',
'저장: `<workspace>/.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 <이름> <MRR> [갱신일] [요금제]`\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=<YYYY-MM-DD> 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<string, CandidateState[]>();
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<string, CandidateState[]>();
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<boolean> {
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`.',
'저장: `<workspace>/.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<string, string> = { 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 });
+40 -64
View File
@@ -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<string, string>;
/** 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<strin
deps.turnCtx.lessons = lessonChunks.map((c) => 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<strin
excerpt: (c.content || '').slice(0, 200),
}));
// Terminology Dictionary — 사용자 편집 글로서리 파일을 시스템 프롬프트 블록으로 주입.
// 파일 없으면 빈 문자열 (no-op). 캐시 + mtime 체크로 매 turn 디스크 read 최소화.
if (config.glossaryEnabled !== false) {
deps.turnCtx.terminology = buildTerminologyBlock({
relPath: config.glossaryPath || '.astra/glossary.md',
maxBodyLength: config.glossaryMaxBodyLength ?? 4000,
});
} else {
deps.turnCtx.terminology = '';
}
// Lessons 블록은 일반 RAG context 보다 앞 — context-overflow truncation 에서 먼저
// 살아남게.
const lessonBlock = buildLessonChecklistBlock(lessonChunks.map((c) => ({ title: c.title, content: c.content })));
+72
View File
@@ -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<MyParsed>('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<T> {
mtimeMs: number;
parsed: T;
}
export interface MtimeFileCache<T> {
/** 파일 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<T>(
_name: string,
parse: (raw: string, filePath: string) => T,
): MtimeFileCache<T> {
const cache = new Map<string, CacheEntry<T>>();
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;
},
};
}
+5 -15
View File
@@ -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<string, { mtime: number; content: string }>();
/** Raw 본문 캐시 — mtime 기반, 파일 편집 시 자동 재read. */
const _rawCache = createMtimeFileCache<string>('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 {