From f3439ddad5b920114573934b7884d15cf819a7fc Mon Sep 17 00:00:00 2001 From: g1nation Date: Wed, 27 May 2026 18:46:07 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20v2.2.168-172=20=E2=80=94=20Google=20Tas?= =?UTF-8?q?ks=20=ED=86=B5=ED=95=A9=20+=20/task=20=EB=AA=85=EB=A0=B9=20+=20?= =?UTF-8?q?Tasks=20=EB=8B=A8=EB=8F=85=20=EA=B8=B0=EB=B3=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v2.2.168: 재패키징. v2.2.169: /meet 액션 아이템을 Google Tasks 로 등록 추가. - 신규 src/features/calendar/tasksApi.ts (Google Tasks API v1) - OAuth SCOPE 에 https://www.googleapis.com/auth/tasks 추가 — 사용자 재인증 필요 - 신규 설정 g1nation.meetUsesTasks (기본 true) v2.2.170: /meet 양쪽 동시 등록 (Tasks + Calendar 독립 토글). - meetUsesCalendar 신설, 둘 다 독립 on/off - 출력에 destination 별 성공/실패 표시 v2.2.171: 신규 /task <제목> <시작일> <완료일> 명령. - 단일 작업을 Tasks + Calendar 양쪽에 단발 등록 (설정 무시, 항상 둘 다) - 단일일 폼: /task <제목> <날짜> 도 지원 - 날짜 형식 3종: YY/MM/DD, YYYY-MM-DD, YYYY/MM/DD - Calendar all-day end-exclusive 자동 보정 v2.2.172: meetUsesCalendar 기본 true→false (중복 방지). - Tasks 도 Calendar 사이드바에 같이 노출되어 둘 다 켜면 중복 표시되던 문제 해결 - 양쪽 원하면 명시적으로 true 토글 Co-Authored-By: Claude Opus 4.7 --- ...cde86955f34dda22a6e02b95c9adc0a456927.json | 2 +- ...c10d377a9fef641dd359504b8d53aecd0a4c3.json | 4 +- .../tests/engine/.astra/missions/wiki_on.json | 12 +- ...b3d9d44f32b0e4cd024b2e055db3a0d34417e.json | 2 +- ...973124fb64ba505f767c53a783833bbc3fa6a.json | 2 +- ...0e6575e54853929e991e579e318f2f5a19030.json | 2 +- ...b73b3a5a01af5d82391ec29a25bd72b8239a5.json | 2 +- ...son => stress_conflict_1779872267539.json} | 14 +- PATCHNOTES.md | 55 ++++++ package-lock.json | 2 +- package.json | 12 +- src/features/calendar/index.ts | 6 + src/features/calendar/oauth.ts | 1 + src/features/calendar/tasksApi.ts | 105 +++++++++++ src/features/datacollect/slashRouter.ts | 177 ++++++++++++++++-- 15 files changed, 356 insertions(+), 42 deletions(-) rename .astra/tests/stress/.astra/missions/{stress_conflict_1779860690078.json => stress_conflict_1779872267539.json} (80%) create mode 100644 src/features/calendar/tasksApi.ts diff --git a/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json b/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json index d9b1dac..db5339d 100644 --- a/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json +++ b/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json @@ -1,5 +1,5 @@ { "result": "직답 결과 — single-pass mock 응답입니다.", - "createdAt": 1779860682873, + "createdAt": 1779872260856, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json b/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json index a23c096..8000d7a 100644 --- a/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json +++ b/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json @@ -1,5 +1,5 @@ { - "result": "---\nid: wiki_on\ndate: 2026-05-27T05:44:42.874Z\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) (20ms)\n", - "createdAt": 1779860682875, + "result": "---\nid: wiki_on\ndate: 2026-05-27T08:57:40.858Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\n직답 결과 — single-pass mock 응답입니다.\n\n직답 결과 — single-pass mock 응답입니다.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `0/100` | ✅ Low |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[DIRECT]** 답변 작성 중... (단일 호출 fast-path) (24ms)\n", + "createdAt": 1779872260858, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/engine/.astra/missions/wiki_on.json b/.astra/tests/engine/.astra/missions/wiki_on.json index 45a5c18..ddcfd81 100644 --- a/.astra/tests/engine/.astra/missions/wiki_on.json +++ b/.astra/tests/engine/.astra/missions/wiki_on.json @@ -1,8 +1,8 @@ { "missionId": "wiki_on", "status": "completed", - "startTime": "2026-05-27T05:44:42.851Z", - "totalElapsedMs": 25, + "startTime": "2026-05-27T08:57:40.831Z", + "totalElapsedMs": 28, "results": { "direct": "직답 결과 — single-pass mock 응답입니다." }, @@ -12,16 +12,16 @@ { "from": "idle", "to": "direct", - "durationMs": 20, + "durationMs": 24, "message": "답변 작성 중... (단일 호출 fast-path)", - "ts": "2026-05-27T05:44:42.871Z" + "ts": "2026-05-27T08:57:40.855Z" }, { "from": "direct", "to": "completed", - "durationMs": 5, + "durationMs": 3, "message": "미션 완료", - "ts": "2026-05-27T05:44:42.876Z" + "ts": "2026-05-27T08:57:40.858Z" } ], "resilienceMetrics": { diff --git a/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json b/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json index 1060b24..8467ab7 100644 --- a/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json +++ b/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json @@ -1,5 +1,5 @@ { "result": "Final report with inconsistencies. This should be long enough to pass validation.", - "createdAt": 1779860690104, + "createdAt": 1779872267564, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json b/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json index 4b569fb..9fa213f 100644 --- a/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json +++ b/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json @@ -1,5 +1,5 @@ { "result": "Final report with inconsistencies. This should be long enough to pass validation.", - "createdAt": 1779860690103, + "createdAt": 1779872267563, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json b/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json index fdbf73d..ef03ca7 100644 --- a/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json +++ b/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json @@ -1,5 +1,5 @@ { "result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]", - "createdAt": 1779860690099, + "createdAt": 1779872267560, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json b/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json index 0e52128..21214bc 100644 --- a/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json +++ b/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json @@ -1,5 +1,5 @@ { "result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", - "createdAt": 1779860690101, + "createdAt": 1779872267562, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/missions/stress_conflict_1779860690078.json b/.astra/tests/stress/.astra/missions/stress_conflict_1779872267539.json similarity index 80% rename from .astra/tests/stress/.astra/missions/stress_conflict_1779860690078.json rename to .astra/tests/stress/.astra/missions/stress_conflict_1779872267539.json index 17d700e..07154f7 100644 --- a/.astra/tests/stress/.astra/missions/stress_conflict_1779860690078.json +++ b/.astra/tests/stress/.astra/missions/stress_conflict_1779872267539.json @@ -1,8 +1,8 @@ { - "missionId": "stress_conflict_1779860690078", + "missionId": "stress_conflict_1779872267539", "status": "completed", - "startTime": "2026-05-27T05:44:50.078Z", - "totalElapsedMs": 27, + "startTime": "2026-05-27T08:57:47.539Z", + "totalElapsedMs": 26, "results": { "outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]", "section_0": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", @@ -16,28 +16,28 @@ "to": "outline", "durationMs": 20, "message": "답변 구조 잡는 중...", - "ts": "2026-05-27T05:44:50.098Z" + "ts": "2026-05-27T08:57:47.559Z" }, { "from": "outline", "to": "section", "durationMs": 2, "message": "본문 작성 중...", - "ts": "2026-05-27T05:44:50.100Z" + "ts": "2026-05-27T08:57:47.561Z" }, { "from": "section", "to": "polish", "durationMs": 2, "message": "최종 다듬기 중...", - "ts": "2026-05-27T05:44:50.102Z" + "ts": "2026-05-27T08:57:47.563Z" }, { "from": "polish", "to": "completed", "durationMs": 2, "message": "미션 완료", - "ts": "2026-05-27T05:44:50.104Z" + "ts": "2026-05-27T08:57:47.565Z" } ], "resilienceMetrics": { diff --git a/PATCHNOTES.md b/PATCHNOTES.md index 34693ec..4a8c811 100644 --- a/PATCHNOTES.md +++ b/PATCHNOTES.md @@ -1,5 +1,60 @@ # Astra Patch Notes +## v2.2.172 (2026-05-27) +### 🎛️ /meet — 기본값을 Tasks 단독 등록으로 변경 (중복 방지) +- **`g1nation.meetUsesCalendar` 기본값 `true` → `false`.** Tasks 도 Calendar 사이드바에 같이 보이므로 둘 다 켜져 있으면 동일 항목이 중복 노출(일정 + 할 일 각 1건씩)되던 문제 해결. +- 사용자 명시 설정이 없던 경우 자동으로 새 기본 적용 — 다음 `/meet` 부터 Tasks 단독. +- 양쪽 등록을 원하면 Settings 에서 `g1nation.meetUsesCalendar` 를 true 로 명시 토글. +- `/task` 명령은 변경 없음 — 사용자가 직접 호출한 명령이라 항상 양쪽 등록. +- **신규 패키징:** `astra-2.2.172.vsix`. + +--- + + + +## v2.2.171 (2026-05-27) +### 📋 신규 `/task` — Google Tasks + Calendar 양쪽 단발 등록 +- **사용법:** + - `/task <제목> <시작일> <완료일>` — 기간 작업 (예: `/task Apple 계정 생성 요청 26/05/27 26/06/28`) + - `/task <제목> <날짜>` — 하루짜리 작업 (시작=완료, 같은 날) +- **날짜 형식 3종 지원**: `YY/MM/DD` · `YYYY-MM-DD` · `YYYY/MM/DD`. 유효성 검증(2/30 같은 잘못된 날짜 차단) 포함. +- **항상 양쪽 등록** — `/meet` 과 달리 사용자가 직접 호출한 명령이므로 `meetUsesTasks` / `meetUsesCalendar` 설정 무시하고 무조건 Tasks + Calendar 둘 다 등록. +- Tasks: 마감일만 (모델 한계 — 시작일은 노트에 기록). Calendar: all-day 기간 일정(end-exclusive 자동 보정으로 완료일 포함 표시). +- 성공/부분 실패/전체 실패 각각 명확한 메시지 + Calendar 이벤트 deep link 표시. +- **신규 패키징:** `astra-2.2.171.vsix`. + +--- + + + +## v2.2.170 (2026-05-27) +### 📝📅 /meet — Tasks + Calendar 양쪽 동시 등록 (독립 토글) +- **v2.2.169의 either/or 분기 → 양쪽 동시 등록**으로 변경. 사용자 요청 반영 ("캘린더에도 등록되고 Tasks로도 등록되게"). +- **신규 설정** `g1nation.meetUsesCalendar` (기본 `true`). 기존 `meetUsesTasks` 와 **독립 토글** — 둘 다 true(기본)면 양쪽 동시 등록, 한쪽만 true면 그쪽만, 둘 다 false면 자동 등록 건너뜀. +- **출력 포맷 개선**: 각 액션 아이템 옆에 어디에 성공/실패했는지 표시 — `(Tasks + Calendar)` 또는 `⚠️ Calendar: <에러>`. 마지막 요약도 destination 별로 분리 (`Tasks 5/5 · Calendar 5/5`). +- 기존 동작 호환: `meetUsesTasks=false` + `meetUsesCalendar=true` 로 두면 2.2.168 이전과 동일 (Calendar 만 등록). +- **신규 패키징:** `astra-2.2.170.vsix`. + +--- + + + +## v2.2.169 (2026-05-27) +### 📝 /meet 액션 아이템 → Google Tasks 등록 (Calendar 일정에서 변경) +- **신규 Google Tasks API 통합** ([tasksApi.ts](src/features/calendar/tasksApi.ts)) — `createTask(context, {title, due, notes})`. Calendar / Sheets 와 동일한 OAuth 토큰 공유, 새로 추가된 `tasks` 스코프 사용. +- **`/meet` 라우팅 변경**: 액션 아이템은 시간 없이 마감일만 있는 "할 일" 이라 **Google Tasks 모델에 더 자연스럽게 맞음**. 기존엔 Calendar all-day 일정으로 등록돼 주 캘린더에 "conference call" 유형으로 표시되던 문제 해결. +- **신규 설정 `g1nation.meetUsesTasks`** (기본 `true`) — false 로 두면 기존 Calendar 일정 등록으로 fallback. +- **OAuth 스코프 추가**: `https://www.googleapis.com/auth/tasks` 가 SCOPE 에 추가됨. **기존 OAuth 한 사용자는 재인증 필수** — 새 스코프는 옛 refresh token 에 없음. +- **사용자 액션 필요 (1회)**: + 1. **Google Cloud Console 에서 Tasks API 활성화**: https://console.developers.google.com/apis/api/tasks.googleapis.com/overview + 2. VS Code 에서 `Astra: Google Calendar OAuth 연결 (쓰기)` 명령 재실행 — Tasks 스코프 동의 화면이 새로 뜬다. +- 권한 부족 시 친화 안내 메시지 자동 표시 (어떤 명령을 재실행해야 하는지). +- **신규 패키징:** `astra-2.2.169.vsix`. + +--- + + + ## v2.2.168 (2026-05-27) ### 📦 재패키징 (소스 변경 없음) - ASTRA 소스 변경 없음. 별개로 적용한 **Datacollect Bridge `/api/lm` 복원력 수정**(LM 서버가 raw text 응답해도 OpenAI shape 으로 wrap → ASTRA `/youtube info` 등이 깨진 에러 벽 대신 정상 결과를 받음)에 맞춰 깨끗한 설치본 제공. diff --git a/package-lock.json b/package-lock.json index 042c338..6e3125c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "astra", - "version": "2.2.168", + "version": "2.2.172", "license": "MIT", "dependencies": { "@lmstudio/sdk": "^1.5.0", diff --git a/package.json b/package.json index ba3bca8..e602fb2 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "astra", "displayName": "Astra", "description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.", - "version": "2.2.168", + "version": "2.2.172", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", @@ -242,6 +242,16 @@ "maximum": 2, "markdownDescription": "채팅 응답 생성의 temperature. 낮을수록(0.2~0.3) 한국어 오타·깨진 토큰·환각이 줄고 결과가 안정적이며, 높을수록 표현이 다양해집니다. 분석·업무용은 0.3 권장." }, + "g1nation.meetUsesTasks": { + "type": "boolean", + "default": true, + "markdownDescription": "`/meet` 액션 아이템을 **Google Tasks** 에도 등록할지 여부. 시간 없이 마감일만 있는 \"할 일\" 모델. `meetUsesCalendar` 와 독립적으로 토글 가능 — 둘 다 true 면 양쪽 모두 등록. true 로 두려면 OAuth 재연결로 Tasks 스코프 동의 + Google Cloud Console 에서 Tasks API 활성화 필요(처음 1회)." + }, + "g1nation.meetUsesCalendar": { + "type": "boolean", + "default": false, + "markdownDescription": "`/meet` 액션 아이템을 **Google Calendar** 일정(all-day)으로도 등록할지 여부. **기본 `false`** — Tasks 단독 등록으로 중복 방지 (Tasks 도 캘린더 사이드바에 같이 보이므로 둘 다 켜면 중복). true 로 켜면 Tasks + Calendar 양쪽 모두 등록." + }, "g1nation.memoryEnabled": { "type": "boolean", "default": true, diff --git a/src/features/calendar/index.ts b/src/features/calendar/index.ts index fa7ab07..653298d 100644 --- a/src/features/calendar/index.ts +++ b/src/features/calendar/index.ts @@ -30,3 +30,9 @@ export { _addMinutesIso, _addDaysDate, } from './calendarApi'; + +export { + createTask, + TaskInput, + CreatedTask, +} from './tasksApi'; diff --git a/src/features/calendar/oauth.ts b/src/features/calendar/oauth.ts index 2ac41d3..38c16f9 100644 --- a/src/features/calendar/oauth.ts +++ b/src/features/calendar/oauth.ts @@ -24,6 +24,7 @@ import * as vscode from 'vscode'; const SCOPE = [ 'https://www.googleapis.com/auth/calendar.events', 'https://www.googleapis.com/auth/spreadsheets', + 'https://www.googleapis.com/auth/tasks', 'openid', 'email', ].join(' '); diff --git a/src/features/calendar/tasksApi.ts b/src/features/calendar/tasksApi.ts new file mode 100644 index 0000000..24c54e6 --- /dev/null +++ b/src/features/calendar/tasksApi.ts @@ -0,0 +1,105 @@ +/** + * Google Tasks API v1 — task create 호출. + * + * Calendar / Sheets 와 같은 OAuth 토큰을 공유한다 (scope 에 `tasks` 포함). + * Tasks 는 date-only 모델(시간 없음)이라 /meet 의 액션 아이템처럼 "시간 없이 + * 마감일만 있는 할 일" 에 자연스럽게 맞는다. + * + * 외부 라이브러리 안 씀 — Tasks API 도 REST 라 native fetch 면 충분. + */ + +import * as vscode from 'vscode'; +import { getFreshAccessToken } from './calendarApi'; + +const API_BASE = 'https://tasks.googleapis.com/tasks/v1'; + +export interface TaskInput { + /** 작업 제목 (필수). */ + title: string; + /** 마감일 'YYYY-MM-DD' — Google Tasks 는 시간 무시, 날짜만 사용. */ + due: string; + /** 메모 (옵션) — Tasks UI 에서 작업 본문 아래 노트로 표시. */ + notes?: string; + /** + * 작업이 들어갈 task list ID. default `@default` — 기본 list ("내 할 일"). + * 사용자가 별도 list 를 만들었으면 그 ID 를 넣으면 됨. + */ + taskListId?: string; +} + +export interface CreatedTask { + /** Google 이 발급한 task id. */ + id: string; + title: string; + due: string; + /** Google Tasks API 의 self link (API 용). 사용자용 deep link 는 별도로 없음. */ + selfLink?: string; +} + +/** + * Google Tasks 에 작업 생성. config 에 refresh token 있어야 함. access token 자동 갱신. + * + * 사용자가 Tasks 스코프 미동의(예전 OAuth 만 한 사용자) 면 Google 이 401/403 으로 + * 거부 → `error` 에 그 메시지가 친화적 형태로 전달되고, 사용자는 OAuth 재연결 안내를 본다. + * + * 반환값: + * ok: true → CreatedTask + * ok: false → 에러 메시지 (UI 표시용) + */ +export async function createTask( + context: vscode.ExtensionContext, + input: TaskInput, +): Promise<{ ok: true; task: CreatedTask } | { ok: false; error: string }> { + if (!input.title?.trim()) return { ok: false, error: 'title 비어 있음' }; + if (!/^\d{4}-\d{2}-\d{2}$/.test(input.due)) { + return { ok: false, error: `due 는 'YYYY-MM-DD' 형식이어야 함 (받은 값: ${input.due})` }; + } + + const tokenResult = await getFreshAccessToken(context); + if (!tokenResult.ok) return { ok: false, error: tokenResult.error }; + + const taskListId = (input.taskListId || '@default').trim() || '@default'; + const url = `${API_BASE}/lists/${encodeURIComponent(taskListId)}/tasks`; + const body = { + title: input.title.trim(), + // Tasks API 의 `due` 는 RFC3339 timestamp 인데 시간 부분은 서버에서 무시되고 + // 날짜만 사용. UTC midnight 으로 보내는 게 표준 패턴. + due: `${input.due}T00:00:00.000Z`, + ...(input.notes ? { notes: input.notes } : {}), + }; + + try { + const res = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${tokenResult.accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(15000), + }); + const json: any = await res.json().catch(() => ({})); + if (!res.ok) { + const msg: string = json?.error?.message || `HTTP ${res.status}`; + // 스코프/권한 부족이면 사용자 친화 안내로 변환 — 어떤 명령을 다시 실행해야 하는지 명시. + if (res.status === 401 || res.status === 403 || /insufficient|scope|disabled|enable/i.test(msg)) { + return { + ok: false, + error: `Tasks API 권한 부족 — "Astra: Google Calendar OAuth 연결 (쓰기)" 명령을 다시 실행해 Tasks 스코프 동의가 필요합니다. (Google Cloud Console 에서 Tasks API 활성화도 함께 확인) 원인: ${msg}`, + }; + } + return { ok: false, error: msg }; + } + return { + ok: true, + task: { + id: json.id, + title: json.title, + due: input.due, + selfLink: json.selfLink, + }, + }; + } catch (e: any) { + return { ok: false, error: e?.message || String(e) }; + } +} diff --git a/src/features/datacollect/slashRouter.ts b/src/features/datacollect/slashRouter.ts index 0b8d17c..b358d66 100644 --- a/src/features/datacollect/slashRouter.ts +++ b/src/features/datacollect/slashRouter.ts @@ -2,7 +2,7 @@ import * as vscode from 'vscode'; import { promises as fsp } from 'fs'; import { logInfo } from '../../utils'; import { bridgeFetch, getBridgeBaseUrl, BRIDGE_API } from './bridgeClient'; -import { createCalendarEvent, readCalendarConfig } from '../calendar'; +import { createCalendarEvent, createTask, readCalendarConfig, _addDaysDate } from '../calendar'; /** * Datacollect "라디오" slash 명령 라우터. @@ -1070,27 +1070,55 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode. const meetTitle = titleMatch ? titleMatch[1].replace(/^\[회의 제목\]\s*/, '').trim() : '회의'; - chunk(view, `\n📅 **캘린더 등록**: 액션 아이템 ${tasks.length}건…\n`); - let ok = 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 result = await createCalendarEvent(context, { - title: evTitle, - start: date, - allDay: true, - description: `회의록: ${meetTitle}\n담당: ${task.owner}\n기한 표기: ${task.due || '(없음)'}\nAstra /meet 자동 등록`, - }); - if (result.ok) { - ok++; - chunk(view, ` · ${date} — ${evTitle}\n`); - } else { - chunk(view, ` · ⚠️ 등록 실패 (${task.work}): ${result.error}\n`); + // 액션 아이템 destination — Tasks·Calendar 독립 토글. 기본 둘 다 true. + // 양쪽 다 끄면 자동 등록 자체를 건너뛴다. + const gCfg = vscode.workspace.getConfiguration('g1nation'); + const meetUsesTasks = gCfg.get('meetUsesTasks', true); + const meetUsesCalendar = gCfg.get('meetUsesCalendar', true); + if (!meetUsesTasks && !meetUsesCalendar) { + chunk(view, `\nℹ️ Google Tasks·Calendar 등록이 모두 꺼져 있어 액션 아이템 자동 등록을 건너뜁니다. (Settings 의 \`g1nation.meetUsesTasks\` / \`g1nation.meetUsesCalendar\` 확인)\n`); + } else { + const destLabel = [meetUsesTasks && 'Tasks', meetUsesCalendar && 'Calendar'].filter(Boolean).join(' + '); + chunk(view, `\n📝 **Google ${destLabel} 등록**: 액션 아이템 ${tasks.length}건…\n`); + let tasksOk = 0; + let calendarOk = 0; + let tentativeCount = 0; + for (const task of tasks) { + const { date, tentative } = resolveTaskDate(task.due, meetingDate, today); + if (tentative) tentativeCount++; + const evTitle = tentative ? `${task.work} (미확정)` : task.work; + const notes = `회의록: ${meetTitle}\n담당: ${task.owner}\n기한 표기: ${task.due || '(없음)'}\nAstra /meet 자동 등록`; + + const successes: string[] = []; + const failures: string[] = []; + if (meetUsesTasks) { + const r = await createTask(context, { title: evTitle, due: date, notes }); + if (r.ok) { tasksOk++; successes.push('Tasks'); } + else { failures.push(`Tasks: ${r.error}`); } + } + if (meetUsesCalendar) { + const r = await createCalendarEvent(context, { + title: evTitle, + start: date, + allDay: true, + description: notes, + }); + if (r.ok) { calendarOk++; successes.push('Calendar'); } + else { failures.push(`Calendar: ${r.error}`); } + } + + if (failures.length === 0) { + chunk(view, ` · ${date} — ${evTitle} (${successes.join(' + ')})\n`); + } else { + chunk(view, ` · ${date} — ${evTitle}${successes.length ? ` (✅ ${successes.join(' + ')})` : ''}\n`); + for (const f of failures) chunk(view, ` ⚠️ ${f}\n`); + } } + const summary: string[] = []; + if (meetUsesTasks) summary.push(`Tasks ${tasksOk}/${tasks.length}`); + if (meetUsesCalendar) summary.push(`Calendar ${calendarOk}/${tasks.length}`); + chunk(view, `✅ 등록 완료 — ${summary.join(' · ')}${tentativeCount > 0 ? ` · 미확정 ${tentativeCount}건` : ''}\n`); } - chunk(view, `✅ 캘린더 ${ok}/${tasks.length}건 등록 완료${tentativeCount > 0 ? ` · 미확정 ${tentativeCount}건` : ''}\n`); } } } catch (e: any) { @@ -1110,6 +1138,114 @@ import { parseActionItems, } from './scheduling/calendarHelpers'; +// ─── /task — 단발 작업을 Google Tasks + Calendar 양쪽에 등록 ───────────── +// `/task <제목> <시작일> <완료일>` (range) 또는 `/task <제목> <날짜>` (단일일) +// 날짜 형식: YY/MM/DD (예: 26/05/27) 또는 YYYY-MM-DD / YYYY/MM/DD. +// /meet 와 달리 사용자가 직접 호출한 명령이므로 *항상* 양쪽 등록(설정 무시). + +/** 유연한 한국 날짜 파서. YY/MM/DD · YYYY/MM/DD · YYYY-MM-DD 지원. 잘못된 입력은 null. */ +function parseFlexibleDate(s: string): string | null { + if (!s) return null; + let y: number, mo: number, d: number; + let m = s.match(/^(\d{2})\/(\d{1,2})\/(\d{1,2})$/); + if (m) { y = 2000 + Number(m[1]); mo = Number(m[2]); d = Number(m[3]); } + else if ((m = s.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})$/))) { y = Number(m[1]); mo = Number(m[2]); d = Number(m[3]); } + else if ((m = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/))) { y = Number(m[1]); mo = Number(m[2]); d = Number(m[3]); } + else return null; + if (mo < 1 || mo > 12 || d < 1 || d > 31) return null; + // 진짜 유효한 날짜인지 (예: 2/30 차단) Date 로 round-trip 검증. + const date = new Date(y, mo - 1, d); + if (Number.isNaN(date.getTime()) || date.getMonth() !== mo - 1 || date.getDate() !== d) return null; + return `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`; +} + +async function runTask(arg: string, view: any, context?: vscode.ExtensionContext): Promise { + if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /task 실행 불가.\n'); return true; } + if (!arg.trim()) { + chunk(view, [ + '\n📋 **/task — Google Tasks + Calendar 동시 등록**', + '', + '사용법:', + ' `/task <제목> <시작일> <완료일>` — 기간 작업 (Calendar 다일 일정 + Tasks 마감일)', + ' `/task <제목> <날짜>` — 하루짜리 작업 (시작=완료=같은 날)', + '', + '날짜 형식: `YY/MM/DD` (예: `26/05/27`) · `YYYY-MM-DD` · `YYYY/MM/DD`', + '', + '예시:', + ' `/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; } + + // 끝에서부터 날짜 매칭. 마지막 2개가 모두 날짜면 range, 마지막 1개만 날짜면 단일일. + const lastDate = parseFlexibleDate(tokens[tokens.length - 1]); + const secondLastDate = tokens.length >= 3 ? parseFlexibleDate(tokens[tokens.length - 2]) : null; + let startYmd: string, endYmd: string, titleTokens: string[]; + if (lastDate && secondLastDate) { + startYmd = secondLastDate; + endYmd = lastDate; + titleTokens = tokens.slice(0, -2); + } else if (lastDate) { + startYmd = lastDate; + endYmd = lastDate; + titleTokens = tokens.slice(0, -1); + } else { + chunk(view, `\n❌ 마지막 토큰이 날짜 형식이 아닙니다 ("${tokens[tokens.length - 1]}"). 사용 가능 형식: YY/MM/DD · YYYY-MM-DD · YYYY/MM/DD.\n사용법: \`/task\` (인자 없이) 실행해 도움말 보기.\n`); + return true; + } + const title = titleTokens.join(' ').trim(); + if (!title) { chunk(view, '\n❌ 제목 누락.\n'); return true; } + if (startYmd > endYmd) { chunk(view, `\n❌ 시작일(${startYmd})이 완료일(${endYmd})보다 늦습니다.\n`); return true; } + + const isRange = startYmd !== endYmd; + const periodLabel = isRange ? `${startYmd} ~ ${endYmd}` : startYmd; + chunk(view, `\n📝 **Google Tasks + Calendar 등록**: ${title}\n · 기간: ${periodLabel}\n`); + + const notes = `Astra /task 직접 등록\n기간: ${periodLabel}`; + const successes: string[] = []; + const failures: string[] = []; + let calLink: string | undefined; + + // Tasks — due = 완료일. Tasks 모델은 start 없음 → 시작일은 노트에 포함. + const taskNotes = isRange ? `${notes}\n(Tasks 는 마감일만 사용 — 시작일은 노트 참조)` : notes; + const taskResult = await createTask(context, { title, due: endYmd, notes: taskNotes }); + if (taskResult.ok) successes.push('Tasks'); + else failures.push(`Tasks: ${taskResult.error}`); + + // Calendar — all-day 일정. Google Calendar 의 all-day end 는 *exclusive* 라 + // 완료일을 포함시키려면 +1 일 해서 전달해야 함. + const calEnd = _addDaysDate(endYmd, 1); + const calResult = await createCalendarEvent(context, { + title, start: startYmd, end: calEnd, allDay: true, description: notes, + }); + if (calResult.ok) { + successes.push('Calendar'); + calLink = calResult.event.htmlLink; + } else { + failures.push(`Calendar: ${calResult.error}`); + } + + if (failures.length === 0) { + chunk(view, `✅ 등록 완료 — ${successes.join(' + ')}\n`); + } else if (successes.length > 0) { + chunk(view, `✅ 부분 성공 — ${successes.join(' + ')}\n`); + for (const f of failures) chunk(view, ` ⚠️ ${f}\n`); + } else { + chunk(view, `❌ 모두 실패\n`); + for (const f of failures) chunk(view, ` · ${f}\n`); + } + if (calLink) chunk(view, `🔗 Calendar 일정 열기: ${calLink}\n`); + return true; +} + // ─── 기본 명령 등록 ─────────────────────────────────────────────────────── // Astra 가 기본 제공하는 6개 명령. 외부 플러그인 / 사용자 정의 명령은 동일하게 // `registerSlashCommand({ name, description, handler })` 한 번 호출로 추가. @@ -1125,6 +1261,7 @@ registerSlashCommand({ name: '/youtube', description: 'YouTube 단일 영상 또 registerSlashCommand({ name: '/blog', description: 'Blog Pipeline 안내 (Datacollect 별도 흐름)', handler: runBlog }); registerSlashCommand({ name: '/wikify', description: '웹 URL → P-Reinforce v3.0 위키 합성·저장', handler: runWikify }); registerSlashCommand({ name: '/meet', description: '회의 transcript → 회의록 합성 + 캘린더·task 등록', handler: runMeet }); +registerSlashCommand({ name: '/task', description: '단일 작업을 Google Tasks + Calendar 양쪽에 등록 (제목 + 시작일 + 완료일)', handler: runTask }); // /stocks 는 `src/features/stocks/slashStocks.ts` 의 sub-command 라우터로 위임. // list/check/signal/sync/add/remove/judge/report/run/path 9 개의 subcommand 가 그 안에서 분기.