feat(datacollect): /meet 회의록 명령 + 캘린더 자동 등록, 한·영 깨짐 개선

- /meet <txt> — 로컬 회의 녹취를 사실 기반 구조화 회의록으로 합성·저장 (v2.2.53)
- /meet 회의록 액션 아이템을 task별 종일 일정으로 Google Calendar에 자동 등록 (v2.2.55)
  - 날짜 규칙: 명시 날짜·"차주"(+6일)·변환 불가(등록일+영업일 5일, "(미확정)" 꼬리표)
  - handleSlashCommand에 ExtensionContext 배선 (chatHandlers 경유)
- callLmSynthesis: top_p/top_k/repeat_penalty 추가 + 한·영 깨짐 조건부 교정 패스 (v2.2.54)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 19:11:00 +09:00
parent dea5953f59
commit 132d130ff1
4 changed files with 373 additions and 4 deletions
+35
View File
@@ -1,5 +1,40 @@
# Astra Patch Notes # Astra Patch Notes
## v2.2.55 (2026-05-21)
### 📅 /meet — 회의록 액션 아이템 → Google 캘린더 자동 등록 (Phase 1)
- `/meet`이 회의록 합성·저장 후, **액션 아이템 표를 파싱해 task별 종일 일정으로 Google Calendar에 자동 등록**한다.
- 날짜 규칙(사용자 정의): 명시 날짜(`YYYY-MM-DD`/`YYYY년 M월 D일`)→그대로 / "차주·다음 주"→회의일 +6일 / "즉시·당일"→등록일 / 변환 불가·빈 값→등록일 +영업일 5일(토·일 제외, 공휴일 무시) + 제목에 **"(미확정)"** 꼬리표.
- 캘린더 이벤트 설명에 회의 제목·담당·원래 기한 표기를 기록. Google Calendar OAuth(쓰기)가 연결돼 있어야 하며, 미연결 시 회의록 저장만 하고 안내한다.
- `handleSlashCommand``ExtensionContext` 배선 추가(`/meet`만 사용).
- **신규 패키징:** `astra-2.2.55.vsix`.
---
## v2.2.54 (2026-05-21)
### 🔧 한·영 토큰 깨짐 추가 개선 — 샘플링 조정 + 교정 패스
- **샘플링 파라미터 조정.** 슬래시 합성 LLM 호출에 `top_p`(0.85)·`top_k`(20)·`repeat_penalty`(1.1) 추가 — 깨진 저확률 토큰("핵ess" 등)의 샘플링 자체를 억제.
- **조건부 교정 패스.** 합성 결과에 한·영 깨짐(`한글+영문 소문자 조각`)이 감지되면 LLM 교정 패스를 1회 돌려 깨진 표기만 자연스러운 한국어로 교정. 깨짐이 없으면 추가 호출 없음. 교정 결과가 원본보다 비정상적으로 짧으면(잘라먹음) 원본을 유지하는 안전장치 포함.
- 적용 범위: `/benchmark`·`/youtube`·`/wikify`·`/meet` 모든 슬래시 합성.
- **신규 패키징:** `astra-2.2.54.vsix`.
---
## v2.2.53 (2026-05-20)
### 📝 신규 /meet — 회의 녹취 txt → 사실 기반 구조화 회의록
- **신규 슬래시 명령 `/meet <txt 파일 경로> [메타데이터]`.** 로컬 회의 녹취 텍스트 파일을 ASTRA가 직접 읽어(로컬 파일이라 Bridge 불필요), 사실 기반 구조화 회의록(Actionable Minutes)으로 LLM 합성·저장한다.
- 처리 규칙: Deconstruction(잡담 제거) → Classification(Fact/Discussion/Decision/Risk/Action) → Decision Logic → Structuring. 메타데이터(참석자·날짜)가 녹취록과 충돌하면 메타데이터 우선.
- 출력 구조: 요약 보고 / 주요 논의 사항 / 리스크·이슈 / 결정 사항 / 오픈 이슈 / 액션 아이템(담당·작업·기한 표).
- 경로에 공백이 있으면 따옴표로 감쌀 수 있음. 결과물은 `.md``WIKI_RAW_PATH`(`E:\Wiki\2nd\00_Raw`)에 저장.
- **신규 패키징:** `astra-2.2.53.vsix`.
---
## v2.2.52 (2026-05-20) ## v2.2.52 (2026-05-20)
### 📦 재패키징 (v2.2.51 동일 내용) ### 📦 재패키징 (v2.2.51 동일 내용)
- 기능 변경 없음 — v2.2.51 작업 트리를 버전만 올려 재패키징. 버전 정합성 정리를 위해 `package-lock.json` 버전도 함께 2.2.52로 동기화. - 기능 변경 없음 — v2.2.51 작업 트리를 버전만 올려 재패키징. 버전 정합성 정리를 위해 `package-lock.json` 버전도 함께 2.2.52로 동기화.
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "astra", "name": "astra",
"displayName": "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.", "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.52", "version": "2.2.55",
"publisher": "g1nation", "publisher": "g1nation",
"license": "MIT", "license": "MIT",
"icon": "assets/icon.png", "icon": "assets/icon.png",
+336 -2
View File
@@ -1,6 +1,8 @@
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import { promises as fsp } from 'fs';
import { logInfo } from '../../utils'; import { logInfo } from '../../utils';
import { bridgeFetch, getBridgeBaseUrl } from './bridgeClient'; import { bridgeFetch, getBridgeBaseUrl } from './bridgeClient';
import { createCalendarEvent, readCalendarConfig } from '../calendar';
/** /**
* Datacollect "라디오" slash 명령 라우터. * Datacollect "라디오" slash 명령 라우터.
@@ -16,7 +18,7 @@ import { bridgeFetch, getBridgeBaseUrl } from './bridgeClient';
* 명령이 처리되면 true 반환 → chatHandlers가 일반 LLM 흐름으로 안 내려가게. * 명령이 처리되면 true 반환 → chatHandlers가 일반 LLM 흐름으로 안 내려가게.
*/ */
const COMMANDS = ['/research', '/benchmark', '/youtube', '/blog', '/wikify'] as const; const COMMANDS = ['/research', '/benchmark', '/youtube', '/blog', '/wikify', '/meet'] as const;
type SlashCommand = typeof COMMANDS[number]; type SlashCommand = typeof COMMANDS[number];
export function isSlashCommand(input: string): boolean { export function isSlashCommand(input: string): boolean {
@@ -48,6 +50,7 @@ function chunk(view: Webview | undefined, value: string) {
export async function handleSlashCommand( export async function handleSlashCommand(
input: string, input: string,
view: Webview | undefined, view: Webview | undefined,
context?: vscode.ExtensionContext,
): Promise<boolean> { ): Promise<boolean> {
const trimmed = input.trim(); const trimmed = input.trim();
const spaceIdx = trimmed.indexOf(' '); const spaceIdx = trimmed.indexOf(' ');
@@ -73,6 +76,7 @@ export async function handleSlashCommand(
case '/youtube': return await runYoutube(arg, view); case '/youtube': return await runYoutube(arg, view);
case '/blog': return await runBlog(arg, view); case '/blog': return await runBlog(arg, view);
case '/wikify': return await runWikify(arg, view); case '/wikify': return await runWikify(arg, view);
case '/meet': return await runMeet(arg, view, context);
} }
return true; return true;
} catch (e: any) { } catch (e: any) {
@@ -436,6 +440,11 @@ async function callLmSynthesis(prompt: string, systemPrompt?: string): Promise<s
{ role: 'user', content: prompt }, { role: 'user', content: prompt },
], ],
temperature, temperature,
// 깨진 저확률 토큰(한·영 혼합 등) 샘플링 억제 — top_p/top_k로 후보를
// 좁히고 repeat_penalty로 반복 깨짐을 누른다.
top_p: 0.85,
top_k: 20,
repeat_penalty: 1.1,
}, },
}), }),
}, { timeoutMs: 120_000 }); }, { timeoutMs: 120_000 });
@@ -446,9 +455,51 @@ async function callLmSynthesis(prompt: string, systemPrompt?: string): Promise<s
?? ''; ?? '';
// 안전망 — 모델이 그래도 내부 검증 로그를 붙이면 잘라낸다. [Self-Reflector Check] // 안전망 — 모델이 그래도 내부 검증 로그를 붙이면 잘라낸다. [Self-Reflector Check]
// 블록은 항상 답변 맨 끝에 오므로 그 지점부터 끝까지 제거한다. // 블록은 항상 답변 맨 끝에 오므로 그 지점부터 끝까지 제거한다.
return String(content) let out = String(content)
.replace(/\n*(?:```[\w]*\s*)?\[Self-Reflector Check\][\s\S]*$/i, '') .replace(/\n*(?:```[\w]*\s*)?\[Self-Reflector Check\][\s\S]*$/i, '')
.trim(); .trim();
// 한·영 깨짐(예: "핵ess" — 한글 음절 + 영문 소문자 조각)이 감지되면 교정 패스를
// 1회 돈다. 깨짐이 없으면(대부분) 추가 호출 없이 그대로 반환한다.
if (/[가-힣][a-z]{2,}/.test(out)) {
out = await repairKoreanGlitches(out, lmUrl, model);
}
return out;
}
/**
* 한글+영문이 한 단어로 깨진 표기(LLM 토큰 꼬임)를 LLM 1회 호출로 교정.
* 깨진 토큰만 고치고 나머지 내용·구조는 보존한다. 실패하거나 결과가 비정상적으로
* 짧으면(LLM이 내용을 잘라먹은 경우) 원본을 그대로 반환한다.
*/
async function repairKoreanGlitches(text: string, lmUrl: string, model: string): Promise<string> {
try {
const res = await bridgeFetch<any>('/api/lm', {
method: 'POST',
body: JSON.stringify({
url: `${lmUrl}/v1/chat/completions`,
payload: {
model,
messages: [
{ role: 'system', content: '당신은 한국어 교정기입니다. 입력 텍스트에서 한글과 영문 알파벳이 한 단어로 잘못 합쳐진 깨진 표기(예: "핵ess"→"핵심", "결ently"→"결국")만 자연스러운 한국어로 교정합니다. 그 외 내용·문장·마크다운 구조·표·정상적인 영문 용어(API, JSON 등)는 한 글자도 바꾸지 않으며, 교정된 전체 텍스트만 그대로 출력합니다(설명·주석 금지).' },
{ role: 'user', content: text },
],
temperature: 0,
top_p: 0.7,
top_k: 20,
},
}),
}, { timeoutMs: 120_000 });
const fixed = String(
res?.choices?.[0]?.message?.content
?? res?.choices?.[0]?.text
?? res?.answer ?? res?.response ?? '',
).replace(/\n*(?:```[\w]*\s*)?\[Self-Reflector Check\][\s\S]*$/i, '').trim();
// 안전장치 — 교정 결과가 원본의 70% 미만이면 LLM이 내용을 잘라먹은 것이므로 원본 사용.
if (!fixed || fixed.length < text.length * 0.7) return text;
return fixed;
} catch {
return text; // 교정 실패는 비치명적 — 원본 그대로 반환.
}
} }
async function runBenchmark(arg: string, view: Webview | undefined): Promise<boolean> { async function runBenchmark(arg: string, view: Webview | undefined): Promise<boolean> {
@@ -1083,3 +1134,286 @@ async function runWikify(arg: string, view: Webview | undefined): Promise<boolea
chunk(view, `\n---\n\n🏁 **배치 완료**: ${okCount}/${urls.length}개 성공 (${Math.round((Date.now() - batchT0) / 1000)}s 소요)\n`); chunk(view, `\n---\n\n🏁 **배치 완료**: ${okCount}/${urls.length}개 성공 (${Math.round((Date.now() - batchT0) / 1000)}s 소요)\n`);
return true; return true;
} }
// ───────────────────────────── /meet ─────────────────────────────
/**
* 회의 녹취 텍스트 → 사실 기반 구조화 회의록(Actionable Minutes) LLM 프롬프트.
* 사용자 정의 규칙: Fact/Discussion/Decision/Risk/Action 분류, 메타데이터 우선.
*/
function buildMeetPrompt(transcript: string, metadata: string): string {
const metaBlock = metadata.trim()
|| '(메타데이터 미입력 — 녹취록 내용에서 추론하거나 "확인 불가"로 표기)';
return `# 임무 (Objective)
제공된 회의 녹취 텍스트와 메타데이터를 기반으로, 외부 지식 없이 사실 기반의
구조화된 회의록(Actionable Minutes)을 생성한다.
# 역할 (Role)
- Fact Extractor: 사실만 추출
- Decision Tracker: 결정 여부 구분
- Action Organizer: 실행 항목 구조화
- Context Filter: 불필요한 발언(잡담) 제거
# 데이터 우선순위 (Data Priority)
1순위: 메타데이터 / 2순위: 녹취록 내용. 충돌 시 반드시 메타데이터를 사용한다.
# 처리 절차 (Processing Flow)
1. Deconstruction — 잡담 제거, 의미 단위로 분해, 발언자 ID는 무시한다.
2. Classification — 모든 내용을 Fact / Discussion / Decision / Risk / Action 으로 분류한다.
3. Decision Logic
- 명확한 합의 표현 → Decision
- 실행 주체 + 행동 → Action
- 제안/의견 → Discussion
- 조건 부족 → Open Issue
4. Structuring — 중복 제거, 핵심만 유지, 짧고 명확한 문장을 사용한다.
# 출력 검증 (Validation)
출력 전 내부적으로 점검한다: Decision은 실제 합의인가 / Action은 실행 가능한가.
단, 검증 과정·체크 로그는 출력하지 말 것. 최종 회의록만 출력한다.
[메타데이터]
${metaBlock}
[회의 녹취록]
\`\`\`
${transcript}
\`\`\`
# 출력 형식 (Output Format — 정확히 이 구조를 유지)
# [회의 제목]
- **날짜**: [YYYY년 MM월 DD일 | 확인 불가]
- **참석자**: [메타데이터 기준 | 없을 경우: 논의 참여 주체]
- **주제 요약**: [한 문장 요약]
## 🔹 요약 보고
핵심 논의 요약 3~5개를 글머리표로 작성.
## 1. 주요 논의 사항
각 안건마다 아래 구조로:
### [안건 제목]
- **현황**:
- **핵심 논의**:
- **결론**: [결정됨 / 논의 중 / 보류]
## 2. 리스크 및 이슈
## 3. 결정 사항
## 4. 오픈 이슈
## 5. 액션 아이템
| 담당 | 작업 내용 | 기한 |
| --- | --- | --- |
위 형식을 정확히 따르고, 모든 내용은 한국어로 작성하시오.`;
}
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⏳ 녹취 파일 읽는 중…`);
// 1) 로컬 txt 파일 읽기 — ASTRA가 직접 (로컬 파일이라 Bridge 불필요).
let transcript: string;
try {
transcript = await fsp.readFile(filePath, 'utf-8');
} catch (e: any) {
chunk(view, `\n\n❌ 파일을 읽을 수 없습니다: ${e?.message || String(e)}\n경로가 정확한지 확인하세요.\n`);
return true;
}
if (!transcript || transcript.trim().length < 20) {
chunk(view, `\n\n⚠️ 파일 내용이 거의 비어 있습니다.\n`);
return true;
}
// LLM 입력 폭주 방지 — 60000자 상한.
const MAX = 60000;
const truncated = transcript.length > MAX;
if (truncated) transcript = transcript.slice(0, MAX);
chunk(view, `\n✅ 파일 읽기 완료 (${transcript.length.toLocaleString()}${truncated ? ', 상한 초과로 일부 잘림' : ''})\n\n`);
// 2) LLM 회의록 합성.
const cfg = vscode.workspace.getConfiguration('g1nation');
const model = (cfg.get<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');
// 3) 저장 — /api/wiki/save (datacollectSavePath > WIKI_RAW_PATH).
// WIKI_RAW_PATH가 E:\Wiki\2nd\00_Raw 를 가리키므로 결과물이 그곳에 .md로 저장된다.
try {
const today = new Date().toISOString().slice(0, 10);
const baseName = filePath.replace(/^.*[\\/]/, '').replace(/\.[^.]+$/, '') || 'meeting';
const title = `회의록 ${baseName} ${today}`;
const savePath = (cfg.get<string>('datacollectSavePath', '') || '').trim();
const body: Record<string, unknown> = { title, content: report };
if (savePath) body.saveDir = savePath;
const saved = await bridgeFetch<{ success: boolean; path?: string }>(
'/api/wiki/save',
{ method: 'POST', body: JSON.stringify(body) },
{ timeoutMs: 30_000 },
);
chunk(view, `💾 **회의록 저장 완료**: \`${saved?.path || '(경로 미확인)'}\`\n`);
} catch (e: any) {
chunk(view, `⚠️ 회의록 저장 실패: ${e?.message || String(e)}\n`);
}
// 4) 캘린더 자동 등록 — 액션 아이템을 task별 종일 일정으로 Google Calendar에 등록.
if (context) {
try {
const calCfg = readCalendarConfig(context);
if (!calCfg.refreshToken) {
chunk(view, `\nℹ️ 캘린더 자동 등록을 건너뜁니다 — Google Calendar OAuth(쓰기)가 연결되지 않았습니다. (Astra Settings → Google 섹션에서 연결)\n`);
} else {
const tasks = parseActionItems(report);
if (tasks.length === 0) {
chunk(view, `\nℹ️ 액션 아이템이 없어 캘린더에 등록할 항목이 없습니다.\n`);
} else {
const today = new Date();
const meetingDate = extractMeetingDate(report, today);
const titleMatch = report.match(/^#\s+(.+)$/m);
const meetTitle = titleMatch
? titleMatch[1].replace(/^\[회의 제목\]\s*/, '').trim()
: '회의';
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`);
}
}
chunk(view, `✅ 캘린더 ${ok}/${tasks.length}건 등록 완료${tentativeCount > 0 ? ` · 미확정 ${tentativeCount}` : ''}\n`);
}
}
} catch (e: any) {
chunk(view, `\n⚠️ 캘린더 등록 중 오류: ${e?.message || String(e)}\n`);
}
}
return true;
}
// ─── /meet 캘린더 등록 헬퍼 ───
/** 토·일을 제외하고 영업일 n일을 더한 날짜 (공휴일은 고려하지 않음). */
function addBusinessDays(base: Date, n: number): Date {
const r = new Date(base);
let added = 0;
while (added < n) {
r.setDate(r.getDate() + 1);
const day = r.getDay();
if (day !== 0 && day !== 6) added++;
}
return r;
}
/** Date → 'YYYY-MM-DD' (로컬 기준). */
function toYmd(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
/** 회의록 본문의 "**날짜**: 2026년 05월 08일"에서 회의 날짜 추출. 없으면 fallback. */
function extractMeetingDate(report: string, fallback: Date): Date {
const m = report.match(/날짜\*{0,2}\s*[:]\s*\*{0,2}\s*(\d{4})\s*년\s*(\d{1,2})\s*월\s*(\d{1,2})\s*일/);
if (m) {
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
if (!isNaN(d.getTime())) return d;
}
return fallback;
}
/**
* 액션 아이템 '기한' 텍스트 → 캘린더 등록 날짜. 사용자 정의 규칙:
* - 명시 날짜(YYYY-MM-DD / YYYY년 M월 D일) → 그 날짜
* - "차주 / 다음 주 / 내주" → 회의일 +6일
* - "즉시 / 당일 / 금일 / 바로 / 오늘" → 등록일(오늘)
* - 변환 불가 / 빈 값 → 등록일 +영업일 5일, tentative=true (제목에 "(미확정)")
*/
function resolveTaskDate(due: string, meetingDate: Date, today: Date): { date: string; tentative: boolean } {
const t = (due || '').trim();
const iso = t.match(/(\d{4})-(\d{1,2})-(\d{1,2})/);
if (iso) {
return { date: `${iso[1]}-${iso[2].padStart(2, '0')}-${iso[3].padStart(2, '0')}`, tentative: false };
}
const kor = t.match(/(\d{4})\s*년\s*(\d{1,2})\s*월\s*(\d{1,2})\s*일/);
if (kor) {
return { date: toYmd(new Date(Number(kor[1]), Number(kor[2]) - 1, Number(kor[3]))), tentative: false };
}
if (/차주|다음\s*주|내주/.test(t)) {
const d = new Date(meetingDate);
d.setDate(d.getDate() + 6);
return { date: toYmd(d), tentative: false };
}
if (/즉시|당일|금일|바로|오늘/.test(t)) {
return { date: toYmd(today), tentative: false };
}
// 변환 불가 — 등록일 + 영업일 5일, "(미확정)" 꼬리표.
return { date: toYmd(addBusinessDays(today, 5)), tentative: true };
}
/** 회의록 본문의 "## 5. 액션 아이템" 마크다운 표에서 행을 파싱. */
function parseActionItems(report: string): { owner: string; work: string; due: string }[] {
const rows: { owner: string; work: string; due: string }[] = [];
let inSection = false;
for (const line of report.split('\n')) {
if (/^#{1,6}\s*5\.\s*액션\s*아이템/.test(line)) { inSection = true; continue; }
if (!inSection) continue;
if (/^#{1,6}\s/.test(line)) break; // 다음 섹션 시작 → 종료
if (!/^\s*\|/.test(line)) continue;
const cells = line.split('|').slice(1, -1).map((c) => c.trim());
if (cells.length < 3) continue;
if (/^:?-+:?$/.test(cells[0])) continue; // 표 구분선
if (cells[0] === '담당' || cells[1] === '작업 내용') continue; // 헤더
rows.push({ owner: cells[0], work: cells[1], due: cells[2] });
}
return rows;
}
+1 -1
View File
@@ -35,7 +35,7 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
return true; return true;
} }
logInfo(`[SLASH] handleSlashCommand entering`); logInfo(`[SLASH] handleSlashCommand entering`);
await handleSlashCommand(data.value, provider._view.webview); await handleSlashCommand(data.value, provider._view.webview, provider._context);
logInfo(`[SLASH] handleSlashCommand returned`); logInfo(`[SLASH] handleSlashCommand returned`);
return true; return true;
} }