v2.2.15: Astra Office Refactor & Multi-Service Integration
This commit is contained in:
+279
@@ -3303,6 +3303,185 @@ export class AgentExecutor {
|
||||
} catch (err: any) { report.push(`❌ URL Read failed: ${err.message}`); }
|
||||
}
|
||||
|
||||
// Action 9: Create Calendar Event (OAuth) — agent 가 회의록·작업 분석 후 일정 자동 생성.
|
||||
// 형식: <create_calendar_event title="..." start="2026-05-21T14:00" duration="60" location="...">설명</create_calendar_event>
|
||||
// 속성: title (필수), start (필수, ISO 'YYYY-MM-DDTHH:MM' 또는 timezone 포함),
|
||||
// end | duration (분, default 60), location, all_day (true/false)
|
||||
const calRegex = /<create_calendar_event\b([^>]*)>([\s\S]*?)<\/create_calendar_event>/gi;
|
||||
while ((match = calRegex.exec(aiMessage)) !== null) {
|
||||
const attrs = _parseCalEventAttrs(match[1]);
|
||||
const desc = match[2].trim();
|
||||
if (!attrs.title || !attrs.start) {
|
||||
report.push(`❌ Calendar Event: title / start 누락`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const { createCalendarEvent } = await import('./features/calendar');
|
||||
const r = await createCalendarEvent(this.context, {
|
||||
title: attrs.title,
|
||||
start: attrs.start,
|
||||
end: attrs.end,
|
||||
durationMinutes: attrs.duration,
|
||||
location: attrs.location,
|
||||
description: desc || undefined,
|
||||
allDay: attrs.allDay,
|
||||
});
|
||||
if (r.ok) {
|
||||
report.push(`📅 Calendar Event Created: ${r.event.title} (${r.event.startIso})`);
|
||||
// chatHistory 에 결과 주입 — agent 가 다음 답변에서 link 인용 가능.
|
||||
this.chatHistory.push({
|
||||
role: 'system',
|
||||
content: `[Calendar event created] ${r.event.title} · ${r.event.startIso}\nLink: ${r.event.htmlLink}`,
|
||||
internal: true,
|
||||
});
|
||||
} else {
|
||||
report.push(`❌ Calendar Event Failed: ${r.error}`);
|
||||
}
|
||||
} catch (err: any) { report.push(`❌ Calendar Event Error: ${err?.message ?? String(err)}`); }
|
||||
}
|
||||
|
||||
// Action 10/11/12: Google Sheets read / write / append.
|
||||
// 모두 spreadsheet_id (속성) + range (속성) 필수. write/append 는 본문이 TSV.
|
||||
// <read_sheet spreadsheet_id="1abc..." range="Sheet1!A1:D20"/>
|
||||
// <write_sheet spreadsheet_id="1abc..." range="Sheet1!A1">
|
||||
// 이름\t나이\t직책
|
||||
// 민지\t29\t디자이너
|
||||
// </write_sheet>
|
||||
// <append_sheet spreadsheet_id="1abc..." range="Sheet1!A:C">
|
||||
// 2026-05-21\t새 항목\t완료
|
||||
// </append_sheet>
|
||||
const sheetReadRegex = /<read_sheet\b([^>/]*?)\s*\/>/gi;
|
||||
while ((match = sheetReadRegex.exec(aiMessage)) !== null) {
|
||||
const a = _parseSheetAttrs(match[1]);
|
||||
if (!a.spreadsheetId || !a.range) {
|
||||
report.push(`❌ Sheet Read: spreadsheet_id / range 누락`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const { readSheetRange, valuesToMarkdownTable } = await import('./features/sheets');
|
||||
const r = await readSheetRange(this.context, a.spreadsheetId, a.range);
|
||||
if (r.ok) {
|
||||
const md = valuesToMarkdownTable(r.values);
|
||||
report.push(`📊 Sheet Read: ${a.spreadsheetId.slice(0, 8)}…/${r.range} (${r.values.length} rows)`);
|
||||
this.chatHistory.push({
|
||||
role: 'system',
|
||||
content: `[Sheet read ${r.range}]\n${md}`,
|
||||
internal: true,
|
||||
});
|
||||
} else {
|
||||
report.push(`❌ Sheet Read Failed: ${r.error}`);
|
||||
}
|
||||
} catch (err: any) { report.push(`❌ Sheet Read Error: ${err?.message ?? String(err)}`); }
|
||||
}
|
||||
const sheetWriteRegex = /<write_sheet\b([^>]*)>([\s\S]*?)<\/write_sheet>/gi;
|
||||
while ((match = sheetWriteRegex.exec(aiMessage)) !== null) {
|
||||
const a = _parseSheetAttrs(match[1]);
|
||||
const body = match[2];
|
||||
if (!a.spreadsheetId || !a.range) {
|
||||
report.push(`❌ Sheet Write: spreadsheet_id / range 누락`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const { writeSheetRange, parseTsvBody } = await import('./features/sheets');
|
||||
const values = parseTsvBody(body);
|
||||
if (values.length === 0) {
|
||||
report.push(`❌ Sheet Write: 본문 비어있음`);
|
||||
continue;
|
||||
}
|
||||
const r = await writeSheetRange(this.context, a.spreadsheetId, a.range, values);
|
||||
if (r.ok) {
|
||||
report.push(`📊 Sheet Write: ${r.updatedRange} (${r.updatedCells} cells)`);
|
||||
} else {
|
||||
report.push(`❌ Sheet Write Failed: ${r.error}`);
|
||||
}
|
||||
} catch (err: any) { report.push(`❌ Sheet Write Error: ${err?.message ?? String(err)}`); }
|
||||
}
|
||||
const sheetAppendRegex = /<append_sheet\b([^>]*)>([\s\S]*?)<\/append_sheet>/gi;
|
||||
while ((match = sheetAppendRegex.exec(aiMessage)) !== null) {
|
||||
const a = _parseSheetAttrs(match[1]);
|
||||
const body = match[2];
|
||||
if (!a.spreadsheetId || !a.range) {
|
||||
report.push(`❌ Sheet Append: spreadsheet_id / range 누락`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const { appendSheetRows, parseTsvBody } = await import('./features/sheets');
|
||||
const values = parseTsvBody(body);
|
||||
if (values.length === 0) {
|
||||
report.push(`❌ Sheet Append: 본문 비어있음`);
|
||||
continue;
|
||||
}
|
||||
const r = await appendSheetRows(this.context, a.spreadsheetId, a.range, values);
|
||||
if (r.ok) {
|
||||
report.push(`📊 Sheet Append: ${r.appendedRange} (${r.updatedCells} cells)`);
|
||||
} else {
|
||||
report.push(`❌ Sheet Append Failed: ${r.error}`);
|
||||
}
|
||||
} catch (err: any) { report.push(`❌ Sheet Append Error: ${err?.message ?? String(err)}`); }
|
||||
}
|
||||
|
||||
// Action 13/14/15: Task tracker — _shared/tasks.md 에 누적.
|
||||
// 회의록·계획·작업 진척 추적의 단일 출처. status: open/in_progress/blocked/done.
|
||||
// <add_task title="..." owner="@me" due="2026-05-24T18:00" notes="..."/>
|
||||
// <update_task id="t_001" status="in_progress" notes="진행중"/>
|
||||
// <complete_task id="t_001"/>
|
||||
const addTaskRegex = /<add_task\b([^>/]*?)\s*\/>/gi;
|
||||
while ((match = addTaskRegex.exec(aiMessage)) !== null) {
|
||||
const a = _parseTaskAttrs(match[1]);
|
||||
if (!a.title) { report.push(`❌ Add Task: title 누락`); continue; }
|
||||
try {
|
||||
const { readTaskStore, writeTaskStore, addTask } = await import('./features/tasks');
|
||||
const store = readTaskStore(this.context);
|
||||
const created = addTask(store, {
|
||||
title: a.title,
|
||||
owner: a.owner,
|
||||
due: a.due,
|
||||
notes: a.notes,
|
||||
status: a.status,
|
||||
});
|
||||
writeTaskStore(this.context, store);
|
||||
report.push(`📋 Task Added: ${created.id} · ${created.title}${created.due ? ' (due ' + created.due + ')' : ''}`);
|
||||
} catch (err: any) { report.push(`❌ Add Task Error: ${err?.message ?? String(err)}`); }
|
||||
}
|
||||
const updTaskRegex = /<update_task\b([^>/]*?)\s*\/>/gi;
|
||||
while ((match = updTaskRegex.exec(aiMessage)) !== null) {
|
||||
const a = _parseTaskAttrs(match[1]);
|
||||
if (!a.id) { report.push(`❌ Update Task: id 누락`); continue; }
|
||||
try {
|
||||
const { readTaskStore, writeTaskStore, updateTask } = await import('./features/tasks');
|
||||
const store = readTaskStore(this.context);
|
||||
const patch: any = {};
|
||||
if (a.title) patch.title = a.title;
|
||||
if (a.owner) patch.owner = a.owner;
|
||||
if (a.due) patch.due = a.due;
|
||||
if (a.notes) patch.notes = a.notes;
|
||||
if (a.status) patch.status = a.status;
|
||||
const updated = updateTask(store, a.id, patch);
|
||||
if (!updated) {
|
||||
report.push(`❌ Update Task: ${a.id} 를 active 목록에서 못 찾음`);
|
||||
} else {
|
||||
writeTaskStore(this.context, store);
|
||||
report.push(`📋 Task Updated: ${updated.id} → ${updated.status}${updated.due ? ' (due ' + updated.due + ')' : ''}`);
|
||||
}
|
||||
} catch (err: any) { report.push(`❌ Update Task Error: ${err?.message ?? String(err)}`); }
|
||||
}
|
||||
const compTaskRegex = /<complete_task\b([^>/]*?)\s*\/>/gi;
|
||||
while ((match = compTaskRegex.exec(aiMessage)) !== null) {
|
||||
const a = _parseTaskAttrs(match[1]);
|
||||
if (!a.id) { report.push(`❌ Complete Task: id 누락`); continue; }
|
||||
try {
|
||||
const { readTaskStore, writeTaskStore, completeTask } = await import('./features/tasks');
|
||||
const store = readTaskStore(this.context);
|
||||
const closed = completeTask(store, a.id);
|
||||
if (!closed) {
|
||||
report.push(`❌ Complete Task: ${a.id} 못 찾음 (이미 done 이거나 존재 X)`);
|
||||
} else {
|
||||
writeTaskStore(this.context, store);
|
||||
report.push(`✅ Task Done: ${closed.id} · ${closed.title}`);
|
||||
}
|
||||
} catch (err: any) { report.push(`❌ Complete Task Error: ${err?.message ?? String(err)}`); }
|
||||
}
|
||||
|
||||
if (firstCreatedFile) {
|
||||
// Always open file results in the editor group (column 2) — the ConnectAI
|
||||
// sidebar lives in column 3 and we don't want freshly-written files to
|
||||
@@ -3369,3 +3548,103 @@ export class AgentExecutor {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* <add_task> / <update_task> / <complete_task> 의 attribute 파서.
|
||||
* 모든 필드 optional 로 받고 caller 가 필수 체크. status 는 정규화 (in_progress, 등).
|
||||
*/
|
||||
export function _parseTaskAttrs(raw: string): {
|
||||
id?: string;
|
||||
title?: string;
|
||||
owner?: string;
|
||||
due?: string;
|
||||
notes?: string;
|
||||
status?: import('./features/tasks').TaskStatus;
|
||||
} {
|
||||
const out: any = {};
|
||||
const re = /([\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(raw)) !== null) {
|
||||
const key = m[1].toLowerCase();
|
||||
const val = (m[2] ?? m[3] ?? m[4] ?? '').trim();
|
||||
if (!val) continue;
|
||||
switch (key) {
|
||||
case 'id': out.id = val; break;
|
||||
case 'title': out.title = val; break;
|
||||
case 'owner': out.owner = val; break;
|
||||
case 'due': out.due = val; break;
|
||||
case 'notes': out.notes = val; break;
|
||||
case 'status': {
|
||||
const v = val.toLowerCase().replace(/\s+/g, '_');
|
||||
if (v === 'in_progress' || v === 'inprogress' || v === 'progress') out.status = 'in_progress';
|
||||
else if (v === 'blocked' || v === 'block') out.status = 'blocked';
|
||||
else if (v === 'done' || v === 'completed' || v === 'closed') out.status = 'done';
|
||||
else out.status = 'open';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* <read_sheet> / <write_sheet> / <append_sheet> 의 attribute 문자열을 객체로 파싱.
|
||||
* spreadsheet_id / spreadsheetId / sheetId 모두 받는 — LLM 의 변형 emission 흡수.
|
||||
*/
|
||||
export function _parseSheetAttrs(raw: string): { spreadsheetId?: string; range?: string } {
|
||||
const out: { spreadsheetId?: string; range?: string } = {};
|
||||
const re = /([\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(raw)) !== null) {
|
||||
const key = m[1].toLowerCase();
|
||||
const val = (m[2] ?? m[3] ?? m[4] ?? '').trim();
|
||||
if (!val) continue;
|
||||
if (key === 'spreadsheet_id' || key === 'spreadsheetid' || key === 'sheet_id' || key === 'sheetid') {
|
||||
out.spreadsheetId = val;
|
||||
} else if (key === 'range') {
|
||||
out.range = val;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* <create_calendar_event ...> 의 attribute 문자열을 객체로 파싱.
|
||||
* 큰따옴표 / 작은따옴표 / 따옴표 없이 (공백·`>` 으로 종료) 모두 허용 — LLM 이 어떤
|
||||
* 스타일로 emit 해도 통과시키기 위함. 단위테스트 가능하도록 export.
|
||||
*/
|
||||
export function _parseCalEventAttrs(raw: string): {
|
||||
title?: string;
|
||||
start?: string;
|
||||
end?: string;
|
||||
duration?: number;
|
||||
location?: string;
|
||||
allDay?: boolean;
|
||||
} {
|
||||
const out: any = {};
|
||||
// attr_name = "value" | 'value' | bare. `-` 포함 키 (all-day) 지원.
|
||||
const re = /([\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(raw)) !== null) {
|
||||
const key = m[1].toLowerCase();
|
||||
const val = (m[2] ?? m[3] ?? m[4] ?? '').trim();
|
||||
if (!val) continue;
|
||||
switch (key) {
|
||||
case 'title': out.title = val; break;
|
||||
case 'start': out.start = val; break;
|
||||
case 'end': out.end = val; break;
|
||||
case 'duration': {
|
||||
const n = parseInt(val, 10);
|
||||
if (!Number.isNaN(n) && n > 0) out.duration = n;
|
||||
break;
|
||||
}
|
||||
case 'location': out.location = val; break;
|
||||
case 'all_day':
|
||||
case 'allday':
|
||||
case 'all-day':
|
||||
out.allDay = val === 'true' || val === '1' || val === 'yes';
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -664,6 +664,22 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
// 같은 pixelOfficeUpdate 메시지 스트림을 공유하므로 백엔드 변경 최소.
|
||||
provider?.openPixelOfficePanel();
|
||||
}),
|
||||
// Google Calendar (iCal 읽기 전용) — 셋업 / 재연결 / 해제 / 즉시 새로고침.
|
||||
vscode.commands.registerCommand('g1nation.calendar.connect', async () => {
|
||||
await runConnectGoogleCalendarIcal(context);
|
||||
}),
|
||||
vscode.commands.registerCommand('g1nation.calendar.refresh', async () => {
|
||||
const { refreshCalendarCache } = await import('./features/calendar');
|
||||
const r = await refreshCalendarCache(context);
|
||||
if (r.ok) {
|
||||
vscode.window.showInformationMessage(`📅 캘린더 ${r.count}개 일정을 회사 컨텍스트에 동기화했습니다.`);
|
||||
} else {
|
||||
vscode.window.showErrorMessage(r.error || 'Calendar 새로고침 실패');
|
||||
}
|
||||
}),
|
||||
vscode.commands.registerCommand('g1nation.calendar.connectOAuth', async () => {
|
||||
await runConnectGoogleCalendarOAuth(context);
|
||||
}),
|
||||
);
|
||||
|
||||
/** All lesson/playbook/qa-finding cards in the active brain. Uses the brain index for the lesson-kind
|
||||
@@ -891,6 +907,197 @@ export async function deactivate() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Calendar (iCal 읽기 전용) 연결 마법사.
|
||||
*
|
||||
* 사용자 흐름:
|
||||
* 1. 이미 셋업 됐으면 "연결 해제 / URL 변경 / 지금 새로고침 / 취소" 선택지 노출
|
||||
* 2. 새로 셋업: Google Calendar 설정 페이지 외부 브라우저로 열고 → 비공개 iCal URL 입력
|
||||
* 3. 입력값을 globalState 에 저장 후 즉시 한 번 새로고침 실행 → 캐시 파일 생성 안내
|
||||
*
|
||||
* OAuth 가 아닌 read-only iCal 만 — 셋업 3분, 토큰 관리 없음.
|
||||
*/
|
||||
async function runConnectGoogleCalendarIcal(context: vscode.ExtensionContext) {
|
||||
const { readCalendarConfig, writeCalendarConfig, refreshCalendarCache } =
|
||||
await import('./features/calendar');
|
||||
const cur = readCalendarConfig(context);
|
||||
if (cur.icalUrl) {
|
||||
const choice = await vscode.window.showInformationMessage(
|
||||
`📅 이미 연결됨${cur.lastFetchAt ? ` (마지막 동기화: ${cur.lastFetchAt.slice(0, 16)})` : ''}`,
|
||||
{ modal: false },
|
||||
'지금 새로고침',
|
||||
'URL 변경',
|
||||
'연결 해제',
|
||||
'취소',
|
||||
);
|
||||
if (!choice || choice === '취소') return;
|
||||
if (choice === '지금 새로고침') {
|
||||
const r = await refreshCalendarCache(context);
|
||||
if (r.ok) vscode.window.showInformationMessage(`📅 ${r.count}개 일정 동기화 완료.`);
|
||||
else vscode.window.showErrorMessage(r.error || '새로고침 실패');
|
||||
return;
|
||||
}
|
||||
if (choice === '연결 해제') {
|
||||
await writeCalendarConfig(context, { icalUrl: '', lastFetchAt: undefined });
|
||||
vscode.window.showInformationMessage('Google Calendar 연결 해제됨. 캐시 파일은 그대로 둡니다.');
|
||||
return;
|
||||
}
|
||||
// URL 변경 → 아래 입력 흐름으로 fall through
|
||||
} else {
|
||||
const intro = await vscode.window.showInformationMessage(
|
||||
'📅 Google Calendar 연결 (읽기 전용, 셋업 3분)\n\n비공개 iCal URL 1개만 있으면 됩니다. OAuth 없음.\n\n계속할까요?',
|
||||
{ modal: true },
|
||||
'시작',
|
||||
'Google Calendar 설정 페이지 열기',
|
||||
'취소',
|
||||
);
|
||||
if (!intro || intro === '취소') return;
|
||||
if (intro === 'Google Calendar 설정 페이지 열기') {
|
||||
await vscode.env.openExternal(
|
||||
vscode.Uri.parse('https://calendar.google.com/calendar/u/0/r/settings'),
|
||||
);
|
||||
const back = await vscode.window.showInformationMessage(
|
||||
'1. 왼쪽에서 본인 캘린더 클릭 → "캘린더 통합" 섹션\n2. "비공개 주소(iCal 형식)" 옆 복사 버튼 클릭\n3. URL 복사한 뒤 ↓',
|
||||
{ modal: true },
|
||||
'복사함 — URL 붙여넣기',
|
||||
'취소',
|
||||
);
|
||||
if (back !== '복사함 — URL 붙여넣기') return;
|
||||
}
|
||||
}
|
||||
|
||||
const url = await vscode.window.showInputBox({
|
||||
title: 'Google Calendar 비공개 iCal URL',
|
||||
prompt: 'calendar.google.com/calendar/ical/.../private-XXX/basic.ics 형태',
|
||||
placeHolder: 'https://calendar.google.com/calendar/ical/...',
|
||||
value: cur.icalUrl,
|
||||
password: true,
|
||||
ignoreFocusOut: true,
|
||||
validateInput: (v) => {
|
||||
const t = (v || '').trim();
|
||||
if (!t) return '비어있어요';
|
||||
if (!/^https?:\/\//.test(t)) return 'http:// 또는 https:// 로 시작해야 합니다.';
|
||||
return null;
|
||||
},
|
||||
});
|
||||
if (!url) return;
|
||||
|
||||
await writeCalendarConfig(context, { icalUrl: url.trim() });
|
||||
const r = await refreshCalendarCache(context);
|
||||
if (r.ok) {
|
||||
vscode.window.showInformationMessage(
|
||||
`✅ 연결 완료 — ${r.count}개 일정을 회사 컨텍스트에 동기화했습니다.\n\n이제 기업 모드에서 모든 에이전트가 다가오는 일정을 자동으로 참고합니다.`,
|
||||
);
|
||||
} else {
|
||||
vscode.window.showErrorMessage(
|
||||
`URL 저장은 됐지만 첫 새로고침 실패: ${r.error}\n\nURL 이 정확한지 다시 확인해주세요.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Calendar OAuth (쓰기) 연결 마법사.
|
||||
*
|
||||
* iCal 마법사와 별도 — 이쪽은 agent 가 회의록 보고 자동으로 일정 *만들* 수 있게 한다.
|
||||
* 셋업 5~10분: Google Cloud Console 에서 OAuth Client ID/Secret 발급 → 본 마법사가
|
||||
* loopback OAuth 흐름 실행 → refresh token 받아 globalState 저장.
|
||||
*/
|
||||
async function runConnectGoogleCalendarOAuth(context: vscode.ExtensionContext) {
|
||||
const { readCalendarConfig, writeCalendarConfig, runOAuthLoopback, fetchUserEmail } =
|
||||
await import('./features/calendar');
|
||||
const cur = readCalendarConfig(context);
|
||||
const already = !!(cur.clientId && cur.clientSecret && cur.refreshToken);
|
||||
if (already) {
|
||||
const choice = await vscode.window.showInformationMessage(
|
||||
`✅ 이미 OAuth 연결됨${cur.connectedAs ? ` (${cur.connectedAs})` : ''}`,
|
||||
{ modal: false },
|
||||
'재연결',
|
||||
'연결 해제',
|
||||
'취소',
|
||||
);
|
||||
if (!choice || choice === '취소') return;
|
||||
if (choice === '연결 해제') {
|
||||
await writeCalendarConfig(context, {
|
||||
clientId: undefined, clientSecret: undefined, refreshToken: undefined,
|
||||
accessToken: undefined, accessTokenExpiresAt: undefined,
|
||||
connectedAs: undefined, connectedAt: undefined,
|
||||
});
|
||||
vscode.window.showInformationMessage(
|
||||
'OAuth 연결 해제. https://myaccount.google.com/permissions 에서도 권한을 직접 회수할 수 있습니다.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 재연결 → 아래 flow
|
||||
} else {
|
||||
const intro = await vscode.window.showInformationMessage(
|
||||
'📅 Google Calendar 쓰기 연결 (OAuth, 5~10분)\n\n회의록을 받으면 agent 가 자동으로 일정을 생성하게 됩니다.\n\n1단계: Google Cloud Console 에서 OAuth Client ID 발급 (수동 클릭, 가이드 따라)\n2단계: ID + Secret 붙여넣기\n3단계: 브라우저 로그인',
|
||||
{ modal: true },
|
||||
'시작',
|
||||
'Cloud Console 먼저 열기',
|
||||
'취소',
|
||||
);
|
||||
if (!intro || intro === '취소') return;
|
||||
if (intro === 'Cloud Console 먼저 열기') {
|
||||
await vscode.env.openExternal(vscode.Uri.parse('https://console.cloud.google.com/apis/credentials'));
|
||||
const back = await vscode.window.showInformationMessage(
|
||||
'아래 절차 마치고 돌아오세요:\n\n1. 새 프로젝트 만들기 (또는 기존)\n2. APIs & Services → Library → "Google Calendar API" 활성화\n3. OAuth 동의 화면 — External, Test users 에 본인 이메일\n4. Credentials → Create OAuth 2.0 Client ID → "Desktop app"\n5. Client ID + Client Secret 복사',
|
||||
{ modal: true },
|
||||
'다 됐음 →',
|
||||
'취소',
|
||||
);
|
||||
if (back !== '다 됐음 →') return;
|
||||
}
|
||||
}
|
||||
|
||||
const clientId = await vscode.window.showInputBox({
|
||||
title: 'Google OAuth Client ID',
|
||||
prompt: 'Credentials 페이지에서 복사한 Client ID',
|
||||
placeHolder: 'xxxxxxxx.apps.googleusercontent.com',
|
||||
value: cur.clientId,
|
||||
ignoreFocusOut: true,
|
||||
validateInput: (v) => (v || '').trim() ? null : '비어있어요',
|
||||
});
|
||||
if (!clientId) return;
|
||||
const clientSecret = await vscode.window.showInputBox({
|
||||
title: 'Google OAuth Client Secret',
|
||||
prompt: '같은 화면의 Client Secret',
|
||||
placeHolder: 'GOCSPX-...',
|
||||
value: cur.clientSecret,
|
||||
password: true,
|
||||
ignoreFocusOut: true,
|
||||
validateInput: (v) => (v || '').trim() ? null : '비어있어요',
|
||||
});
|
||||
if (!clientSecret) return;
|
||||
|
||||
await vscode.window.withProgress({
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: '🔐 Google 로그인 대기 중…',
|
||||
cancellable: true,
|
||||
}, async (progress, cancelToken) => {
|
||||
progress.report({ message: '브라우저에서 Google 로그인 진행하세요 (최대 5분 대기)' });
|
||||
const result = await runOAuthLoopback(clientId.trim(), clientSecret.trim(), cancelToken);
|
||||
if (!result.ok) {
|
||||
vscode.window.showErrorMessage(`OAuth 실패: ${result.error}`);
|
||||
return;
|
||||
}
|
||||
const email = await fetchUserEmail(result.accessToken);
|
||||
await writeCalendarConfig(context, {
|
||||
clientId: clientId.trim(),
|
||||
clientSecret: clientSecret.trim(),
|
||||
refreshToken: result.refreshToken,
|
||||
accessToken: result.accessToken,
|
||||
accessTokenExpiresAt: result.expiresAt,
|
||||
calendarId: cur.calendarId ?? 'primary',
|
||||
defaultDurationMinutes: cur.defaultDurationMinutes ?? 60,
|
||||
connectedAs: email,
|
||||
connectedAt: new Date().toISOString(),
|
||||
});
|
||||
vscode.window.showInformationMessage(
|
||||
`✅ Google Calendar 쓰기 연결 완료!${email ? ' (' + email + ')' : ''}\n\n이제 회의록을 보내거나 due 가 있는 작업을 알려주면 agent 가 자동으로 일정을 생성합니다.`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function runInitialSetup(context: vscode.ExtensionContext) {
|
||||
// 이미 사용자가 URL을 설정했다면 자동 감지를 스킵
|
||||
const existingUrl = vscode.workspace.getConfiguration('g1nation').get<string>('ollamaUrl');
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Astra Office — public API. 다음 세션에서 추가될 OfficeSnapshot presenter / schema
|
||||
// 도 같은 entry 로 노출 예정.
|
||||
//
|
||||
// 현재 노출: full webview panel HTML 생성 함수. sidebarProvider.ts 는 이 한 줄만 import.
|
||||
|
||||
export { renderAstraOfficePanelHtml } from './view/panelHtml';
|
||||
export type { AstraOfficePanelAssets } from './view/panelHtml';
|
||||
export type {
|
||||
OfficeSnapshot,
|
||||
OfficeAgentSnapshot,
|
||||
OfficePhase,
|
||||
OfficeActivityItem,
|
||||
OfficeBubbleSeed,
|
||||
} from './schema';
|
||||
export { validateOfficeSnapshot, makeEmptyOfficeSnapshot } from './schema';
|
||||
export { presentOfficeSnapshot } from './presenter';
|
||||
export type { LayoutV2, OfficeDeskCell, OfficeProp } from './view/layoutSchema';
|
||||
export { validateLayout, migrateLayout } from './view/layoutSchema';
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Presenter — 옛 AgentWorkState + bubble queue + activity items 를 OfficeSnapshot 으로
|
||||
* 변환하는 *pure* 함수. mini / full view 둘 다 같은 OfficeSnapshot 을 받게 만드는 게 목표.
|
||||
*
|
||||
* 이번 세션의 범위: 인터페이스 + 스텁. 실제 wiring 은 다음 세션에서:
|
||||
* - sidebarProvider 의 `_pixelOfficeBroadcast` 가 옛 message 대신 OfficeSnapshot 송신
|
||||
* - mini view (media/sidebar.js) 와 full view (features/astraOffice/view/runtime.ts)
|
||||
* 둘 다 `officeSnapshot` message 를 받아 자기 식대로 렌더
|
||||
*
|
||||
* 이번 세션에 옛 message 와 OfficeSnapshot 을 *동시에* 보낼 수도 있게 — 호환 모드. 다음
|
||||
* 세션에서 옛 message 제거.
|
||||
*/
|
||||
|
||||
import type { AgentWorkState, AgentBubble } from '../company/pixelOfficeState';
|
||||
import type { CompanyState } from '../company/types';
|
||||
import {
|
||||
type OfficeSnapshot,
|
||||
type OfficeAgentSnapshot,
|
||||
type OfficePhase,
|
||||
type OfficeActivityItem,
|
||||
type OfficeBubbleSeed,
|
||||
makeEmptyOfficeSnapshot,
|
||||
} from './schema';
|
||||
|
||||
/** mini/full view 가 받는 message envelope. */
|
||||
export interface OfficeSnapshotMessage {
|
||||
type: 'officeSnapshot';
|
||||
value: OfficeSnapshot;
|
||||
}
|
||||
|
||||
/** 옛 AgentWorkState.status → OfficePhase 매핑. */
|
||||
const STATUS_TO_PHASE: Record<string, OfficePhase> = {
|
||||
idle: 'idle',
|
||||
intake: 'intake',
|
||||
analyzing: 'planning',
|
||||
need_clarification: 'awaiting-approval',
|
||||
contract_ready: 'planning',
|
||||
planning: 'planning',
|
||||
executing: 'executing',
|
||||
reviewing: 'reviewing',
|
||||
waiting_approval: 'awaiting-approval',
|
||||
error: 'error',
|
||||
done: 'done',
|
||||
};
|
||||
|
||||
/** agentId 가 alias 면 정식 id 로 정규화. dispatcher / agents.ts 와 같은 규칙. */
|
||||
const AGENT_ALIASES: Record<string, string> = {
|
||||
writer: 'writer',
|
||||
editor: 'designer',
|
||||
secretary: 'support',
|
||||
business: 'inspector',
|
||||
};
|
||||
|
||||
export function normalizeAgentId(rawAgentId: string | undefined): string | null {
|
||||
if (!rawAgentId) return null;
|
||||
const lower = rawAgentId.toLowerCase();
|
||||
return AGENT_ALIASES[lower] ?? lower;
|
||||
}
|
||||
|
||||
/**
|
||||
* 옛 입력들을 합쳐 새 OfficeSnapshot 을 만든다. 입력 중 일부가 undefined 라도 안전.
|
||||
*
|
||||
* 이번 세션 stub: 옛 AgentWorkState 의 *단일 슬롯* 정보로 roster 1개 짜리 snapshot 만
|
||||
* 생성. 다음 세션에서 CompanyState 의 active roster 전체로 확장.
|
||||
*/
|
||||
export function presentOfficeSnapshot(input: {
|
||||
activeState?: AgentWorkState;
|
||||
recentBubbles?: AgentBubble[];
|
||||
recentActivity?: OfficeActivityItem[];
|
||||
company?: CompanyState;
|
||||
/**
|
||||
* 이 turn 의 active agent roster — `listActiveAgentsByCategory` 결과를 평탄화해서 전달.
|
||||
* 빈 배열/undefined 면 옛 동작 (active agent 1명만) 으로 fallback.
|
||||
*/
|
||||
roster?: Array<{ agentId: string; agentName: string; roleCategory: OfficeAgentSnapshot['roleCategory'] }>;
|
||||
}): OfficeSnapshot {
|
||||
const snap = makeEmptyOfficeSnapshot();
|
||||
const { activeState, recentBubbles, recentActivity, roster: rosterInput } = input;
|
||||
|
||||
if (activeState) {
|
||||
const phase = STATUS_TO_PHASE[activeState.status] ?? 'idle';
|
||||
snap.phase = phase;
|
||||
snap.activeAgentId = normalizeAgentId(activeState.agentId);
|
||||
const activeId = snap.activeAgentId;
|
||||
// Roster:
|
||||
// - rosterInput 이 주어지면 (#G) 회사 전체 active agent 사용. active agent 만
|
||||
// activeState 의 상태로 표시하고 나머지는 idle.
|
||||
// - 없으면 (옛 caller) 활성 agent 1명만 fallback.
|
||||
if (rosterInput && rosterInput.length > 0) {
|
||||
const now = Date.now();
|
||||
snap.roster = rosterInput.map((r) => {
|
||||
const isActive = activeId !== null && r.agentId === activeId;
|
||||
return {
|
||||
agentId: r.agentId,
|
||||
agentName: r.agentName,
|
||||
roleCategory: r.roleCategory,
|
||||
status: isActive ? activeState.status : 'idle',
|
||||
currentStep: isActive ? activeState.currentStep : undefined,
|
||||
lastLog: isActive ? (activeState.recentLogs ?? []).slice(-1)[0] : undefined,
|
||||
lastActivityAt: isActive ? (activeState.updatedAt ?? now) : 0,
|
||||
};
|
||||
});
|
||||
} else {
|
||||
const role = _inferRoleCategory(activeState.agentId);
|
||||
const agent: OfficeAgentSnapshot = {
|
||||
agentId: snap.activeAgentId ?? activeState.agentId,
|
||||
agentName: activeState.agentName ?? activeState.agentId,
|
||||
roleCategory: role,
|
||||
status: activeState.status,
|
||||
currentStep: activeState.currentStep,
|
||||
lastLog: (activeState.recentLogs ?? []).slice(-1)[0],
|
||||
lastActivityAt: activeState.updatedAt ?? Date.now(),
|
||||
};
|
||||
snap.roster = [agent];
|
||||
}
|
||||
if (activeState.currentTask) {
|
||||
snap.task = {
|
||||
goal: activeState.currentTask,
|
||||
criteria: activeState.requirementContract?.criteria,
|
||||
openQuestions: activeState.requirementContract?.openQuestions,
|
||||
format: activeState.requirementContract?.format,
|
||||
context: activeState.requirementContract?.context,
|
||||
};
|
||||
}
|
||||
if (activeState.pipelineStages) {
|
||||
snap.pipeline = {
|
||||
stages: activeState.pipelineStages.map((s) => ({
|
||||
label: s.label,
|
||||
agentId: s.agent,
|
||||
status: s.status,
|
||||
})),
|
||||
index: activeState.pipelineStages.findIndex((s) => s.status === 'active'),
|
||||
};
|
||||
}
|
||||
if (activeState.needUserInput?.length || activeState.awaitingApproval) {
|
||||
snap.awaiting = {
|
||||
kind: activeState.awaitingApproval ? 'approval' : 'clarification',
|
||||
questions: activeState.awaitingApproval
|
||||
? [activeState.awaitingApproval]
|
||||
: (activeState.needUserInput ?? []),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (recentActivity?.length) snap.activity = recentActivity.slice(-32);
|
||||
|
||||
if (recentBubbles?.length) {
|
||||
snap.newBubbles = recentBubbles
|
||||
.map((b) => _toBubbleSeed(b))
|
||||
.filter((b): b is OfficeBubbleSeed => b !== null);
|
||||
}
|
||||
|
||||
snap.updatedAt = Date.now();
|
||||
return snap;
|
||||
}
|
||||
|
||||
// ── helpers ──
|
||||
|
||||
function _inferRoleCategory(rawAgentId: string | undefined): OfficeAgentSnapshot['roleCategory'] {
|
||||
if (!rawAgentId) return 'support';
|
||||
const id = rawAgentId.toLowerCase();
|
||||
if (id.includes('ceo')) return 'ceo';
|
||||
if (id.includes('plan')) return 'planner';
|
||||
if (id.includes('research')) return 'researcher';
|
||||
if (id.includes('design')) return 'designer';
|
||||
if (id.includes('writer')) return 'writer';
|
||||
if (id.includes('editor')) return 'designer';
|
||||
if (id.includes('developer') || id.includes('dev')) return 'developer';
|
||||
if (id.includes('qa')) return 'qa';
|
||||
if (id.includes('inspect') || id.includes('business')) return 'inspector';
|
||||
return 'support';
|
||||
}
|
||||
|
||||
function _toBubbleSeed(b: AgentBubble): OfficeBubbleSeed | null {
|
||||
if (!b || !b.text) return null;
|
||||
return {
|
||||
agentId: normalizeAgentId(b.agentId) ?? b.agentId,
|
||||
text: b.text,
|
||||
type: (b.type as OfficeBubbleSeed['type']) ?? 'status',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* OfficeSnapshot — Astra Office 의 도메인 타입.
|
||||
*
|
||||
* 동시성 진실 (docs/ASTRA_OFFICE_REFACTOR.md §1): dispatcher 는 직렬이라 한 시점에
|
||||
* active agent 는 0 또는 1명. 이걸 데이터로 강제하는 게 이 타입의 핵심 역할.
|
||||
*
|
||||
* 이 세션에서는 *타입 + validator + empty factory* 만. 백엔드 emit 측 wiring 은
|
||||
* 다음 세션에서 단계적으로 옮긴다. 현재는 옛 AgentWorkState/CompanyTurnEvent 가
|
||||
* presenter 입력으로 살아있고, 출력만 OfficeSnapshot.
|
||||
*/
|
||||
|
||||
import type { AgentStatus } from '../company/pixelOfficeState';
|
||||
|
||||
export type OfficePhase =
|
||||
| 'idle'
|
||||
| 'intake'
|
||||
| 'planning'
|
||||
| 'executing'
|
||||
| 'reviewing'
|
||||
| 'awaiting-approval'
|
||||
| 'reporting'
|
||||
| 'done'
|
||||
| 'error';
|
||||
|
||||
/** 한 명의 agent 가 한 시점에 가진 상태. */
|
||||
export interface OfficeAgentSnapshot {
|
||||
/** company roster 의 정식 id (built-in 또는 custom). */
|
||||
agentId: string;
|
||||
/** 표시 이름. */
|
||||
agentName: string;
|
||||
/** 책상 색깔 / 매핑에 쓰이는 role category. */
|
||||
roleCategory:
|
||||
| 'ceo'
|
||||
| 'planner'
|
||||
| 'researcher'
|
||||
| 'designer'
|
||||
| 'developer'
|
||||
| 'qa'
|
||||
| 'inspector'
|
||||
| 'support'
|
||||
| 'writer';
|
||||
status: AgentStatus;
|
||||
/** "검수 라운드 2/3", "타입 누락" 같은 짧은 부가 정보. */
|
||||
currentStep?: string;
|
||||
/** 머리 위 말풍선 source. presenter 가 비워서 보내면 webview 는 풍선 안 띄움. */
|
||||
lastLog?: string;
|
||||
/** 정렬 / 신선도 판정. */
|
||||
lastActivityAt: number;
|
||||
}
|
||||
|
||||
export interface OfficeActivityItem {
|
||||
/** epoch ms. */
|
||||
ts: number;
|
||||
agentId: string;
|
||||
text: string;
|
||||
kind?: 'ok' | 'warn' | 'err' | 'info';
|
||||
}
|
||||
|
||||
/** webview 가 풍선을 띄울 때 참고할 hint. presenter 가 매 update 마다 새 풍선 후보를 만들어 보냄. */
|
||||
export interface OfficeBubbleSeed {
|
||||
agentId: string;
|
||||
text: string;
|
||||
/** 색깔/스타일. presenter 가 status/event/warning/error/success 중 분류. */
|
||||
type: 'status' | 'event' | 'warning' | 'error' | 'success';
|
||||
}
|
||||
|
||||
export interface OfficeSnapshot {
|
||||
/** 회사 전체 phase. activeAgent 가 어떤 단계에 있는지와 분리해서 추적. */
|
||||
phase: OfficePhase;
|
||||
/** 활성 agent id — 직렬 dispatch 가정. null 이면 idle. */
|
||||
activeAgentId: string | null;
|
||||
/** 이 turn 에 참여 가능한 모든 agent. webview 의 책상 그리드 source. */
|
||||
roster: OfficeAgentSnapshot[];
|
||||
/** 현재 요구사항 / 계약 요약. */
|
||||
task?: {
|
||||
goal: string;
|
||||
context?: string;
|
||||
format?: string;
|
||||
criteria?: string[];
|
||||
openQuestions?: string[];
|
||||
};
|
||||
/** 파이프라인 모드의 stage 진행도. */
|
||||
pipeline?: {
|
||||
stages: Array<{
|
||||
label: string;
|
||||
agentId?: string;
|
||||
status: 'done' | 'active' | 'pending';
|
||||
}>;
|
||||
index: number;
|
||||
};
|
||||
/** 승인 / 추가 정보 대기. */
|
||||
awaiting?: {
|
||||
kind: 'approval' | 'clarification';
|
||||
questions: string[];
|
||||
};
|
||||
/** 누적 활동 ring buffer — webview ticker 의 source. */
|
||||
activity: OfficeActivityItem[];
|
||||
/** 이 update 사이클에서 새로 생긴 풍선 후보. presenter 가 매 emit 마다 갱신. */
|
||||
newBubbles: OfficeBubbleSeed[];
|
||||
/** 디버깅/정렬용. */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
const VALID_PHASES: ReadonlySet<OfficePhase> = new Set<OfficePhase>([
|
||||
'idle',
|
||||
'intake',
|
||||
'planning',
|
||||
'executing',
|
||||
'reviewing',
|
||||
'awaiting-approval',
|
||||
'reporting',
|
||||
'done',
|
||||
'error',
|
||||
]);
|
||||
|
||||
const VALID_ROLES: ReadonlySet<OfficeAgentSnapshot['roleCategory']> = new Set([
|
||||
'ceo',
|
||||
'planner',
|
||||
'researcher',
|
||||
'designer',
|
||||
'developer',
|
||||
'qa',
|
||||
'inspector',
|
||||
'support',
|
||||
'writer',
|
||||
]);
|
||||
|
||||
const VALID_STATUSES: ReadonlySet<AgentStatus> = new Set<AgentStatus>([
|
||||
'idle',
|
||||
'intake',
|
||||
'analyzing',
|
||||
'need_clarification',
|
||||
'contract_ready',
|
||||
'planning',
|
||||
'executing',
|
||||
'reviewing',
|
||||
'waiting_approval',
|
||||
'error',
|
||||
'done',
|
||||
]);
|
||||
|
||||
const VALID_BUBBLE_TYPES = new Set<OfficeBubbleSeed['type']>([
|
||||
'status',
|
||||
'event',
|
||||
'warning',
|
||||
'error',
|
||||
'success',
|
||||
]);
|
||||
|
||||
const VALID_KINDS = new Set<NonNullable<OfficeActivityItem['kind']>>([
|
||||
'ok',
|
||||
'warn',
|
||||
'err',
|
||||
'info',
|
||||
]);
|
||||
|
||||
/**
|
||||
* 런타임 schema validation. 임의의 unknown 입력을 받아 *완전한 OfficeSnapshot* 또는
|
||||
* null 을 반환한다. 누락된 필드는 안전한 기본값으로 채운다. 잘못된 enum 값은 'idle'/
|
||||
* 기본 role 로 fall through. 호출자는 null 이면 default snapshot 으로 폴백 권장.
|
||||
*/
|
||||
export function validateOfficeSnapshot(raw: unknown): OfficeSnapshot | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const r = raw as Record<string, unknown>;
|
||||
|
||||
const phaseRaw = String(r.phase ?? 'idle');
|
||||
const phase: OfficePhase = (VALID_PHASES as ReadonlySet<string>).has(phaseRaw)
|
||||
? (phaseRaw as OfficePhase)
|
||||
: 'idle';
|
||||
|
||||
const activeAgentId =
|
||||
typeof r.activeAgentId === 'string' && r.activeAgentId.length > 0
|
||||
? r.activeAgentId
|
||||
: null;
|
||||
|
||||
const rosterRaw = Array.isArray(r.roster) ? r.roster : [];
|
||||
const roster: OfficeAgentSnapshot[] = rosterRaw
|
||||
.map((a) => _validateAgent(a))
|
||||
.filter((a): a is OfficeAgentSnapshot => a !== null);
|
||||
|
||||
const taskRaw = r.task as Record<string, unknown> | undefined;
|
||||
const task = taskRaw && typeof taskRaw === 'object' && typeof taskRaw.goal === 'string'
|
||||
? {
|
||||
goal: taskRaw.goal,
|
||||
context: _optStr(taskRaw.context),
|
||||
format: _optStr(taskRaw.format),
|
||||
criteria: Array.isArray(taskRaw.criteria)
|
||||
? taskRaw.criteria.filter((x): x is string => typeof x === 'string')
|
||||
: undefined,
|
||||
openQuestions: Array.isArray(taskRaw.openQuestions)
|
||||
? taskRaw.openQuestions.filter((x): x is string => typeof x === 'string')
|
||||
: undefined,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const pipelineRaw = r.pipeline as Record<string, unknown> | undefined;
|
||||
const pipeline = pipelineRaw && Array.isArray(pipelineRaw.stages)
|
||||
? {
|
||||
stages: pipelineRaw.stages
|
||||
.map((s) => _validateStage(s))
|
||||
.filter((s): s is { label: string; agentId?: string; status: 'done' | 'active' | 'pending' } => s !== null),
|
||||
index: typeof pipelineRaw.index === 'number' ? pipelineRaw.index : 0,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const awaitingRaw = r.awaiting as Record<string, unknown> | undefined;
|
||||
const awaiting = awaitingRaw && (awaitingRaw.kind === 'approval' || awaitingRaw.kind === 'clarification')
|
||||
? {
|
||||
kind: awaitingRaw.kind as 'approval' | 'clarification',
|
||||
questions: Array.isArray(awaitingRaw.questions)
|
||||
? awaitingRaw.questions.filter((x): x is string => typeof x === 'string')
|
||||
: [],
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const activity: OfficeActivityItem[] = Array.isArray(r.activity)
|
||||
? r.activity
|
||||
.map((it) => _validateActivity(it))
|
||||
.filter((it): it is OfficeActivityItem => it !== null)
|
||||
: [];
|
||||
|
||||
const newBubbles: OfficeBubbleSeed[] = Array.isArray(r.newBubbles)
|
||||
? r.newBubbles
|
||||
.map((b) => _validateBubble(b))
|
||||
.filter((b): b is OfficeBubbleSeed => b !== null)
|
||||
: [];
|
||||
|
||||
const updatedAt = typeof r.updatedAt === 'number' ? r.updatedAt : Date.now();
|
||||
|
||||
return { phase, activeAgentId, roster, task, pipeline, awaiting, activity, newBubbles, updatedAt };
|
||||
}
|
||||
|
||||
export function makeEmptyOfficeSnapshot(): OfficeSnapshot {
|
||||
return {
|
||||
phase: 'idle',
|
||||
activeAgentId: null,
|
||||
roster: [],
|
||||
activity: [],
|
||||
newBubbles: [],
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────── helpers ───────────
|
||||
|
||||
function _optStr(v: unknown): string | undefined {
|
||||
return typeof v === 'string' ? v : undefined;
|
||||
}
|
||||
|
||||
function _validateAgent(raw: unknown): OfficeAgentSnapshot | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const a = raw as Record<string, unknown>;
|
||||
if (typeof a.agentId !== 'string' || !a.agentId) return null;
|
||||
|
||||
const role = typeof a.roleCategory === 'string' && (VALID_ROLES as ReadonlySet<string>).has(a.roleCategory)
|
||||
? (a.roleCategory as OfficeAgentSnapshot['roleCategory'])
|
||||
: 'support';
|
||||
const status: AgentStatus = typeof a.status === 'string' && (VALID_STATUSES as ReadonlySet<string>).has(a.status)
|
||||
? (a.status as AgentStatus)
|
||||
: 'idle';
|
||||
|
||||
return {
|
||||
agentId: a.agentId,
|
||||
agentName: typeof a.agentName === 'string' ? a.agentName : a.agentId,
|
||||
roleCategory: role,
|
||||
status,
|
||||
currentStep: _optStr(a.currentStep),
|
||||
lastLog: _optStr(a.lastLog),
|
||||
lastActivityAt: typeof a.lastActivityAt === 'number' ? a.lastActivityAt : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function _validateStage(raw: unknown): { label: string; agentId?: string; status: 'done' | 'active' | 'pending' } | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const s = raw as Record<string, unknown>;
|
||||
if (typeof s.label !== 'string') return null;
|
||||
const statusRaw = String(s.status ?? 'pending');
|
||||
const status = (statusRaw === 'done' || statusRaw === 'active' || statusRaw === 'pending') ? statusRaw : 'pending';
|
||||
return { label: s.label, agentId: _optStr(s.agentId), status };
|
||||
}
|
||||
|
||||
function _validateActivity(raw: unknown): OfficeActivityItem | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const a = raw as Record<string, unknown>;
|
||||
if (typeof a.text !== 'string' || typeof a.agentId !== 'string') return null;
|
||||
const kind = typeof a.kind === 'string' && (VALID_KINDS as ReadonlySet<string>).has(a.kind)
|
||||
? (a.kind as OfficeActivityItem['kind'])
|
||||
: undefined;
|
||||
return {
|
||||
ts: typeof a.ts === 'number' ? a.ts : Date.now(),
|
||||
agentId: a.agentId,
|
||||
text: a.text,
|
||||
kind,
|
||||
};
|
||||
}
|
||||
|
||||
function _validateBubble(raw: unknown): OfficeBubbleSeed | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const b = raw as Record<string, unknown>;
|
||||
if (typeof b.agentId !== 'string' || typeof b.text !== 'string') return null;
|
||||
const type = typeof b.type === 'string' && (VALID_BUBBLE_TYPES as ReadonlySet<string>).has(b.type)
|
||||
? (b.type as OfficeBubbleSeed['type'])
|
||||
: 'status';
|
||||
return { agentId: b.agentId, text: b.text, type };
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Pixel Office layout 저장 스키마 — workspaceState 의 `g1nation.pixelOfficeLayout` 키
|
||||
* 에 저장되는 객체의 런타임 validator + v1 → v2 migration.
|
||||
*
|
||||
* 옛 runtime.ts 의 `_isV2Snap()` heuristic 을 정식 schema 로 격상. webview 에서 받는
|
||||
* 즉시 한 번 통과시키면 깨진 데이터 / 옛 데이터 모두 안전하게 처리된다.
|
||||
*
|
||||
* 백엔드는 unknown 그대로 저장하지만, *로드 직후* 이 validator 를 적용해 정규화한다.
|
||||
*/
|
||||
|
||||
export interface OfficeDeskCell {
|
||||
/** 안정적 식별자 — DOM dataset.role 로도 쓰임. */
|
||||
roleKey: string;
|
||||
/** 매핑된 agent id. 비어있으면 unmapped. */
|
||||
agentKey: string;
|
||||
label: string;
|
||||
charRow: number; // 0~7
|
||||
deskSprite: string;
|
||||
/** 앉은 face. */
|
||||
face: 'L' | 'R' | 'U' | 'D';
|
||||
boss: boolean;
|
||||
/** seat 에서 잠시 일어났다 가는 dock 좌표. */
|
||||
dock?: [number, number];
|
||||
/** 랜덤 roam 후보 좌표들. */
|
||||
roam?: Array<[number, number]>;
|
||||
deskX: number;
|
||||
deskY: number;
|
||||
deskW: number;
|
||||
deskRot: number;
|
||||
deskZ: number;
|
||||
seatX: number;
|
||||
seatY: number;
|
||||
charRot: number;
|
||||
charZ: number;
|
||||
/** 캐릭터를 지운 빈 책상. */
|
||||
noChar: boolean;
|
||||
}
|
||||
|
||||
export interface OfficeProp {
|
||||
id: string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w?: number;
|
||||
rot: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
export interface LayoutV2 {
|
||||
schema: 2;
|
||||
cells: OfficeDeskCell[];
|
||||
objs: OfficeProp[];
|
||||
}
|
||||
|
||||
const VALID_FACES = new Set<OfficeDeskCell['face']>(['L', 'R', 'U', 'D']);
|
||||
|
||||
/**
|
||||
* raw 가 valid v2 layout 이면 정규화된 LayoutV2 를, 아니면 null.
|
||||
* v1 (옛 좌표만 있는 포맷) 은 별도 `migrateLayout()` 사용.
|
||||
*/
|
||||
export function validateLayout(raw: unknown): LayoutV2 | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const r = raw as Record<string, unknown>;
|
||||
if (!Array.isArray(r.cells)) return null;
|
||||
|
||||
const isV2 = r.schema === 2 || r.cells.some(
|
||||
(c) =>
|
||||
c && typeof c === 'object' &&
|
||||
(typeof (c as Record<string, unknown>).deskSprite === 'string'
|
||||
|| typeof (c as Record<string, unknown>).agentKey === 'string'
|
||||
|| typeof (c as Record<string, unknown>).charRow === 'number'),
|
||||
);
|
||||
if (!isV2) return null;
|
||||
|
||||
const cells = r.cells.map((c) => _normalizeCell(c)).filter((c): c is OfficeDeskCell => c !== null);
|
||||
const objsRaw = Array.isArray(r.objs) ? r.objs : [];
|
||||
const objs = objsRaw.map((o) => _normalizeProp(o)).filter((o): o is OfficeProp => o !== null);
|
||||
|
||||
return { schema: 2, cells, objs };
|
||||
}
|
||||
|
||||
/**
|
||||
* v1 (옛 좌표 패치 포맷) → v2 (전체 station 정의) 마이그레이션. v1 은 좌표만 갖고 있어
|
||||
* default station 의 나머지 필드(charRow, deskSprite 등)를 채워줘야 한다. webview 의
|
||||
* default station 매핑이 함께 주어져야 정확. 없으면 best-effort.
|
||||
*
|
||||
* 이번 세션 stub: v1 입력이 들어오면 v2 shape 으로 일단 변환 (default 필드는 0/빈 값).
|
||||
* 다음 세션에서 default station 룩업과 결합.
|
||||
*/
|
||||
export function migrateLayout(raw: unknown): LayoutV2 | null {
|
||||
const asV2 = validateLayout(raw);
|
||||
if (asV2) return asV2;
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const r = raw as Record<string, unknown>;
|
||||
if (!Array.isArray(r.cells)) return null;
|
||||
|
||||
const cells: OfficeDeskCell[] = r.cells
|
||||
.map((cRaw) => {
|
||||
if (!cRaw || typeof cRaw !== 'object') return null;
|
||||
const c = cRaw as Record<string, unknown>;
|
||||
if (typeof c.roleKey !== 'string') return null;
|
||||
return _normalizeCell({
|
||||
...c,
|
||||
// v1 은 charRow / deskSprite 등이 없으니 안전한 기본값.
|
||||
charRow: 0,
|
||||
deskSprite: 'desk-main',
|
||||
face: 'R',
|
||||
boss: false,
|
||||
agentKey: c.roleKey, // 옛 키가 곧 agent 였음.
|
||||
label: c.roleKey,
|
||||
noChar: false,
|
||||
});
|
||||
})
|
||||
.filter((c): c is OfficeDeskCell => c !== null);
|
||||
|
||||
const objs = Array.isArray(r.objs)
|
||||
? r.objs.map((o) => _normalizeProp(o)).filter((o): o is OfficeProp => o !== null)
|
||||
: [];
|
||||
|
||||
return { schema: 2, cells, objs };
|
||||
}
|
||||
|
||||
function _normalizeCell(raw: unknown): OfficeDeskCell | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const c = raw as Record<string, unknown>;
|
||||
if (typeof c.roleKey !== 'string' || !c.roleKey) return null;
|
||||
const face = typeof c.face === 'string' && (VALID_FACES as ReadonlySet<string>).has(c.face)
|
||||
? (c.face as OfficeDeskCell['face'])
|
||||
: 'R';
|
||||
return {
|
||||
roleKey: c.roleKey,
|
||||
agentKey: typeof c.agentKey === 'string' ? c.agentKey : '',
|
||||
label: typeof c.label === 'string' ? c.label : c.roleKey,
|
||||
charRow: _num(c.charRow, 0),
|
||||
deskSprite: typeof c.deskSprite === 'string' ? c.deskSprite : 'desk-main',
|
||||
face,
|
||||
boss: !!c.boss,
|
||||
dock: _pair(c.dock),
|
||||
roam: Array.isArray(c.roam)
|
||||
? (c.roam.map(_pair).filter(Boolean) as Array<[number, number]>)
|
||||
: undefined,
|
||||
deskX: _num(c.deskX, 0),
|
||||
deskY: _num(c.deskY, 0),
|
||||
deskW: _num(c.deskW, 112),
|
||||
deskRot: _num(c.deskRot, 0),
|
||||
deskZ: _num(c.deskZ, 0),
|
||||
seatX: _num(c.seatX, 0),
|
||||
seatY: _num(c.seatY, 0),
|
||||
charRot: _num(c.charRot, 0),
|
||||
charZ: _num(c.charZ, 0),
|
||||
noChar: !!c.noChar,
|
||||
};
|
||||
}
|
||||
|
||||
function _normalizeProp(raw: unknown): OfficeProp | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const o = raw as Record<string, unknown>;
|
||||
if (typeof o.name !== 'string') return null;
|
||||
return {
|
||||
id: typeof o.id === 'string' ? o.id : `obj_${Math.random().toString(36).slice(2, 8)}`,
|
||||
name: o.name,
|
||||
x: _num(o.x, 0),
|
||||
y: _num(o.y, 0),
|
||||
w: typeof o.w === 'number' ? o.w : undefined,
|
||||
rot: _num(o.rot, 0),
|
||||
z: _num(o.z, 0),
|
||||
};
|
||||
}
|
||||
|
||||
function _num(v: unknown, fallback: number): number {
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : fallback;
|
||||
}
|
||||
|
||||
function _pair(v: unknown): [number, number] | undefined {
|
||||
if (!Array.isArray(v) || v.length !== 2) return undefined;
|
||||
const a = typeof v[0] === 'number' ? v[0] : NaN;
|
||||
const b = typeof v[1] === 'number' ? v[1] : NaN;
|
||||
if (!Number.isFinite(a) || !Number.isFinite(b)) return undefined;
|
||||
return [a, b];
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// 자동 분리: src/sidebarProvider.ts 3984-4001 에서 추출. 동작 동등.
|
||||
export const OFFICE_BODY = `
|
||||
<body>
|
||||
<header><div><div class="h-title">🏢 ASTRA OFFICE</div><div class="h-sub" id="agent">Astra</div></div><div style="display:flex;gap:8px;align-items:center;"><button id="editBtn" class="edit-btn" title="배치 편집 모드 토글">✏️ 편집</button><div class="status" id="status">idle</div></div></header>
|
||||
<div id="miniMap" class="mini-map" style="display:none;"></div>
|
||||
<div id="editToolbar" class="edit-toolbar" style="display:none;">
|
||||
<span class="et-hint">드래그로 이동 · <b>R</b> 회전 · <b>]</b>/<b>[</b> 레이어 · 4px snap</span>
|
||||
<button id="addDeskBtn" class="add" title="책상 추가">+ 책상</button>
|
||||
<button id="addPropBtn" class="add" title="프랍(소품) 추가">+ 프랍</button>
|
||||
<button id="deleteSelBtn" class="del" title="선택 항목 삭제" disabled>🗑 삭제</button>
|
||||
<button id="layerUpBtn" title="레이어 위로 (])">⬆</button>
|
||||
<button id="layerDownBtn" title="레이어 아래로 ([)">⬇</button>
|
||||
<button id="saveBtn">💾 저장</button>
|
||||
<button id="resetBtn" title="기본 배치로 복귀">↻ 디폴트</button>
|
||||
<button id="cancelBtn" title="저장 안 하고 종료">✕ 취소</button>
|
||||
</div>
|
||||
<div class="strip"><span><b>작업</b> <span id="task">—</span></span><span><b>단계</b> <span id="step">—</span></span></div>
|
||||
<main class="office"><div class="stage" id="stage"><div class="wall-window w1"></div><div class="wall-window w2"></div></div><div id="propPanel" class="prop-panel"></div></main>
|
||||
<div id="ticker" class="ticker" style="display:none;"><div class="tk-track" id="tickerTrack"></div></div>
|
||||
<footer><div class="progress"><div class="bar" id="bar"></div></div><div id="log">—</div></footer>
|
||||
`;
|
||||
@@ -0,0 +1,121 @@
|
||||
// 자동 분리: src/sidebarProvider.ts 3866-3982 에서 추출. 동작 동등.
|
||||
// design doc: docs/ASTRA_OFFICE_REFACTOR.md
|
||||
export const OFFICE_CSS = `
|
||||
<style>
|
||||
:root{--bg:#0E1019;--wall:#202536;--floor:#302634;--floor2:#281F2C;--text:#F1F4FB;--muted:#A8B0C7;--accent:#7C83FF;}
|
||||
*{box-sizing:border-box} body{margin:0;height:100vh;background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;display:flex;flex-direction:column;overflow:hidden}
|
||||
header{display:flex;justify-content:space-between;align-items:center;padding:10px 16px;background:rgba(0,0,0,.22);border-bottom:1px solid rgba(255,255,255,.08)}
|
||||
.h-title{font-weight:800}.h-sub{font-size:11px;color:var(--muted)}.status{font-size:12px;padding:4px 10px;border:1px solid rgba(255,255,255,.18);border-radius:999px}
|
||||
.strip{display:flex;gap:16px;padding:8px 16px;font-size:12px;color:var(--muted);border-bottom:1px solid rgba(255,255,255,.06)}.strip b{color:var(--text)}
|
||||
.office{position:relative;flex:1;overflow:hidden;display:flex;align-items:center;justify-content:center;background:linear-gradient(180deg,#21283a 0 16%,transparent 16%),radial-gradient(ellipse at 50% 0%,rgba(124,131,255,.12),transparent 42%),linear-gradient(135deg,#322835,#271f2a)}
|
||||
.office:before{content:'';position:absolute;left:0;right:0;top:16%;bottom:0;background-image:linear-gradient(rgba(255,255,255,.028) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.028) 1px,transparent 1px);background-size:48px 48px}
|
||||
.office:after{content:'';position:absolute;left:0;right:0;top:15.5%;height:8px;background:linear-gradient(180deg,rgba(0,0,0,.36),transparent)}
|
||||
.stage{position:relative;width:720px;height:585px;margin:0}
|
||||
.wall-window{position:absolute;top:16px;width:86px;height:42px;border:3px solid rgba(206,223,255,.35);background:linear-gradient(180deg,rgba(160,208,255,.3),rgba(110,150,210,.1));box-shadow:inset 0 0 0 2px rgba(15,20,31,.55)}
|
||||
.wall-window.w1{left:84px}.wall-window.w2{left:550px}
|
||||
.obj,.desk,.char{position:absolute;image-rendering:pixelated}
|
||||
.obj{filter:drop-shadow(3px 4px 0 rgba(0,0,0,.28));z-index:4}
|
||||
.desk{width:112px;z-index:5;filter:drop-shadow(4px 5px 0 rgba(0,0,0,.32))}.desk.boss{width:136px}.label{position:absolute;left:50%;bottom:-10px;transform:translateX(-50%);font-size:10px;color:rgba(241,244,251,.78);white-space:nowrap;text-shadow:1px 1px #000}
|
||||
.char{width:56px;height:72px;z-index:7;transition:left 1.17s cubic-bezier(.2,.7,.2,1),top 1.17s cubic-bezier(.2,.7,.2,1)}.char.walking{z-index:14}.char img{position:absolute;left:0;bottom:0;max-width:100%;max-height:100%;image-rendering:pixelated;filter:drop-shadow(2px 2px 0 rgba(0,0,0,.45));transform-origin:center bottom}
|
||||
.char.active:before{content:'';position:absolute;left:24px;top:-10px;width:8px;height:8px;background:var(--role-color,var(--accent));box-shadow:0 0 12px var(--role-color,var(--accent));animation:po-pulse 1.6s ease-in-out infinite}
|
||||
@keyframes po-pulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(1.5);opacity:.6}}
|
||||
/* ── C. 직군별 페르소나 컬러 ── 책상 outline 가벼운 강조, 활성 캐릭터 위 점이 직군색.
|
||||
data-role attribute로 자동 매핑. 사용자가 PNG sprite로 swap해도 컬러는 유지. */
|
||||
.char[data-agent="ceo"],.desk[data-agent="ceo"] {--role-color:#A78BFA}
|
||||
.char[data-agent="planner"],.desk[data-agent="planner"] {--role-color:#60A5FA}
|
||||
.char[data-agent="researcher"],.desk[data-agent="researcher"] {--role-color:#10B981}
|
||||
.char[data-agent="designer"],.desk[data-agent="designer"] {--role-color:#F472B6}
|
||||
.char[data-agent="developer"],.desk[data-agent="developer"] {--role-color:#FBBF24}
|
||||
.char[data-agent="qa"],.desk[data-agent="qa"] {--role-color:#22D3EE}
|
||||
.char[data-agent="inspector"],.desk[data-agent="inspector"] {--role-color:#FB923C}
|
||||
.char[data-agent="support"],.desk[data-agent="support"] {--role-color:#94A3B8}
|
||||
.char.active::after{content:'';position:absolute;left:0;right:0;bottom:-4px;height:3px;background:var(--role-color,var(--accent));box-shadow:0 0 8px var(--role-color,var(--accent));border-radius:2px;animation:po-glow 1.6s ease-in-out infinite}
|
||||
@keyframes po-glow{0%,100%{opacity:.7}50%{opacity:1}}
|
||||
/* desk 는 line 3878 의 .obj,.desk,.char{position:absolute} 를 그대로 유지해야 한다.
|
||||
과거 .desk{position:relative} 가 cascade로 override 되어, 새로 추가한 책상이 normal-flow Y
|
||||
에 따라 stage 바깥으로 밀려나던 버그가 있었음. ::after pseudo 는 absolute parent 기준으로도 정상 동작. */
|
||||
.desk::after{content:'';position:absolute;inset:-2px;border-radius:4px;border:2px solid transparent;pointer-events:none;transition:border-color .3s}
|
||||
.stage:has(.char.active[data-agent="ceo"]) .desk[data-agent="ceo"]::after,
|
||||
.stage:has(.char.active[data-agent="planner"]) .desk[data-agent="planner"]::after,
|
||||
.stage:has(.char.active[data-agent="researcher"]) .desk[data-agent="researcher"]::after,
|
||||
.stage:has(.char.active[data-agent="designer"]) .desk[data-agent="designer"]::after,
|
||||
.stage:has(.char.active[data-agent="developer"]) .desk[data-agent="developer"]::after,
|
||||
.stage:has(.char.active[data-agent="qa"]) .desk[data-agent="qa"]::after,
|
||||
.stage:has(.char.active[data-agent="inspector"]) .desk[data-agent="inspector"]::after,
|
||||
.stage:has(.char.active[data-agent="support"]) .desk[data-agent="support"]::after
|
||||
{border-color:var(--role-color)}
|
||||
.shadow{position:absolute;left:12px;bottom:0;width:28px;height:7px;background:radial-gradient(ellipse,rgba(0,0,0,.55),transparent 70%)}
|
||||
.bubble{position:absolute;z-index:20;transform:translate(-50%,-100%);background:#fff;color:#222;padding:5px 8px;border-radius:8px;font-size:11px;box-shadow:2px 2px 0 rgba(0,0,0,.35);white-space:nowrap}
|
||||
.edit-btn{background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.2);color:#F1F4FB;padding:4px 10px;border-radius:5px;cursor:pointer;font-size:11px}.edit-btn:hover{background:rgba(99,102,241,.25);border-color:#6366F1}
|
||||
/* ── B. 워크플로우 미니 맵 ── 헤더 아래 dot strip. 각 dot이 stage 하나. 완료=
|
||||
채워진 점, 활성=링 펄스, 대기=빈 점. 호버 시 라벨 표시. */
|
||||
.mini-map{display:flex;gap:5px;align-items:center;padding:7px 16px;background:rgba(0,0,0,.3);border-bottom:1px solid rgba(255,255,255,.06);overflow-x:auto;scrollbar-width:none}.mini-map::-webkit-scrollbar{display:none}
|
||||
.mini-map .mm-dot{position:relative;width:10px;height:10px;border-radius:50%;background:rgba(255,255,255,.12);border:1.5px solid rgba(255,255,255,.18);flex-shrink:0;cursor:default;transition:all .25s}
|
||||
.mini-map .mm-dot[data-status="done"]{background:#10B981;border-color:#10B981;box-shadow:0 0 4px rgba(16,185,129,.5)}
|
||||
.mini-map .mm-dot[data-status="active"]{background:var(--accent);border-color:var(--accent);width:14px;height:14px;box-shadow:0 0 0 3px rgba(99,102,241,.3);animation:mm-pulse 1.4s ease-in-out infinite}
|
||||
@keyframes mm-pulse{0%,100%{box-shadow:0 0 0 3px rgba(99,102,241,.3)}50%{box-shadow:0 0 0 6px rgba(99,102,241,.15)}}
|
||||
.mini-map .mm-bar{flex:1;height:1px;background:linear-gradient(90deg,rgba(255,255,255,.08),rgba(255,255,255,.16))}
|
||||
.mini-map .mm-label{position:absolute;left:50%;top:-22px;transform:translateX(-50%);font-size:10px;color:#F1F4FB;background:rgba(0,0,0,.85);padding:2px 6px;border-radius:3px;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .15s;z-index:50}
|
||||
.mini-map .mm-dot:hover .mm-label{opacity:1}
|
||||
.mini-map .mm-counter{flex-shrink:0;font-size:10px;color:#94A3B8;margin-left:8px;white-space:nowrap}
|
||||
/* ── E. Activity Ticker ── action-tag executor 결과를 하단 strip으로 흘림.
|
||||
사용자가 에이전트의 *실제 행동*(파일 쓰기, 명령 실행)을 실시간으로 보며 신뢰. */
|
||||
.ticker{position:relative;padding:5px 16px;background:rgba(99,102,241,.08);border-top:1px solid rgba(99,102,241,.18);overflow:hidden;font-size:11px;font-family:ui-monospace,monospace;height:24px}
|
||||
.tk-track{display:flex;gap:18px;white-space:nowrap;animation:tk-roll 22s linear infinite;will-change:transform}
|
||||
.ticker:hover .tk-track{animation-play-state:paused}
|
||||
.tk-item{flex-shrink:0;color:#D7DBEA}
|
||||
.tk-item.tk-ok{color:#10B981}
|
||||
.tk-item.tk-warn{color:#F5C518}
|
||||
.tk-item.tk-err{color:#EF4444}
|
||||
.tk-item .tk-agent{color:#A78BFA;margin-right:5px;font-weight:600}
|
||||
@keyframes tk-roll{from{transform:translateX(0)}to{transform:translateX(-50%)}}
|
||||
/* ── D. 캐릭터 컨텍스트 메뉴 ── 편집 모드 X일 때 캐릭터 클릭하면 작은 메뉴 popup.
|
||||
현재 turn 제어 + 최근 활동 보기. */
|
||||
.ctx-menu{position:fixed;z-index:1000;background:#13162A;border:1px solid #2A2E3F;border-radius:6px;box-shadow:0 8px 24px rgba(0,0,0,.6);padding:4px;min-width:170px;font-size:12px;color:#F1F4FB}
|
||||
.ctx-menu-head{padding:6px 10px 4px;font-size:10px;color:#94A3B8;border-bottom:1px solid rgba(255,255,255,.08);margin-bottom:4px}
|
||||
.ctx-menu-head .cmh-role{color:var(--role-color,#A78BFA);font-weight:700;text-transform:uppercase}
|
||||
.ctx-menu-item{display:flex;align-items:center;gap:8px;padding:7px 10px;cursor:pointer;border-radius:4px;transition:background .12s}
|
||||
.ctx-menu-item:hover{background:rgba(99,102,241,.18)}
|
||||
.ctx-menu-item.danger:hover{background:rgba(239,68,68,.18);color:#FCA5A5}
|
||||
.ctx-menu-divider{height:1px;background:rgba(255,255,255,.08);margin:3px 4px}
|
||||
body[data-edit-mode="true"] .ctx-menu{display:none!important}
|
||||
body:not([data-edit-mode="true"]) .char{cursor:pointer}
|
||||
.ctx-detail{position:fixed;z-index:1001;background:#13162A;border:1px solid #2A2E3F;border-radius:8px;box-shadow:0 12px 36px rgba(0,0,0,.7);padding:16px 18px;color:#F1F4FB;min-width:320px;max-width:520px;max-height:60vh;overflow-y:auto;font-size:12px;line-height:1.5}
|
||||
.ctx-detail h3{margin:0 0 8px;font-size:14px;color:var(--role-color,#A78BFA);text-transform:uppercase;letter-spacing:.04em}
|
||||
.ctx-detail .cd-close{position:absolute;top:8px;right:10px;background:transparent;border:none;color:#94A3B8;font-size:16px;cursor:pointer}
|
||||
.ctx-detail dl{margin:0;display:grid;grid-template-columns:auto 1fr;gap:4px 14px}
|
||||
.ctx-detail dt{color:#94A3B8;font-weight:600;white-space:nowrap}
|
||||
.ctx-detail dd{margin:0;color:#F1F4FB;overflow-wrap:anywhere}
|
||||
.ctx-detail .cd-logs{margin-top:10px;padding:6px 8px;background:rgba(0,0,0,.3);border-radius:4px;font-family:ui-monospace,monospace;font-size:10.5px;max-height:120px;overflow-y:auto}
|
||||
.edit-toolbar{display:flex;gap:8px;align-items:center;padding:6px 16px;background:rgba(99,102,241,.18);border-bottom:1px solid rgba(99,102,241,.4);font-size:11px;flex-wrap:wrap}.edit-toolbar .et-hint{flex:1;color:#D7DBEA;min-width:160px}.edit-toolbar button{background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.25);color:#F1F4FB;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:11px}.edit-toolbar button:hover{background:rgba(99,102,241,.35)}
|
||||
.edit-toolbar button.add{background:rgba(16,185,129,.22);border-color:rgba(16,185,129,.55)}.edit-toolbar button.add:hover{background:rgba(16,185,129,.4)}
|
||||
.edit-toolbar button.del{background:rgba(239,68,68,.22);border-color:rgba(239,68,68,.55)}.edit-toolbar button.del:hover{background:rgba(239,68,68,.4)}.edit-toolbar button[disabled]{opacity:.4;cursor:not-allowed}
|
||||
/* 선택된 item 의 속성 편집 패널 — 우측 슬라이드 */
|
||||
.prop-panel{position:absolute;right:12px;top:90px;width:240px;background:#13162A;border:1px solid #2A2E3F;border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.6);padding:12px;font-size:11px;color:#F1F4FB;z-index:25;display:none}
|
||||
.prop-panel.show{display:block}
|
||||
.prop-panel h4{margin:0 0 8px;font-size:12px;color:#A78BFA;text-transform:uppercase;letter-spacing:.04em}
|
||||
.prop-panel .pp-row{margin-bottom:8px}
|
||||
.prop-panel label{display:block;font-size:10px;color:#94A3B8;margin-bottom:2px}
|
||||
.prop-panel select,.prop-panel input{width:100%;background:#0c1020;color:#F1F4FB;border:1px solid #2A2E3F;border-radius:4px;padding:3px 6px;font-size:11px}
|
||||
.prop-panel .pp-thumbs{display:grid;grid-template-columns:repeat(4,1fr);gap:4px;margin-top:4px}
|
||||
.prop-panel .pp-thumb{width:100%;aspect-ratio:1/1;background:#0c1020;border:1px solid #2A2E3F;border-radius:3px;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:2px}
|
||||
.prop-panel .pp-thumb img{max-width:100%;max-height:100%;image-rendering:pixelated}
|
||||
.prop-panel .pp-thumb.active{border-color:#A78BFA;box-shadow:0 0 0 2px rgba(167,139,250,.35)}
|
||||
/* 프랍 추가 picker — 모달 grid */
|
||||
.prop-picker{position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:1100;display:flex;align-items:center;justify-content:center}
|
||||
.prop-picker-box{background:#13162A;border:1px solid #2A2E3F;border-radius:10px;padding:14px;max-width:520px;max-height:80vh;overflow-y:auto;color:#F1F4FB}
|
||||
.prop-picker-box h3{margin:0 0 10px;font-size:13px;color:#A78BFA}
|
||||
.prop-picker-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px}
|
||||
.prop-pick{background:#0c1020;border:1px solid #2A2E3F;border-radius:4px;padding:6px;cursor:pointer;text-align:center}
|
||||
.prop-pick:hover{border-color:#A78BFA}
|
||||
.prop-pick img{max-width:60px;max-height:60px;image-rendering:pixelated}
|
||||
.prop-pick .pp-name{font-size:10px;color:#94A3B8;margin-top:4px;word-break:break-all}
|
||||
/* 편집 모드 — 드래그 가능 요소 강조 */
|
||||
body[data-edit-mode="true"] .stage{background-image:linear-gradient(rgba(99,102,241,.15) 1px,transparent 1px),linear-gradient(90deg,rgba(99,102,241,.15) 1px,transparent 1px);background-size:32px 32px}
|
||||
body[data-edit-mode="true"] .desk,body[data-edit-mode="true"] .char,body[data-edit-mode="true"] .obj{cursor:grab;outline:1px dashed rgba(99,102,241,.5)}
|
||||
body[data-edit-mode="true"] .desk:hover,body[data-edit-mode="true"] .char:hover,body[data-edit-mode="true"] .obj:hover{outline:2px solid #6366F1;z-index:30}
|
||||
body[data-edit-mode="true"] .dragging{cursor:grabbing!important;opacity:.7;outline:2px solid #FB923C!important;z-index:40}
|
||||
body[data-edit-mode="true"] .selected{outline:2px solid #F472B6!important;box-shadow:0 0 0 4px rgba(244,114,182,.25);z-index:35}
|
||||
body[data-edit-mode="true"] .char .shadow{display:none}
|
||||
footer{padding:8px 16px 12px;border-top:1px solid rgba(255,255,255,.08);background:rgba(0,0,0,.25);font-size:11px;color:var(--muted)}.progress{height:5px;background:rgba(255,255,255,.08);margin-bottom:6px}.bar{height:100%;width:0;background:var(--accent);transition:width .25s}
|
||||
`;
|
||||
@@ -0,0 +1,26 @@
|
||||
// Full Astra Office webview HTML composition.
|
||||
// 옛 sidebarProvider.ts 의 거대한 _pixelOfficePanelHtml 을 4개 파일로 분리한 entry.
|
||||
// 이번 세션은 *동작 동등 분리* 만. 다음 세션에 mini view 와 공통 presenter 도입.
|
||||
|
||||
import { OFFICE_CSS } from './officeStyles';
|
||||
import { OFFICE_BODY } from './officeBody';
|
||||
import { officeRuntimeJs } from './runtime';
|
||||
|
||||
export interface AstraOfficePanelAssets {
|
||||
cspSource: string;
|
||||
derivedBase: string;
|
||||
}
|
||||
|
||||
export function renderAstraOfficePanelHtml(assets: AstraOfficePanelAssets): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${assets.cspSource} data:; style-src 'unsafe-inline'; script-src 'unsafe-inline';" />
|
||||
<style>
|
||||
${OFFICE_CSS}
|
||||
</style></head>
|
||||
<body>
|
||||
${OFFICE_BODY}
|
||||
${officeRuntimeJs(assets.derivedBase)}</body></html>`;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Google Calendar API v3 — event create/list 호출.
|
||||
*
|
||||
* access token 은 caller 가 직접 주입한다. 만료 처리는 `withFreshAccessToken`
|
||||
* 헬퍼가 refresh token 으로 갱신 → 호출 → 401 발생 시 한 번 더 갱신 + 재시도.
|
||||
*
|
||||
* 외부 라이브러리(googleapis) 안 씀 — Calendar API 는 REST 라 native fetch 면 충분.
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { refreshAccessToken } from './oauth';
|
||||
import { readCalendarConfig, writeCalendarConfig } from './calendarCache';
|
||||
|
||||
const API_BASE = 'https://www.googleapis.com/calendar/v3';
|
||||
|
||||
export interface CalendarEventInput {
|
||||
/** 일정 제목 (필수). */
|
||||
title: string;
|
||||
/** ISO 시작 시각 — 'YYYY-MM-DDTHH:MM' (로컬) 또는 'YYYY-MM-DDTHH:MM:SS±HH:MM' (timezone 포함). */
|
||||
start: string;
|
||||
/** ISO 종료 시각. 없으면 duration(분) 으로부터 계산. duration 도 없으면 60분. */
|
||||
end?: string;
|
||||
/** end 없을 때 시작부터 이만큼 (분 단위, default 60). */
|
||||
durationMinutes?: number;
|
||||
description?: string;
|
||||
location?: string;
|
||||
/** all-day 일정 여부 — true 면 start 는 'YYYY-MM-DD' 만 받음. */
|
||||
allDay?: boolean;
|
||||
}
|
||||
|
||||
export interface CreatedEvent {
|
||||
/** Google 이 발급한 event id. */
|
||||
id: string;
|
||||
/** Google Calendar 웹에서 열 수 있는 URL. */
|
||||
htmlLink: string;
|
||||
/** API 가 echo 해준 시작 시각. */
|
||||
startIso: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 생성. config 에 refresh token 이 있어야 함. access token 자동 갱신.
|
||||
*
|
||||
* 반환값:
|
||||
* ok: true → CreatedEvent
|
||||
* ok: false → 에러 메시지 (UI 표시용)
|
||||
*/
|
||||
export async function createCalendarEvent(
|
||||
context: vscode.ExtensionContext,
|
||||
input: CalendarEventInput,
|
||||
): Promise<{ ok: true; event: CreatedEvent } | { ok: false; error: string }> {
|
||||
const cfg = readCalendarConfig(context);
|
||||
const tokenResult = await _getFreshAccessToken(context);
|
||||
if (!tokenResult.ok) return { ok: false, error: tokenResult.error };
|
||||
|
||||
const body = _buildEventBody(input, cfg.defaultDurationMinutes ?? 60);
|
||||
if (!body.ok) return { ok: false, error: body.error };
|
||||
|
||||
const calId = (cfg.calendarId || 'primary').trim() || 'primary';
|
||||
const url = `${API_BASE}/calendars/${encodeURIComponent(calId)}/events`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenResult.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body.event),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
const json: any = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const msg = json?.error?.message || `HTTP ${res.status}`;
|
||||
return { ok: false, error: msg };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
event: {
|
||||
id: json.id,
|
||||
htmlLink: json.htmlLink,
|
||||
startIso: json.start?.dateTime ?? json.start?.date ?? input.start,
|
||||
title: input.title,
|
||||
},
|
||||
};
|
||||
} catch (e: any) {
|
||||
return { ok: false, error: e?.message ?? String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
/** Calendar API 요청 body 빌더 — 단위테스트 가능하도록 분리. */
|
||||
export function _buildEventBody(
|
||||
input: CalendarEventInput,
|
||||
fallbackDurationMin: number,
|
||||
): { ok: true; event: any } | { ok: false; error: string } {
|
||||
if (!input.title || !input.title.trim()) return { ok: false, error: 'title 비어있음' };
|
||||
if (!input.start || !input.start.trim()) return { ok: false, error: 'start 비어있음' };
|
||||
|
||||
if (input.allDay) {
|
||||
// all-day: date 형식만 (YYYY-MM-DD). end 는 exclusive 라 다음 날.
|
||||
const startDate = input.start.slice(0, 10);
|
||||
const endDate = input.end ? input.end.slice(0, 10) : _addDaysDate(startDate, 1);
|
||||
return {
|
||||
ok: true,
|
||||
event: {
|
||||
summary: input.title.trim(),
|
||||
description: input.description || undefined,
|
||||
location: input.location || undefined,
|
||||
start: { date: startDate },
|
||||
end: { date: endDate },
|
||||
reminders: { useDefault: true },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let endIso: string | undefined = input.end;
|
||||
if (!endIso) {
|
||||
const dur = (input.durationMinutes && input.durationMinutes > 0) ? input.durationMinutes : fallbackDurationMin;
|
||||
const computed = _addMinutesIso(input.start, dur);
|
||||
if (!computed) return { ok: false, error: `start 시각 형식 오류: ${input.start}` };
|
||||
endIso = computed;
|
||||
}
|
||||
|
||||
// Google Calendar 는 timezone 정보가 없으면 timeZone 필드 별도 필요.
|
||||
// 'YYYY-MM-DDTHH:MM' 처럼 timezone 빠진 입력은 OS 로컬 timezone 으로 가정.
|
||||
const hasOffset = /([+-]\d{2}:\d{2}|Z)$/.test(input.start);
|
||||
const timeZone = hasOffset ? undefined : Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
event: {
|
||||
summary: input.title.trim(),
|
||||
description: input.description || undefined,
|
||||
location: input.location || undefined,
|
||||
start: { dateTime: input.start, ...(timeZone ? { timeZone } : {}) },
|
||||
end: { dateTime: endIso, ...(timeZone ? { timeZone } : {}) },
|
||||
reminders: {
|
||||
useDefault: false,
|
||||
overrides: [
|
||||
{ method: 'popup', minutes: 5 },
|
||||
{ method: 'popup', minutes: 60 },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 'YYYY-MM-DDTHH:MM[:SS][±HH:MM|Z]' 에 분 더해 ISO 반환. 잘못된 형식이면 null. */
|
||||
export function _addMinutesIso(startIso: string, minutes: number): string | null {
|
||||
// 안전한 파싱: 명시적 정규식 → Date → ISO 재조립. timezone 정보 보존.
|
||||
const m = startIso.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})(:\d{2})?([+-]\d{2}:\d{2}|Z)?$/);
|
||||
if (!m) return null;
|
||||
const [, base, sec, tz] = m;
|
||||
const full = `${base}${sec ?? ':00'}${tz ?? ''}`;
|
||||
const t = new Date(full);
|
||||
if (Number.isNaN(t.getTime())) return null;
|
||||
const out = new Date(t.getTime() + minutes * 60 * 1000);
|
||||
// 원본이 timezone 정보 없는 로컬 시각이면 같은 포맷으로 돌려준다.
|
||||
if (!tz) {
|
||||
const yy = out.getFullYear();
|
||||
const MM = String(out.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(out.getDate()).padStart(2, '0');
|
||||
const hh = String(out.getHours()).padStart(2, '0');
|
||||
const mm = String(out.getMinutes()).padStart(2, '0');
|
||||
const ss = String(out.getSeconds()).padStart(2, '0');
|
||||
return `${yy}-${MM}-${dd}T${hh}:${mm}:${ss}`;
|
||||
}
|
||||
return out.toISOString();
|
||||
}
|
||||
|
||||
/** 'YYYY-MM-DD' + N 일. all-day 일정 end 계산용. */
|
||||
export function _addDaysDate(yyyymmdd: string, days: number): string {
|
||||
const m = yyyymmdd.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!m) return yyyymmdd;
|
||||
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||
d.setDate(d.getDate() + days);
|
||||
const yy = d.getFullYear(), MM = String(d.getMonth() + 1).padStart(2, '0'), dd = String(d.getDate()).padStart(2, '0');
|
||||
return `${yy}-${MM}-${dd}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 access token 이 유효하면 그대로, 아니면 refresh. config 의 만료 시각 사용.
|
||||
* 갱신 후 만료 시각도 config 에 기록 — 다음 호출 때 불필요한 갱신 방지.
|
||||
*
|
||||
* Calendar / Sheets API 양쪽이 같은 token 을 공유한다 (scope 가 모두 같은 OAuth 에 포함).
|
||||
* 그래서 `_` prefix 떼고 export — Sheets API client 가 직접 호출.
|
||||
*/
|
||||
export async function getFreshAccessToken(
|
||||
context: vscode.ExtensionContext,
|
||||
): Promise<{ ok: true; accessToken: string } | { ok: false; error: string }> {
|
||||
const cfg = readCalendarConfig(context);
|
||||
if (!cfg.clientId || !cfg.clientSecret || !cfg.refreshToken) {
|
||||
return { ok: false, error: 'Google OAuth 가 설정되지 않았습니다. "Astra: Google Calendar OAuth 연결 (쓰기)" 명령으로 한 번 로그인하세요. (Calendar 와 Sheets 권한이 함께 발급됩니다.)' };
|
||||
}
|
||||
const now = Date.now();
|
||||
if (cfg.accessToken && cfg.accessTokenExpiresAt && cfg.accessTokenExpiresAt > now) {
|
||||
return { ok: true, accessToken: cfg.accessToken };
|
||||
}
|
||||
const r = await refreshAccessToken(cfg.clientId, cfg.clientSecret, cfg.refreshToken);
|
||||
if (!r.ok) return { ok: false, error: r.error };
|
||||
await writeCalendarConfig(context, { accessToken: r.accessToken, accessTokenExpiresAt: r.expiresAt });
|
||||
return { ok: true, accessToken: r.accessToken };
|
||||
}
|
||||
|
||||
// 내부 호출용 alias 유지 — 한 줄짜리라 비용 없음.
|
||||
const _getFreshAccessToken = getFreshAccessToken;
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Google Calendar (iCal) 캐시 — fetch + parse + 회사 _shared/calendar_cache.md 에 저장.
|
||||
*
|
||||
* Connect_origin 의 google_calendar.py 를 TypeScript / native fetch 로 옮김. OAuth 없음.
|
||||
* 사용자가 Google Calendar 설정 → "비공개 주소(iCal 형식)" 복사 → 본 모듈에 입력 한 번이면
|
||||
* 모든 agent 가 매 turn 자동으로 다가오는 일정 컨텍스트를 받는다.
|
||||
*
|
||||
* 보안:
|
||||
* - iCal URL 은 ExtensionContext.globalState 에 저장 (machine-local, git 침범 X).
|
||||
* - 캐시 파일은 회사 디렉토리 `_shared/calendar_cache.md` 에 평문 마크다운으로 저장.
|
||||
* 이 파일은 .gitignore 대상은 아니지만 일정 제목/시각이 들어있음 — 사용자가 commit
|
||||
* 안 하도록 가이드 문구를 README/명령에서 안내한다.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import { parseIcs, selectUpcoming, IcsEvent } from './icsParser';
|
||||
|
||||
/** globalState 키 — iCal URL 과 부수 설정 한 묶음. */
|
||||
export const CAL_CONFIG_KEY = 'g1nation.calendar.ical';
|
||||
|
||||
export interface CalendarConfig {
|
||||
/** Google Calendar 비공개 iCal URL. 빈 문자열이면 iCal 읽기 비활성. */
|
||||
icalUrl: string;
|
||||
/** 며칠치 미리 가져올지 (default 14). */
|
||||
daysAhead: number;
|
||||
/** 마지막 성공 fetch ISO timestamp (자동 표시용). */
|
||||
lastFetchAt?: string;
|
||||
// ── OAuth (쓰기) 관련 필드 — Google Calendar API v3 호출에 사용. ──
|
||||
// 모두 ExtensionContext.globalState 에만 저장 (machine-local). 옵션이라 비어있어도 iCal 읽기는 동작.
|
||||
/** Google Cloud Console 에서 발급한 OAuth Client ID. */
|
||||
clientId?: string;
|
||||
/** 같은 페이지의 Client Secret (Desktop app 의 secret 은 공개 가능한 식별자). */
|
||||
clientSecret?: string;
|
||||
/** OAuth 로 받은 refresh token — 진짜 비밀. machine-local. */
|
||||
refreshToken?: string;
|
||||
/** Calendar API 가 쓰는 캘린더 식별자 — 'primary' 또는 특정 calendarId. */
|
||||
calendarId?: string;
|
||||
/** end 없는 이벤트 default 길이 (분). */
|
||||
defaultDurationMinutes?: number;
|
||||
/** 캐시된 access token (만료 전까지 재사용). */
|
||||
accessToken?: string;
|
||||
/** access token 만료 epoch ms. */
|
||||
accessTokenExpiresAt?: number;
|
||||
/** 연결된 Google 계정 이메일 (UI 표시용). */
|
||||
connectedAs?: string;
|
||||
/** OAuth 연결 시각 ISO. */
|
||||
connectedAt?: string;
|
||||
}
|
||||
|
||||
export function readCalendarConfig(context: vscode.ExtensionContext): CalendarConfig {
|
||||
const raw = context.globalState.get(CAL_CONFIG_KEY) as Partial<CalendarConfig> | undefined;
|
||||
return {
|
||||
icalUrl: typeof raw?.icalUrl === 'string' ? raw.icalUrl : '',
|
||||
daysAhead: typeof raw?.daysAhead === 'number' && raw.daysAhead > 0 ? raw.daysAhead : 14,
|
||||
lastFetchAt: typeof raw?.lastFetchAt === 'string' ? raw.lastFetchAt : undefined,
|
||||
clientId: typeof raw?.clientId === 'string' ? raw.clientId : undefined,
|
||||
clientSecret: typeof raw?.clientSecret === 'string' ? raw.clientSecret : undefined,
|
||||
refreshToken: typeof raw?.refreshToken === 'string' ? raw.refreshToken : undefined,
|
||||
calendarId: typeof raw?.calendarId === 'string' ? raw.calendarId : undefined,
|
||||
defaultDurationMinutes: typeof raw?.defaultDurationMinutes === 'number' ? raw.defaultDurationMinutes : undefined,
|
||||
accessToken: typeof raw?.accessToken === 'string' ? raw.accessToken : undefined,
|
||||
accessTokenExpiresAt: typeof raw?.accessTokenExpiresAt === 'number' ? raw.accessTokenExpiresAt : undefined,
|
||||
connectedAs: typeof raw?.connectedAs === 'string' ? raw.connectedAs : undefined,
|
||||
connectedAt: typeof raw?.connectedAt === 'string' ? raw.connectedAt : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function writeCalendarConfig(context: vscode.ExtensionContext, patch: Partial<CalendarConfig>): Promise<void> {
|
||||
const cur = readCalendarConfig(context);
|
||||
const next: CalendarConfig = { ...cur, ...patch };
|
||||
await context.globalState.update(CAL_CONFIG_KEY, next);
|
||||
}
|
||||
|
||||
/** 회사 디렉토리 내부 캐시 파일 경로. workspace 없으면 globalStorage 로 fallback. */
|
||||
function _cachePath(context: vscode.ExtensionContext): string {
|
||||
const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
if (ws) return path.join(ws, '.astra', 'company', '_shared', 'calendar_cache.md');
|
||||
return path.join(context.globalStorageUri.fsPath, 'company', '_shared', 'calendar_cache.md');
|
||||
}
|
||||
|
||||
export interface RefreshResult {
|
||||
ok: boolean;
|
||||
count: number;
|
||||
error?: string;
|
||||
cachePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* iCal URL 에서 fetch → ICS 파싱 → upcoming 필터 → 마크다운 캐시 파일에 쓰기.
|
||||
* URL 비어있으면 즉시 ok:false 반환 (사용자 안내는 호출자가).
|
||||
*/
|
||||
export async function refreshCalendarCache(context: vscode.ExtensionContext): Promise<RefreshResult> {
|
||||
const cfg = readCalendarConfig(context);
|
||||
const cachePath = _cachePath(context);
|
||||
if (!cfg.icalUrl) {
|
||||
return { ok: false, count: 0, error: 'iCal URL 이 설정되지 않았습니다. 명령 팔레트에서 "Astra: Google Calendar 연결" 을 먼저 실행하세요.', cachePath };
|
||||
}
|
||||
if (!/^https?:\/\//.test(cfg.icalUrl)) {
|
||||
return { ok: false, count: 0, error: 'URL 이 http:// 또는 https:// 로 시작하지 않습니다.', cachePath };
|
||||
}
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
// Node 18+ 의 native fetch 사용 — axios / node-fetch 의존성 없이.
|
||||
const res = await fetch(cfg.icalUrl, {
|
||||
method: 'GET',
|
||||
headers: { 'User-Agent': 'Astra-Calendar/1.0' },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { ok: false, count: 0, error: `HTTP ${res.status} — URL 이 잘못됐거나 만료됐을 수 있습니다.`, cachePath };
|
||||
}
|
||||
raw = await res.text();
|
||||
} catch (e: any) {
|
||||
return { ok: false, count: 0, error: `다운로드 실패: ${e?.message ?? String(e)}`, cachePath };
|
||||
}
|
||||
|
||||
const events = parseIcs(raw);
|
||||
const upcoming = selectUpcoming(events, cfg.daysAhead);
|
||||
|
||||
const now = new Date();
|
||||
const md = _renderMarkdown(upcoming, cfg.daysAhead, now);
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
||||
fs.writeFileSync(cachePath, md, 'utf8');
|
||||
} catch (e: any) {
|
||||
return { ok: false, count: 0, error: `캐시 저장 실패: ${e?.message ?? String(e)}`, cachePath };
|
||||
}
|
||||
|
||||
await writeCalendarConfig(context, { lastFetchAt: now.toISOString() });
|
||||
return { ok: true, count: upcoming.length, cachePath };
|
||||
}
|
||||
|
||||
/** Agent prompt 에 주입할 캐시 본문 읽기. 없으면 빈 문자열. */
|
||||
export function readCalendarCache(context: vscode.ExtensionContext): string {
|
||||
const cachePath = _cachePath(context);
|
||||
try {
|
||||
if (!fs.existsSync(cachePath)) return '';
|
||||
return fs.readFileSync(cachePath, 'utf8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function _renderMarkdown(events: IcsEvent[], daysAhead: number, now: Date): string {
|
||||
const tsLabel = (d: Date, allDay: boolean) => {
|
||||
const yy = d.getFullYear(), mm = String(d.getMonth() + 1).padStart(2, '0'), dd = String(d.getDate()).padStart(2, '0');
|
||||
const wk = ['일', '월', '화', '수', '목', '금', '토'][d.getDay()];
|
||||
if (allDay) return `${yy}-${mm}-${dd} (${wk})`;
|
||||
const HH = String(d.getHours()).padStart(2, '0'), MM = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${yy}-${mm}-${dd} (${wk}) ${HH}:${MM}`;
|
||||
};
|
||||
const lines: string[] = [
|
||||
'# 📅 다가오는 일정 (Google Calendar)',
|
||||
`_업데이트: ${tsLabel(now, false)} · 향후 ${daysAhead}일_`,
|
||||
'',
|
||||
];
|
||||
if (events.length === 0) {
|
||||
lines.push('_없음_');
|
||||
} else {
|
||||
for (const ev of events) {
|
||||
const ts = tsLabel(ev.start, ev.allDay);
|
||||
const loc = ev.location ? ` — 📍 ${ev.location}` : '';
|
||||
lines.push(`- **${ts}** · ${ev.summary}${loc}`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Minimal ICS parser — no library deps. Connect_origin 의 Python 버전을
|
||||
* 그대로 옮겼고, 본 함수는 *pure* 라서 단위테스트가 쉽다.
|
||||
*
|
||||
* 처리 범위:
|
||||
* - VEVENT 블록 추출
|
||||
* - line continuation (다음 줄이 공백 시작) 펼치기
|
||||
* - SUMMARY / DESCRIPTION / LOCATION / DTSTART / DTEND 필드
|
||||
* - DTSTART;VALUE=DATE → all-day 이벤트 표시
|
||||
* - YYYYMMDD / YYYYMMDDTHHMMSS / ...Z (UTC) 포맷 모두 처리
|
||||
*
|
||||
* 처리 안 함 (필요해지면 v2):
|
||||
* - RRULE 반복 일정 (단일 instance 만 표시)
|
||||
* - TZID 타임존 변환 (UTC 가 아니면 로컬로 가정)
|
||||
* - VTIMEZONE 블록
|
||||
* - 첨부 / 참석자 / 알림
|
||||
*/
|
||||
|
||||
export interface IcsEvent {
|
||||
/** 시작 시각 (로컬 Date 기준). all-day 일정은 자정. */
|
||||
start: Date;
|
||||
/** 종료 시각. 없으면 undefined. */
|
||||
end?: Date;
|
||||
/** 제목 (없으면 '(제목 없음)'). */
|
||||
summary: string;
|
||||
location: string;
|
||||
description: string;
|
||||
/** DTSTART 가 VALUE=DATE 형식이었으면 true — 시각은 무시하고 날짜만 의미. */
|
||||
allDay: boolean;
|
||||
}
|
||||
|
||||
/** 한 문자열의 ICS 본문을 받아 VEVENT 들을 배열로 반환. 잘못된 입력은 빈 배열. */
|
||||
export function parseIcs(raw: string): IcsEvent[] {
|
||||
if (!raw || typeof raw !== 'string') return [];
|
||||
// Line continuation: ICS 는 75자 wrap 시 다음 줄이 공백/탭으로 시작 → 합쳐줘야 한다.
|
||||
const unfolded = raw.replace(/\r?\n[ \t]/g, '');
|
||||
const events: IcsEvent[] = [];
|
||||
let cur: Record<string, string> | null = null;
|
||||
let curDateOnly: Record<'start' | 'end', boolean> = { start: false, end: false };
|
||||
for (const rawLine of unfolded.split('\n')) {
|
||||
const line = rawLine.replace(/\r$/, '');
|
||||
if (line === 'BEGIN:VEVENT') {
|
||||
cur = {};
|
||||
curDateOnly = { start: false, end: false };
|
||||
} else if (line === 'END:VEVENT') {
|
||||
if (cur) {
|
||||
const ev = _toEvent(cur, curDateOnly);
|
||||
if (ev) events.push(ev);
|
||||
}
|
||||
cur = null;
|
||||
} else if (cur && line.includes(':')) {
|
||||
const colonIdx = line.indexOf(':');
|
||||
const keyPart = line.slice(0, colonIdx);
|
||||
const value = line.slice(colonIdx + 1);
|
||||
const base = keyPart.split(';', 1)[0];
|
||||
if (base === 'SUMMARY' || base === 'DESCRIPTION' || base === 'LOCATION'
|
||||
|| base === 'DTSTART' || base === 'DTEND') {
|
||||
cur[base] = value;
|
||||
if ((base === 'DTSTART' || base === 'DTEND') && keyPart.includes(';VALUE=DATE')) {
|
||||
curDateOnly[base === 'DTSTART' ? 'start' : 'end'] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
/** 시작 시각 기준 오름차순 정렬 + 현재 시각 - 1시간 ~ 미래 cutoffDays 범위만 필터. */
|
||||
export function selectUpcoming(events: IcsEvent[], daysAhead: number, now: Date = new Date()): IcsEvent[] {
|
||||
const past = new Date(now.getTime() - 60 * 60 * 1000); // 1 hour ago
|
||||
const cutoff = new Date(now.getTime() + daysAhead * 24 * 60 * 60 * 1000);
|
||||
return events
|
||||
.filter((e) => e.start >= past && e.start <= cutoff)
|
||||
.sort((a, b) => a.start.getTime() - b.start.getTime());
|
||||
}
|
||||
|
||||
function _toEvent(raw: Record<string, string>, dateOnly: { start: boolean; end: boolean }): IcsEvent | null {
|
||||
const start = _parseDt(raw.DTSTART ?? '');
|
||||
if (!start) return null;
|
||||
const end = _parseDt(raw.DTEND ?? '');
|
||||
return {
|
||||
start,
|
||||
end: end ?? undefined,
|
||||
summary: _unescape(raw.SUMMARY ?? '(제목 없음)'),
|
||||
location: _unescape(raw.LOCATION ?? ''),
|
||||
description: _unescape(raw.DESCRIPTION ?? ''),
|
||||
allDay: dateOnly.start,
|
||||
};
|
||||
}
|
||||
|
||||
function _parseDt(s: string): Date | null {
|
||||
if (!s) return null;
|
||||
const trimmed = s.trim();
|
||||
// Strip trailing Z (UTC marker — Date 파싱 시 자동 처리되지 않으므로 명시 변환).
|
||||
const utc = trimmed.endsWith('Z');
|
||||
const core = utc ? trimmed.slice(0, -1) : trimmed;
|
||||
// Two valid forms: YYYYMMDDTHHMMSS or YYYYMMDD
|
||||
const m = core.match(/^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2}))?$/);
|
||||
if (!m) return null;
|
||||
const [, yy, mm, dd, HH, MM, SS] = m;
|
||||
const year = parseInt(yy, 10), month = parseInt(mm, 10) - 1, day = parseInt(dd, 10);
|
||||
const hour = HH ? parseInt(HH, 10) : 0;
|
||||
const min = MM ? parseInt(MM, 10) : 0;
|
||||
const sec = SS ? parseInt(SS, 10) : 0;
|
||||
if (utc) {
|
||||
return new Date(Date.UTC(year, month, day, hour, min, sec));
|
||||
}
|
||||
return new Date(year, month, day, hour, min, sec);
|
||||
}
|
||||
|
||||
function _unescape(s: string): string {
|
||||
// ICS literal escapes: \, \; \n \\
|
||||
return s.replace(/\\,/g, ',').replace(/\\;/g, ';').replace(/\\n/g, ' ').replace(/\\\\/g, '\\');
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export {
|
||||
parseIcs,
|
||||
selectUpcoming,
|
||||
IcsEvent,
|
||||
} from './icsParser';
|
||||
|
||||
export {
|
||||
CAL_CONFIG_KEY,
|
||||
CalendarConfig,
|
||||
readCalendarConfig,
|
||||
writeCalendarConfig,
|
||||
refreshCalendarCache,
|
||||
readCalendarCache,
|
||||
RefreshResult,
|
||||
} from './calendarCache';
|
||||
|
||||
export {
|
||||
runOAuthLoopback,
|
||||
refreshAccessToken,
|
||||
fetchUserEmail,
|
||||
OAuthResult,
|
||||
OAuthFailure,
|
||||
} from './oauth';
|
||||
|
||||
export {
|
||||
createCalendarEvent,
|
||||
CalendarEventInput,
|
||||
CreatedEvent,
|
||||
_buildEventBody,
|
||||
_addMinutesIso,
|
||||
_addDaysDate,
|
||||
} from './calendarApi';
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Google OAuth 2.0 — loopback (Desktop app) 흐름.
|
||||
*
|
||||
* Google 은 Desktop 앱 OAuth client 에 대해 http://127.0.0.1:<ephemeral_port>
|
||||
* redirect URI 를 허용한다. 본 모듈은:
|
||||
* 1. ephemeral port 에 일회용 HTTP 서버 띄움
|
||||
* 2. 사용자 브라우저로 Google 로그인 페이지 열기
|
||||
* 3. 콜백으로 code 받기
|
||||
* 4. code → access/refresh token 교환
|
||||
* 5. 서버 종료
|
||||
*
|
||||
* 보안: refresh token 은 호출자가 globalState 에 저장 (machine-local).
|
||||
* Client ID/Secret 도 같이 저장하지만, Desktop app 의 client secret 은
|
||||
* Google 가이드에 따라 *공개 가능* (혼동 방지: 진짜 비밀이 아니라 식별자).
|
||||
* refresh token 이 사실상 진짜 비밀.
|
||||
*/
|
||||
|
||||
import * as http from 'http';
|
||||
import * as crypto from 'crypto';
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
// Calendar 와 Sheets 양쪽 권한을 한 번에 요청 — 사용자가 OAuth 한 번 하면 둘 다 동작.
|
||||
// 옛 사용자(Calendar 만 연결)는 Sheets 사용 시 권한 부족 에러 → 재연결 필요.
|
||||
const SCOPE = [
|
||||
'https://www.googleapis.com/auth/calendar.events',
|
||||
'https://www.googleapis.com/auth/spreadsheets',
|
||||
'openid',
|
||||
'email',
|
||||
].join(' ');
|
||||
const TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
||||
const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
||||
|
||||
export interface OAuthResult {
|
||||
ok: true;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
/** Google 가 동의한 scope 들 (공백 구분). */
|
||||
scope: string;
|
||||
/** access token 만료 epoch ms. */
|
||||
expiresAt: number;
|
||||
}
|
||||
export interface OAuthFailure { ok: false; error: string; }
|
||||
|
||||
/**
|
||||
* 풀 OAuth flow — 사용자가 cancel 누르면 cancelToken trip → ok:false.
|
||||
*
|
||||
* @param clientId Google Cloud 의 OAuth Client ID
|
||||
* @param clientSecret 같은 페이지의 Client Secret
|
||||
* @param cancelToken VS Code Progress 의 취소 토큰
|
||||
*/
|
||||
export async function runOAuthLoopback(
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
cancelToken: vscode.CancellationToken,
|
||||
): Promise<OAuthResult | OAuthFailure> {
|
||||
return new Promise<OAuthResult | OAuthFailure>((resolve) => {
|
||||
let _settled = false;
|
||||
const settle = (v: OAuthResult | OAuthFailure) => { if (_settled) return; _settled = true; resolve(v); };
|
||||
|
||||
// CSRF 방어 — state 파라미터 검증.
|
||||
const expectedState = crypto.randomBytes(16).toString('hex');
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
||||
const code = url.searchParams.get('code');
|
||||
const err = url.searchParams.get('error');
|
||||
const stateParam = url.searchParams.get('state');
|
||||
// favicon / 빈 callback 요청은 무시 (브라우저 자동 요청)
|
||||
if (!code && !err) { res.writeHead(204); res.end(); return; }
|
||||
if (stateParam !== expectedState) {
|
||||
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end('state mismatch — possible CSRF. 다시 시도하세요.');
|
||||
server.close();
|
||||
settle({ ok: false, error: 'state mismatch' });
|
||||
return;
|
||||
}
|
||||
if (err) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(_renderHtml('실패', err, false));
|
||||
server.close();
|
||||
settle({ ok: false, error: err });
|
||||
return;
|
||||
}
|
||||
// Got the code — exchange for tokens.
|
||||
const port = (server.address() as any)?.port;
|
||||
const redirectUri = `http://127.0.0.1:${port}`;
|
||||
const body = new URLSearchParams({
|
||||
code: code!,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code',
|
||||
});
|
||||
try {
|
||||
const tokenRes = await fetch(TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
const json: any = await tokenRes.json().catch(() => ({}));
|
||||
if (!tokenRes.ok || !json.access_token || !json.refresh_token) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(_renderHtml('토큰 교환 실패', JSON.stringify(json).slice(0, 300), false));
|
||||
server.close();
|
||||
settle({ ok: false, error: json.error_description || json.error || 'no refresh_token in response' });
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(_renderHtml('연결 완료', '이 탭은 닫아도 됩니다.', true));
|
||||
server.close();
|
||||
settle({
|
||||
ok: true,
|
||||
accessToken: json.access_token,
|
||||
refreshToken: json.refresh_token,
|
||||
scope: json.scope ?? '',
|
||||
expiresAt: Date.now() + (Number(json.expires_in ?? 3600) - 60) * 1000,
|
||||
});
|
||||
} catch (e: any) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(_renderHtml('네트워크 오류', e?.message ?? String(e), false));
|
||||
server.close();
|
||||
settle({ ok: false, error: e?.message ?? String(e) });
|
||||
}
|
||||
} catch (e: any) {
|
||||
try { res.writeHead(500); res.end(); } catch { /* ignore */ }
|
||||
server.close();
|
||||
settle({ ok: false, error: e?.message ?? String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
// ephemeral port (0) — Desktop OAuth client 는 어떤 localhost port 도 허용.
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const port = (server.address() as any)?.port;
|
||||
const redirectUri = `http://127.0.0.1:${port}`;
|
||||
const authUrl = `${AUTH_URL}?` + new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: SCOPE,
|
||||
access_type: 'offline',
|
||||
prompt: 'consent', // refresh_token 을 *항상* 발급받기 위해 강제 (Google 의 default 는 처음 한 번만 발급).
|
||||
state: expectedState,
|
||||
}).toString();
|
||||
void vscode.env.openExternal(vscode.Uri.parse(authUrl));
|
||||
});
|
||||
|
||||
// 사용자 cancel 시 서버 닫고 종료.
|
||||
cancelToken.onCancellationRequested(() => {
|
||||
try { server.close(); } catch { /* ignore */ }
|
||||
settle({ ok: false, error: 'cancelled' });
|
||||
});
|
||||
|
||||
// 안전망 — 5분 무응답 시 자동 종료.
|
||||
const tHandle = setTimeout(() => {
|
||||
try { server.close(); } catch { /* ignore */ }
|
||||
settle({ ok: false, error: 'timeout (5분)' });
|
||||
}, 5 * 60 * 1000);
|
||||
cancelToken.onCancellationRequested(() => clearTimeout(tHandle));
|
||||
});
|
||||
}
|
||||
|
||||
/** refresh_token 으로 새 access_token 발급. 만료된 access token 자동 갱신용. */
|
||||
export async function refreshAccessToken(
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
refreshToken: string,
|
||||
): Promise<{ ok: true; accessToken: string; expiresAt: number } | { ok: false; error: string }> {
|
||||
const body = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
});
|
||||
try {
|
||||
const res = await fetch(TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
const json: any = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !json.access_token) {
|
||||
return { ok: false, error: json.error_description || json.error || `HTTP ${res.status}` };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
accessToken: json.access_token,
|
||||
expiresAt: Date.now() + (Number(json.expires_in ?? 3600) - 60) * 1000,
|
||||
};
|
||||
} catch (e: any) {
|
||||
return { ok: false, error: e?.message ?? String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
/** access token 으로 사용자 이메일 조회 — 누가 연결됐는지 보여주기 위함. */
|
||||
export async function fetchUserEmail(accessToken: string): Promise<string> {
|
||||
try {
|
||||
const res = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!res.ok) return '';
|
||||
const json: any = await res.json();
|
||||
return json?.email ?? json?.name ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** 셋업 완료 페이지 — 깔끔한 메시지 + 자동으로 탭 닫기 안내. */
|
||||
function _renderHtml(title: string, msg: string, success: boolean): string {
|
||||
const color = success ? '#10b981' : '#ef4444';
|
||||
const icon = success ? '✅' : '⚠️';
|
||||
return `<!doctype html><html lang="ko"><head><meta charset="utf-8">
|
||||
<title>Astra · ${title}</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{min-height:100vh;display:flex;align-items:center;justify-content:center;background:#0b1018;color:#e2e8f0;font-family:-apple-system,system-ui,sans-serif}
|
||||
.card{background:rgba(20,28,40,.96);border:1px solid ${color};border-radius:14px;padding:36px 32px;max-width:420px;text-align:center;box-shadow:0 24px 80px rgba(0,0,0,.6)}
|
||||
.icon{font-size:48px;margin-bottom:12px}
|
||||
h1{font-size:20px;margin-bottom:12px;color:${color}}
|
||||
p{color:#94a3b8;font-size:13px;line-height:1.6}
|
||||
small{color:#475569;font-size:11px;margin-top:18px;display:block}
|
||||
</style></head><body>
|
||||
<div class="card"><div class="icon">${icon}</div><h1>${title}</h1><p>${_esc(msg)}</p><small>이 탭은 닫아도 됩니다.</small></div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
function _esc(s: string): string {
|
||||
return String(s).replace(/[&<>"']/g, (c) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } as Record<string, string>)[c],
|
||||
);
|
||||
}
|
||||
@@ -108,7 +108,22 @@ export const COMPANY_AGENTS: Record<string, CompanyAgentDef> = {
|
||||
specialty: '일정·마일스톤 관리, 스프린트·간트 차트, 리소스 배분·우선순위 조정, 리스크 추적·완화, 회의 노트·의사결정 로그, 데일리 스탠드업, 다른 에이전트 산출물 요약 보고, 알림·리마인더, 이해관계자 커뮤니케이션',
|
||||
tagline: '일정·리소스·소통을 챙기고 정리합니다',
|
||||
roleCategory: 'support',
|
||||
persona: '친근하고 정중하지만 일정 앞에서는 단호한 톤. 짧고 정리된 문장. 보고할 때는 한눈에 보이게 불릿 + 핵심만 (날짜·담당·상태). 이모지는 😊·📅·✅ 정도.',
|
||||
persona: `친근하고 정중하지만 일정 앞에서는 단호한 톤. 짧고 정리된 문장. 보고할 때는 한눈에 보이게 불릿 + 핵심만 (날짜·담당·상태). 이모지는 😊·📅·✅ 정도.
|
||||
|
||||
**회의록·트랜스크립트·요청 입력 시 자동 분배 패턴 (당신의 핵심 업무):**
|
||||
입력에서 다음 4종을 *각각 따로* 추출하고 *각각의 액션 태그* 로 즉시 emit:
|
||||
|
||||
1. **확정 일정** (시각이 명확한 약속/미팅/마감) → \`<create_calendar_event>\` 로 Google Calendar 등록 + \`<add_task>\` 로 추적기에도 동시 등록.
|
||||
2. **할일** (시각 없거나 모호한 to-do, 책임 명확) → \`<add_task>\` 로 추적기에만 등록. 시각 확정 안 됐으면 due 비움.
|
||||
3. **결정 사항** (방향성·합의) → 별도 액션 없이 답변 본문 "## 결정" 섹션에 한 줄씩 정리 (decisions.md 는 시스템이 자동).
|
||||
4. **시각 모호** ("다음주", "조만간") → 액션 태그 emit 금지. 답변 마지막에 "❓ 확정 필요: …" 로 질문.
|
||||
|
||||
**진척 추적**: 사용자가 "어제 X 끝냈어" / "Y 블락됐어" 같은 보고를 하면 *즉시* \`<update_task>\` 또는 \`<complete_task>\` emit. "잘 챙겨드릴게요" 라고 말만 하지 말고 태그로 실제 갱신.
|
||||
|
||||
**답변 마지막 한 줄 요약** (사용자가 무엇이 등록됐는지 즉시 확인):
|
||||
- 📅 등록: 제목 · 시각
|
||||
- 📋 추가: 제목 · 담당 · 마감
|
||||
- ✅ 완료: 제목`,
|
||||
},
|
||||
writer: {
|
||||
id: 'writer',
|
||||
|
||||
@@ -566,6 +566,21 @@ async function _dispatchOne(
|
||||
}
|
||||
const memory = readAgentMemory(deps.context, agentId);
|
||||
const decisions = readDecisions(deps.context, 2000);
|
||||
// Google Calendar iCal 캐시 (선택 사항). 셋업 안 된 사용자는 빈 문자열 → 무시.
|
||||
// 매 dispatch 마다 디스크 read 1회 발생하지만 캐시는 KB 단위라 비용 무시 가능.
|
||||
let calendarContext = '';
|
||||
try {
|
||||
const { readCalendarCache } = require('../calendar') as typeof import('../calendar');
|
||||
calendarContext = readCalendarCache(deps.context) ?? '';
|
||||
} catch { /* feature 미설치 / 캐시 없음 — silent skip */ }
|
||||
|
||||
// Task tracker — _shared/tasks.md 의 active 항목 요약. 모든 agent 가 진척 상황을
|
||||
// 한 눈에 볼 수 있도록. 비어있으면 빈 문자열 → 프롬프트에서 섹션 자체 생략.
|
||||
let tasksContext = '';
|
||||
try {
|
||||
const { readTaskStore, summarizeActiveTasks } = require('../tasks') as typeof import('../tasks');
|
||||
tasksContext = summarizeActiveTasks(readTaskStore(deps.context));
|
||||
} catch { /* silent */ }
|
||||
const peerOutputs = earlierOutputs
|
||||
.filter((o) => !o.error) // skip failed peers — they'd just confuse the next agent
|
||||
.map((o) => {
|
||||
@@ -624,6 +639,8 @@ async function _dispatchOne(
|
||||
const system = buildSpecialistPrompt({
|
||||
agentId, state,
|
||||
agentMemory: memory, sharedDecisions: decisions,
|
||||
calendarContext,
|
||||
tasksContext,
|
||||
peerOutputs,
|
||||
brainContext, // injected as `[SECOND BRAIN CONTEXT]` block
|
||||
knowledgeMixPolicy: policyBlock, // injected as `[KNOWLEDGE MIX POLICY]` block
|
||||
|
||||
@@ -239,12 +239,40 @@ const PLAN_ONLY: PipelineTemplate = {
|
||||
],
|
||||
};
|
||||
|
||||
/** Read-only registry of templates the UI surfaces. Add more here later. */
|
||||
/**
|
||||
* "개발까지만" — FULL_PRODUCT_DEV 의 1~10 단계 (plan-discuss ~ dev-impl) 만 진행.
|
||||
* QA·배포는 사용자가 코드 받아본 뒤 수동 처리하길 원하는 경우. dev-impl 종료
|
||||
* 직후 CEO 합산 보고로 turn 종료. design-review 의 dev-design loop-back 은 유지.
|
||||
*
|
||||
* stages 는 FULL_PRODUCT_DEV.stages.slice(0, 10) 으로 *참조 공유* — 사용자가
|
||||
* 템플릿 stamp 시 deep-copy 되므로 본 정의 객체는 read-only 안전.
|
||||
*/
|
||||
const DEV_ONLY: PipelineTemplate = {
|
||||
templateId: 'dev-only',
|
||||
name: '개발까지만 (10단계)',
|
||||
description: '풀 워크플로에서 QA·배포 단계를 뺀 버전. 기획 → 개발까지 만들고 검증·배포는 사용자가 직접 챙깁니다.',
|
||||
suggestedPipelineId: 'dev-only',
|
||||
suggestedPipelineName: '기획→개발 파이프라인',
|
||||
stages: FULL_PRODUCT_DEV.stages.slice(0, 10),
|
||||
};
|
||||
|
||||
/** Read-only registry of templates the UI surfaces. */
|
||||
export const PIPELINE_TEMPLATES: PipelineTemplate[] = [
|
||||
FULL_PRODUCT_DEV,
|
||||
PLAN_ONLY,
|
||||
DEV_ONLY,
|
||||
FULL_PRODUCT_DEV,
|
||||
];
|
||||
|
||||
/**
|
||||
* 스코프 단축 표기 — 사용자가 사이드바에서 한 번에 보고 선택할 수 있는 3-way 라벨.
|
||||
* 각 키는 PIPELINE_TEMPLATES 의 templateId 와 1:1. 매핑 변경 시 UI 동기화 필요.
|
||||
*/
|
||||
export const SCOPE_PRESETS = [
|
||||
{ templateId: 'plan-only', shortLabel: '기획만', longLabel: '기획서까지만' },
|
||||
{ templateId: 'dev-only', shortLabel: '개발까지', longLabel: '개발까지만' },
|
||||
{ templateId: 'full-product-dev', shortLabel: '풀', longLabel: '배포까지 풀 파이프라인' },
|
||||
] as const;
|
||||
|
||||
export function getPipelineTemplate(id: string): PipelineTemplate | undefined {
|
||||
return PIPELINE_TEMPLATES.find((t) => t.templateId === id);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,18 @@ export interface SpecialistPromptInputs {
|
||||
agentMemory?: string;
|
||||
/** Tail of `_shared/decisions.md` (may be empty). */
|
||||
sharedDecisions?: string;
|
||||
/**
|
||||
* `_shared/calendar_cache.md` 내용 (Google Calendar iCal 읽기 전용 feature).
|
||||
* 비어있으면 prompt 에 섹션 추가 안 함. 모든 agent 가 동일하게 받음 — 일정·운영을
|
||||
* 다루는 secretary 직군이 가장 활용 가능성 높지만, 기획·개발도 "이번 주 빈 슬롯"
|
||||
* 같은 추론에 쓸 수 있음.
|
||||
*/
|
||||
calendarContext?: string;
|
||||
/**
|
||||
* Task tracker (`_shared/tasks.md`) active 항목 요약. 모든 agent 가 진척 상황 + 막힌
|
||||
* task 를 인지하고 일을 분배·재조정. 비어있으면 섹션 생략.
|
||||
*/
|
||||
tasksContext?: string;
|
||||
/**
|
||||
* Peer outputs from earlier agents in *this* dispatch, in execution order.
|
||||
* Truncated by the dispatcher before passing — this builder doesn't trim
|
||||
@@ -121,6 +133,28 @@ export function buildSpecialistPrompt(inputs: SpecialistPromptInputs): string {
|
||||
parts.push(' • `<delete_file path="..."/>` — 파일·디렉토리 삭제');
|
||||
parts.push(' • `<list_files path="..."/>` — 디렉토리 목록 보기');
|
||||
parts.push(' • `<run_command>명령</run_command>` — 셸 실행 (디렉토리 생성 등)');
|
||||
parts.push(' • `<create_calendar_event title="..." start="2026-05-21T14:00" duration="60" location="...">설명</create_calendar_event>` — Google Calendar 일정 자동 생성 (OAuth 연결 필요)');
|
||||
parts.push(' • `<read_sheet spreadsheet_id="..." range="Sheet1!A1:D20"/>` — Google Sheets 셀 범위 읽기');
|
||||
parts.push(' • `<write_sheet spreadsheet_id="..." range="Sheet1!A1">TSV 본문</write_sheet>` — 셀 덮어쓰기 (탭 구분, 줄바꿈 = 행)');
|
||||
parts.push(' • `<append_sheet spreadsheet_id="..." range="Sheet1!A:C">TSV 본문</append_sheet>` — 마지막 데이터 행 아래에 append');
|
||||
parts.push(' • `<add_task title="..." owner="@me" due="2026-05-24T18:00" notes="..."/>` — 작업 추적기에 task 추가');
|
||||
parts.push(' • `<update_task id="t_001" status="in_progress|blocked|done" notes="..."/>` — 진척·blocker 갱신');
|
||||
parts.push(' • `<complete_task id="t_001"/>` — task 완료 처리 (done 섹션으로 이동)');
|
||||
parts.push('');
|
||||
parts.push('📋 **Task 사용 시점**:');
|
||||
parts.push('- 회의록·요청 처리 중 *명확한 할일* 마다 add_task 1개씩 emit. 추측·확장 금지.');
|
||||
parts.push('- 사용자가 진척 보고하면 즉시 update_task / complete_task. "추적해뒀어요" 라고 말만 하지 말 것.');
|
||||
parts.push('- due 가 분 단위로 명확하면 add_task + create_calendar_event 함께 emit — 추적기에도, 캘린더에도.');
|
||||
parts.push('');
|
||||
parts.push('📅 **Calendar 사용 시점**:');
|
||||
parts.push('- 사용자가 회의록 / 안건 / "X 일까지 끝내야 해" 같이 *명확한 시각이 있는* 약속·작업을 공유했을 때.');
|
||||
parts.push('- 시간이 모호하면 ("다음주에", "조만간") 태그 emit 하지 말고 *물어봐서 확정* 한 뒤 emit.');
|
||||
parts.push('- 회의록에 여러 일정/할일이 섞여 있으면 *각각 1개 태그씩* emit. 한 태그에 여러 일정 욱여넣지 말 것.');
|
||||
parts.push('');
|
||||
parts.push('📊 **Sheets 사용 시점**:');
|
||||
parts.push('- spreadsheet_id 는 사용자가 *직접 알려준 것만* 사용. 추측 / 생성 금지.');
|
||||
parts.push('- 쓰기 전엔 한 줄로 "어디에 무엇을 쓰겠다" 미리 보고. 사용자가 stop 안 걸면 즉시 태그 emit.');
|
||||
parts.push('- 본문은 TSV (탭 구분, 줄바꿈 = 행). 한 줄에 칼럼이 여러개면 탭으로 구분.');
|
||||
parts.push('');
|
||||
parts.push('🛑 **경로 규칙 (위반 시 권한 거부됨)**:');
|
||||
parts.push('- 경로는 **워크스페이스 루트 상대 경로**로 쓰세요. 예: `timertest/timer.py`, `src/utils.py`');
|
||||
@@ -188,6 +222,26 @@ export function buildSpecialistPrompt(inputs: SpecialistPromptInputs): string {
|
||||
parts.push(decisions);
|
||||
}
|
||||
|
||||
// ── Google Calendar (iCal 읽기 전용) ──
|
||||
// 모든 agent 가 받는 외부 컨텍스트 — "이번 주 화/목 14:00-16:00 비어있다",
|
||||
// "내일 13:00 미팅" 같은 정보. 캐시 파일은 g1nation.calendar.refresh 명령 또는
|
||||
// 셋업 시점에 갱신됨 — turn 마다 fetch 하면 외부 의존성 + 지연 발생하므로 보수적.
|
||||
const calendarCtx = (inputs.calendarContext ?? '').trim();
|
||||
if (calendarCtx) {
|
||||
parts.push('');
|
||||
parts.push('## 사용자 일정 컨텍스트 (Google Calendar)');
|
||||
parts.push(calendarCtx);
|
||||
}
|
||||
|
||||
// ── Task tracker — active 작업 한 눈에 ──
|
||||
const tasksCtx = (inputs.tasksContext ?? '').trim();
|
||||
if (tasksCtx) {
|
||||
parts.push('');
|
||||
parts.push('## 진행 중인 작업 (tasks.md · active)');
|
||||
parts.push('아래는 회사 작업 추적기에 active 로 들어있는 task 들입니다. 진척 / 막힌 항목 / 다가오는 마감을 인지하고 답변에 반영하세요. 사용자가 진척을 알려주면 `<update_task>` 또는 `<complete_task>` 로 즉시 갱신.');
|
||||
parts.push(tasksCtx);
|
||||
}
|
||||
|
||||
// ── Self-Reflector Phase A 룰 (가장 마지막에 prepend) ──
|
||||
// 답변 끝에 [Self-Reflector Check] 블록을 자동으로 붙이게 만든다. developer
|
||||
// 직군이면 코드 가드 블록도 함께 — undefined variable / 잘못된 경로 같은
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
readSheetRange,
|
||||
writeSheetRange,
|
||||
appendSheetRows,
|
||||
parseTsvBody,
|
||||
valuesToMarkdownTable,
|
||||
SheetCell,
|
||||
SheetValues,
|
||||
ReadResult,
|
||||
WriteResult,
|
||||
AppendResult,
|
||||
ApiFailure,
|
||||
} from './sheetsApi';
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Google Sheets API v4 — read / write / append.
|
||||
*
|
||||
* 토큰은 calendar 와 공유 (같은 OAuth 에 spreadsheets scope 포함). 별도 셋업 없음 —
|
||||
* "Astra: Google Calendar OAuth 연결" 명령으로 한 번 로그인하면 둘 다 동작한다.
|
||||
*
|
||||
* 외부 라이브러리 안 씀 — Sheets API REST + native fetch.
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { getFreshAccessToken } from '../calendar/calendarApi';
|
||||
|
||||
const API_BASE = 'https://sheets.googleapis.com/v4/spreadsheets';
|
||||
|
||||
/** 2D 값 배열 — 각 셀은 string | number | boolean (Sheets API valueType). */
|
||||
export type SheetCell = string | number | boolean | null;
|
||||
export type SheetValues = SheetCell[][];
|
||||
|
||||
export interface ReadResult { ok: true; values: SheetValues; range: string; }
|
||||
export interface WriteResult { ok: true; updatedCells: number; updatedRange: string; }
|
||||
export interface AppendResult extends WriteResult { appendedRange: string; }
|
||||
export interface ApiFailure { ok: false; error: string; }
|
||||
|
||||
/**
|
||||
* range 의 셀들을 2D 배열로 읽기. 빈 셀은 빈 문자열로 패딩되지 않음 — Sheets API 는
|
||||
* 마지막 비어있지 않은 셀까지만 반환. caller 가 필요하면 normalize.
|
||||
*/
|
||||
export async function readSheetRange(
|
||||
context: vscode.ExtensionContext,
|
||||
spreadsheetId: string,
|
||||
range: string,
|
||||
): Promise<ReadResult | ApiFailure> {
|
||||
if (!spreadsheetId.trim()) return { ok: false, error: 'spreadsheet_id 비어있음' };
|
||||
if (!range.trim()) return { ok: false, error: 'range 비어있음' };
|
||||
const tok = await getFreshAccessToken(context);
|
||||
if (!tok.ok) return { ok: false, error: tok.error };
|
||||
const url = `${API_BASE}/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(range)}`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${tok.accessToken}` },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
const json: any = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: json?.error?.message || `HTTP ${res.status}` };
|
||||
}
|
||||
return { ok: true, values: (json.values ?? []) as SheetValues, range: json.range ?? range };
|
||||
} catch (e: any) {
|
||||
return { ok: false, error: e?.message ?? String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* range 의 셀을 values 로 덮어쓰기. range 의 좌상단부터 values 배열만큼 채움.
|
||||
* Sheets API 의 valueInputOption='USER_ENTERED' 사용 → "=A1+B1" 같은 수식은 수식으로,
|
||||
* 숫자/날짜 문자열은 Sheets 가 자동 파싱.
|
||||
*/
|
||||
export async function writeSheetRange(
|
||||
context: vscode.ExtensionContext,
|
||||
spreadsheetId: string,
|
||||
range: string,
|
||||
values: SheetValues,
|
||||
): Promise<WriteResult | ApiFailure> {
|
||||
if (!spreadsheetId.trim()) return { ok: false, error: 'spreadsheet_id 비어있음' };
|
||||
if (!range.trim()) return { ok: false, error: 'range 비어있음' };
|
||||
if (!Array.isArray(values) || values.length === 0) return { ok: false, error: 'values 비어있음' };
|
||||
const tok = await getFreshAccessToken(context);
|
||||
if (!tok.ok) return { ok: false, error: tok.error };
|
||||
const url = `${API_BASE}/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(range)}` +
|
||||
`?valueInputOption=USER_ENTERED`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${tok.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ range, values, majorDimension: 'ROWS' }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
const json: any = await res.json().catch(() => ({}));
|
||||
if (!res.ok) return { ok: false, error: json?.error?.message || `HTTP ${res.status}` };
|
||||
return { ok: true, updatedCells: Number(json.updatedCells ?? 0), updatedRange: json.updatedRange ?? range };
|
||||
} catch (e: any) {
|
||||
return { ok: false, error: e?.message ?? String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 값 추가 — range 안에서 *가장 마지막으로 데이터가 있는 행 아래* 에 새 행으로 append.
|
||||
* 로그·일지·트래킹 시트에 유용.
|
||||
*/
|
||||
export async function appendSheetRows(
|
||||
context: vscode.ExtensionContext,
|
||||
spreadsheetId: string,
|
||||
range: string,
|
||||
values: SheetValues,
|
||||
): Promise<AppendResult | ApiFailure> {
|
||||
if (!spreadsheetId.trim()) return { ok: false, error: 'spreadsheet_id 비어있음' };
|
||||
if (!range.trim()) return { ok: false, error: 'range 비어있음' };
|
||||
if (!Array.isArray(values) || values.length === 0) return { ok: false, error: 'values 비어있음' };
|
||||
const tok = await getFreshAccessToken(context);
|
||||
if (!tok.ok) return { ok: false, error: tok.error };
|
||||
const url = `${API_BASE}/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(range)}` +
|
||||
`:append?valueInputOption=USER_ENTERED&insertDataOption=INSERT_ROWS`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${tok.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ range, values, majorDimension: 'ROWS' }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
const json: any = await res.json().catch(() => ({}));
|
||||
if (!res.ok) return { ok: false, error: json?.error?.message || `HTTP ${res.status}` };
|
||||
const upd = json.updates ?? {};
|
||||
return {
|
||||
ok: true,
|
||||
updatedCells: Number(upd.updatedCells ?? 0),
|
||||
updatedRange: upd.updatedRange ?? range,
|
||||
appendedRange: upd.updatedRange ?? range,
|
||||
};
|
||||
} catch (e: any) {
|
||||
return { ok: false, error: e?.message ?? String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────── 파싱 헬퍼 — action tag 본문 TSV 해석 ──────────────
|
||||
|
||||
/**
|
||||
* action tag 본문(여러 줄, 탭/파이프 구분) → 2D SheetValues 변환.
|
||||
* - 우선 탭(\t) 으로 split — 진짜 TSV. LLM 이 보통 이걸 emit.
|
||||
* - 탭이 한 칸도 없으면 ` | ` 파이프 구분으로 fallback (양 옆 공백 1개씩).
|
||||
* - 빈 줄은 무시 (trailing newline 안전).
|
||||
* 셀 값은 모두 문자열 — Sheets API 가 USER_ENTERED 로 자동 형변환.
|
||||
*/
|
||||
export function parseTsvBody(body: string): SheetValues {
|
||||
if (!body || typeof body !== 'string') return [];
|
||||
// 공백·탭·개행만 있는 입력은 빈 배열로 — LLM 이 빈 본문 emit 했을 때 안전.
|
||||
if (!body.trim()) return [];
|
||||
const trimmed = body.replace(/^\s*\n+/, '').replace(/\n+\s*$/, '');
|
||||
const lines = trimmed.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
||||
if (lines.length === 0) return [];
|
||||
const useTab = lines.some((l) => l.includes('\t'));
|
||||
return lines.map((line) =>
|
||||
useTab ? line.split('\t') : line.split(/\s*\|\s*/),
|
||||
);
|
||||
}
|
||||
|
||||
/** 결과 2D 배열을 LLM 친화적 짧은 마크다운 테이블로 (read 결과를 chat 에 주입할 때). */
|
||||
export function valuesToMarkdownTable(values: SheetValues, maxRows: number = 50): string {
|
||||
if (!values.length) return '_(empty)_';
|
||||
const truncated = values.slice(0, maxRows);
|
||||
const rendered = truncated.map((row) => '| ' + row.map((c) => String(c ?? '').replace(/\|/g, '\\|')).join(' | ') + ' |');
|
||||
if (rendered.length === 0) return '_(empty)_';
|
||||
// 헤더 구분선 — 첫 행을 헤더로 가정.
|
||||
const cols = truncated[0]?.length ?? 1;
|
||||
const sep = '|' + Array(cols).fill('---').join('|') + '|';
|
||||
const result = [rendered[0], sep, ...rendered.slice(1)].join('\n');
|
||||
if (values.length > maxRows) {
|
||||
return result + `\n_(... ${values.length - maxRows} more rows truncated)_`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
Task,
|
||||
TaskStatus,
|
||||
TaskStore,
|
||||
readTaskStore,
|
||||
writeTaskStore,
|
||||
parseTaskStore,
|
||||
renderTaskStore,
|
||||
addTask,
|
||||
updateTask,
|
||||
completeTask,
|
||||
summarizeActiveTasks,
|
||||
} from './taskStore';
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Task tracker — `.astra/company/_shared/tasks.md` 단일 파일.
|
||||
*
|
||||
* 설계 원칙:
|
||||
* - 외부 DB 없이 *사람이 읽을 수 있는* 마크다운 테이블에 누적. git 으로 history 추적 가능.
|
||||
* - 파싱은 regex 기반 (셀 구분자 `|`). 사용자가 손으로 편집해도 fault-tolerant.
|
||||
* - 모든 task 는 안정적 id (t_001, t_002, ...) — agent 가 update/complete 시 식별자로 사용.
|
||||
* - active / done 두 섹션. done 은 최근 N개만 보관 (오래된 건 archive).
|
||||
*
|
||||
* 파일 포맷:
|
||||
*
|
||||
* # Tasks
|
||||
*
|
||||
* ## Active
|
||||
* | ID | Title | Owner | Due | Status | Notes |
|
||||
* |---|---|---|---|---|---|
|
||||
* | t_001 | 광고주 자료 정리 | @me | 2026-05-24T18:00 | in_progress | 자료 대기 |
|
||||
* | t_002 | 디자인 리뷰 준비 | @planner | 2026-05-21T13:00 | open | |
|
||||
*
|
||||
* ## Done (recent 10)
|
||||
* | ID | Title | Owner | Completed |
|
||||
* |---|---|---|---|
|
||||
* | t_000 | 일정 셋업 | @me | 2026-05-20T10:00 |
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
export type TaskStatus = 'open' | 'in_progress' | 'blocked' | 'done';
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
/** @me / @planner / @qa 같은 자유 형식. 정확한 agent id 와 무관해도 됨. */
|
||||
owner: string;
|
||||
/** ISO 'YYYY-MM-DDTHH:MM' 또는 빈 문자열. due 없는 task 도 허용. */
|
||||
due: string;
|
||||
status: TaskStatus;
|
||||
notes: string;
|
||||
/** done 으로 전환된 시각 ISO. status==='done' 일 때만 의미. */
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
const DONE_KEEP_RECENT = 10;
|
||||
|
||||
function _tasksPath(context: vscode.ExtensionContext): string {
|
||||
const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
if (ws) return path.join(ws, '.astra', 'company', '_shared', 'tasks.md');
|
||||
return path.join(context.globalStorageUri.fsPath, 'company', '_shared', 'tasks.md');
|
||||
}
|
||||
|
||||
/** 전체 task store. active 와 done 분리. */
|
||||
export interface TaskStore {
|
||||
active: Task[];
|
||||
done: Task[];
|
||||
}
|
||||
|
||||
export function readTaskStore(context: vscode.ExtensionContext): TaskStore {
|
||||
const p = _tasksPath(context);
|
||||
try {
|
||||
if (!fs.existsSync(p)) return { active: [], done: [] };
|
||||
const md = fs.readFileSync(p, 'utf8');
|
||||
return parseTaskStore(md);
|
||||
} catch {
|
||||
return { active: [], done: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export function writeTaskStore(context: vscode.ExtensionContext, store: TaskStore): void {
|
||||
const p = _tasksPath(context);
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, renderTaskStore(store), 'utf8');
|
||||
} catch {
|
||||
// silent fail — caller 가 다음 read 에서 빈 store 받게 됨.
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────── parse ──────────────
|
||||
|
||||
export function parseTaskStore(md: string): TaskStore {
|
||||
const active: Task[] = [];
|
||||
const done: Task[] = [];
|
||||
let section: 'active' | 'done' | null = null;
|
||||
const lines = md.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
if (/^##\s*Active/i.test(line)) { section = 'active'; continue; }
|
||||
if (/^##\s*Done/i.test(line)) { section = 'done'; continue; }
|
||||
if (!section) continue;
|
||||
if (!line.trim().startsWith('|')) continue;
|
||||
// header / separator 행 skip
|
||||
if (/^\|\s*ID\b/i.test(line)) continue;
|
||||
if (/^\|\s*[-:]+/.test(line)) continue;
|
||||
const cells = _parseRow(line);
|
||||
if (!cells || cells.length < 3) continue;
|
||||
if (section === 'active') {
|
||||
const [id, title, owner, due, status, notes] = cells;
|
||||
if (!id || !id.startsWith('t_')) continue;
|
||||
active.push({
|
||||
id,
|
||||
title: title ?? '',
|
||||
owner: owner ?? '',
|
||||
due: due ?? '',
|
||||
status: _normalizeStatus(status),
|
||||
notes: notes ?? '',
|
||||
});
|
||||
} else {
|
||||
const [id, title, owner, completedAt] = cells;
|
||||
if (!id || !id.startsWith('t_')) continue;
|
||||
done.push({
|
||||
id,
|
||||
title: title ?? '',
|
||||
owner: owner ?? '',
|
||||
due: '',
|
||||
status: 'done',
|
||||
notes: '',
|
||||
completedAt: completedAt ?? '',
|
||||
});
|
||||
}
|
||||
}
|
||||
return { active, done };
|
||||
}
|
||||
|
||||
function _parseRow(line: string): string[] | null {
|
||||
const t = line.trim();
|
||||
if (!t.startsWith('|') || !t.endsWith('|')) return null;
|
||||
const inner = t.slice(1, -1);
|
||||
return inner.split('|').map((c) => c.trim().replace(/\\\|/g, '|'));
|
||||
}
|
||||
|
||||
function _normalizeStatus(s: string | undefined): TaskStatus {
|
||||
const v = String(s ?? 'open').trim().toLowerCase().replace(/\s+/g, '_');
|
||||
if (v === 'in_progress' || v === 'inprogress' || v === 'progress') return 'in_progress';
|
||||
if (v === 'blocked' || v === 'block') return 'blocked';
|
||||
if (v === 'done' || v === 'completed' || v === 'closed') return 'done';
|
||||
return 'open';
|
||||
}
|
||||
|
||||
// ────────────── render ──────────────
|
||||
|
||||
export function renderTaskStore(store: TaskStore): string {
|
||||
const lines: string[] = [
|
||||
'# Tasks',
|
||||
'',
|
||||
'## Active',
|
||||
'| ID | Title | Owner | Due | Status | Notes |',
|
||||
'|---|---|---|---|---|---|',
|
||||
];
|
||||
for (const t of store.active) {
|
||||
lines.push(`| ${t.id} | ${_esc(t.title)} | ${_esc(t.owner)} | ${_esc(t.due)} | ${t.status} | ${_esc(t.notes)} |`);
|
||||
}
|
||||
if (store.active.length === 0) lines.push('_(no active tasks)_');
|
||||
lines.push('');
|
||||
lines.push(`## Done (recent ${DONE_KEEP_RECENT})`);
|
||||
lines.push('| ID | Title | Owner | Completed |');
|
||||
lines.push('|---|---|---|---|');
|
||||
const recentDone = store.done.slice(-DONE_KEEP_RECENT);
|
||||
for (const t of recentDone) {
|
||||
lines.push(`| ${t.id} | ${_esc(t.title)} | ${_esc(t.owner)} | ${_esc(t.completedAt ?? '')} |`);
|
||||
}
|
||||
if (recentDone.length === 0) lines.push('_(no completed tasks)_');
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
function _esc(s: string): string {
|
||||
return String(s ?? '').replace(/\|/g, '\\|').replace(/\n/g, ' ');
|
||||
}
|
||||
|
||||
// ────────────── CRUD helpers ──────────────
|
||||
|
||||
/**
|
||||
* 새 task 추가. id 는 active+done 전체 max + 1.
|
||||
* 사용자가 같은 title 로 중복 요청해도 별개 task — dedup 책임은 agent.
|
||||
*/
|
||||
export function addTask(store: TaskStore, input: {
|
||||
title: string;
|
||||
owner?: string;
|
||||
due?: string;
|
||||
notes?: string;
|
||||
status?: TaskStatus;
|
||||
}): Task {
|
||||
const allIds = [...store.active, ...store.done].map((t) => t.id);
|
||||
const max = allIds.reduce((acc, id) => {
|
||||
const m = id.match(/^t_(\d+)$/);
|
||||
if (!m) return acc;
|
||||
const n = parseInt(m[1], 10);
|
||||
return n > acc ? n : acc;
|
||||
}, 0);
|
||||
const id = `t_${String(max + 1).padStart(3, '0')}`;
|
||||
const task: Task = {
|
||||
id,
|
||||
title: input.title.trim(),
|
||||
owner: (input.owner ?? '').trim(),
|
||||
due: (input.due ?? '').trim(),
|
||||
status: input.status ?? 'open',
|
||||
notes: (input.notes ?? '').trim(),
|
||||
};
|
||||
store.active.push(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
/** id 로 task 찾아 patch 적용. 못 찾으면 null. */
|
||||
export function updateTask(store: TaskStore, id: string, patch: Partial<Task>): Task | null {
|
||||
const idx = store.active.findIndex((t) => t.id === id);
|
||||
if (idx < 0) return null;
|
||||
const cur = store.active[idx];
|
||||
const next: Task = { ...cur, ...patch, id: cur.id };
|
||||
store.active[idx] = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* task 를 done 으로 옮김. active 에서 빼고 done 에 push.
|
||||
* 못 찾으면 null. 이미 done 이었으면 active 에는 없으므로 동일.
|
||||
*/
|
||||
export function completeTask(store: TaskStore, id: string, completedAt?: string): Task | null {
|
||||
const idx = store.active.findIndex((t) => t.id === id);
|
||||
if (idx < 0) return null;
|
||||
const [t] = store.active.splice(idx, 1);
|
||||
const closed: Task = { ...t, status: 'done', completedAt: completedAt ?? new Date().toISOString().slice(0, 16) };
|
||||
store.done.push(closed);
|
||||
return closed;
|
||||
}
|
||||
|
||||
/** 프롬프트 주입용 — active 만 짧은 마크다운으로. due 가 가까운 순. */
|
||||
export function summarizeActiveTasks(store: TaskStore, max: number = 12): string {
|
||||
if (store.active.length === 0) return '';
|
||||
const sorted = [...store.active].sort((a, b) => {
|
||||
// due 있는 task 우선 (asc). 없는 건 뒤로.
|
||||
if (a.due && !b.due) return -1;
|
||||
if (!a.due && b.due) return 1;
|
||||
return (a.due || '').localeCompare(b.due || '');
|
||||
});
|
||||
const shown = sorted.slice(0, max);
|
||||
const lines = shown.map((t) => {
|
||||
const due = t.due ? ` · 마감 ${t.due}` : '';
|
||||
const owner = t.owner ? ` · ${t.owner}` : '';
|
||||
const status = t.status !== 'open' ? ` [${t.status}]` : '';
|
||||
const notes = t.notes ? ` — ${t.notes}` : '';
|
||||
return `- ${t.id}${status} ${t.title}${owner}${due}${notes}`;
|
||||
});
|
||||
if (sorted.length > max) lines.push(`- _(... ${sorted.length - max} more)_`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -602,6 +602,52 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
|
||||
if (result.ok) await provider._sendCompanyPipelines();
|
||||
return true;
|
||||
}
|
||||
case 'setCompanyScopePreset': {
|
||||
// 스코프 프리셋 클릭 — templateId (plan-only / dev-only / full-product-dev) 받아서:
|
||||
// 1) suggestedPipelineId 의 pipeline 이 state.pipelines 에 없으면 template stamp
|
||||
// 2) activePipelineId 를 그 id 로 설정
|
||||
// 이미 stamp 된 pipeline 이라면 stage 사용자 편집을 *유지* (활성화만).
|
||||
const { getPipelineTemplate, upsertPipeline, setActivePipeline, readCompanyState } =
|
||||
await import('../features/company');
|
||||
const tplId = typeof data.templateId === 'string' ? data.templateId : '';
|
||||
const tpl = getPipelineTemplate(tplId);
|
||||
if (!tpl) {
|
||||
provider._view?.webview.postMessage({
|
||||
type: 'setCompanyScopePresetResult',
|
||||
value: { ok: false, reason: `알 수 없는 템플릿: ${tplId}` },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
const state = readCompanyState(provider._context);
|
||||
if (!state.pipelines || !state.pipelines[tpl.suggestedPipelineId]) {
|
||||
const stampDef = {
|
||||
id: tpl.suggestedPipelineId,
|
||||
name: tpl.suggestedPipelineName,
|
||||
// stage 는 deep clone — 템플릿 read-only 원본 보호.
|
||||
stages: tpl.stages.map((s) => ({ ...s })),
|
||||
};
|
||||
const stamp = await upsertPipeline(provider._context, stampDef);
|
||||
if (!stamp.ok) {
|
||||
provider._view?.webview.postMessage({
|
||||
type: 'setCompanyScopePresetResult',
|
||||
value: { ok: false, reason: stamp.reason },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const activate = await setActivePipeline(provider._context, tpl.suggestedPipelineId);
|
||||
provider._view?.webview.postMessage({
|
||||
type: 'setCompanyScopePresetResult',
|
||||
value: activate.ok
|
||||
? { ok: true, pipelineId: tpl.suggestedPipelineId, templateId: tplId }
|
||||
: { ok: false, reason: activate.reason },
|
||||
});
|
||||
if (activate.ok) {
|
||||
await provider._sendCompanyStatus();
|
||||
await provider._sendCompanyPipelines();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case 'proactiveTrigger':
|
||||
await provider._handleProactiveSuggestion(data.context);
|
||||
return true;
|
||||
|
||||
+93
-1208
File diff suppressed because it is too large
Load Diff
@@ -253,6 +253,87 @@ If neither condition is met, give a definitive answer and stop.
|
||||
[ACTION 8: WEB SEARCH]
|
||||
<read_url>https://html.duckduckgo.com/html/?q=YOUR+SEARCH+QUERY</read_url>
|
||||
|
||||
[ACTION 9: CREATE CALENDAR EVENT]
|
||||
Use only when the user shares meeting notes / agenda / due dates and a real event
|
||||
should land on their Google Calendar. Requires the user to have run
|
||||
"Astra: Google Calendar OAuth 연결 (쓰기)" — if not connected the tag will fail
|
||||
cleanly (reported in the action log).
|
||||
|
||||
<create_calendar_event title="회의 제목" start="2026-05-21T14:00" duration="60" location="회의실 A">
|
||||
설명 (선택) — 회의록 요약 / 안건 등
|
||||
</create_calendar_event>
|
||||
|
||||
Attributes:
|
||||
title (required) — 한 줄 제목
|
||||
start (required) — 'YYYY-MM-DDTHH:MM' 로컬, 또는 timezone 포함 ISO
|
||||
end | duration — end 없으면 duration(분, default 60) 으로 자동 계산
|
||||
location (optional)
|
||||
all_day="true" — DTSTART 만 'YYYY-MM-DD' 형식으로
|
||||
|
||||
Emit *one tag per event*. Never invent times the user didn't mention — if
|
||||
unclear, ask first. Do not emit tags for vague phrases like "다음주에" without
|
||||
a concrete time.
|
||||
|
||||
[ACTION 10: READ SHEET]
|
||||
Google Sheets 의 셀 범위를 읽어 chat 컨텍스트에 마크다운 테이블로 주입한다.
|
||||
같은 OAuth 권한 (Calendar 연결 시 Sheets 권한도 함께 발급) 필요.
|
||||
|
||||
<read_sheet spreadsheet_id="1abc...xyz" range="Sheet1!A1:D20"/>
|
||||
|
||||
- spreadsheet_id: Google Sheets URL 의 /d/<여기>/edit 부분
|
||||
- range: A1 notation. 시트명 포함 가능. 예: 'Sheet1!A1:E50', '데이터!B:B'
|
||||
|
||||
[ACTION 11: WRITE SHEET]
|
||||
Range 의 좌상단부터 값을 *덮어쓴다*. 본문은 TSV (탭 구분, 줄바꿈 = 행).
|
||||
탭이 한 칸도 없으면 ' | ' 파이프 구분으로 자동 fallback.
|
||||
|
||||
<write_sheet spreadsheet_id="1abc..." range="Sheet1!A1">
|
||||
이름\t나이\t직책
|
||||
민지\t29\t디자이너
|
||||
</write_sheet>
|
||||
|
||||
[ACTION 12: APPEND SHEET]
|
||||
Range 안에서 *가장 마지막 데이터 행 아래* 에 새 행으로 append. 로그·일지에 유용.
|
||||
|
||||
<append_sheet spreadsheet_id="1abc..." range="Sheet1!A:C">
|
||||
2026-05-21\t새 항목\t완료
|
||||
</append_sheet>
|
||||
|
||||
⚠ Sheets 사용 규칙:
|
||||
- spreadsheet_id 는 사용자가 알려준 것만. 추측·생성 금지.
|
||||
- 사용자가 "내 시트" 같이 추상적으로 지칭하면 *URL 을 받아온 뒤* 사용.
|
||||
- 쓰기 전에는 반드시 "이 시트에 이런 데이터를 쓰겠다" 한 줄 미리 알리기 (실수 방지).
|
||||
|
||||
[ACTION 13: ADD TASK]
|
||||
회의록·요청·계획 분석 중 *명확한 할일* 이 발견되면 작업 추적기에 등록.
|
||||
추적기는 모든 agent 가 다음 turn 부터 자동으로 보게 됨 → 진척 가시화 + 누락 방지.
|
||||
|
||||
<add_task title="광고주 자료 정리" owner="@me" due="2026-05-24T18:00" notes="자료 수령 후 시작"/>
|
||||
|
||||
Attributes (title 만 필수):
|
||||
title — 한 줄 요약 (required)
|
||||
owner — @me / @planner / @qa 등 자유 형식
|
||||
due — 'YYYY-MM-DDTHH:MM' (없으면 마감 없는 task)
|
||||
notes — 한 줄 부가 설명
|
||||
status — open(default) / in_progress / blocked
|
||||
|
||||
[ACTION 14: UPDATE TASK]
|
||||
진척·blocker·due 변경. id 는 추적기에 표시된 t_001 같은 식별자.
|
||||
바꿀 필드만 attribute 로 주면 됨 (다른 값은 보존).
|
||||
|
||||
<update_task id="t_001" status="in_progress" notes="자료 수령 완료, 정리 진행 중"/>
|
||||
|
||||
[ACTION 15: COMPLETE TASK]
|
||||
task 가 끝났을 때. active 에서 빼고 done 으로 이동, completedAt 자동 기록.
|
||||
|
||||
<complete_task id="t_001"/>
|
||||
|
||||
⚠ Task 사용 규칙:
|
||||
- 사용자가 *명시적으로* 할일이라고 언급한 것만 add — 추측·확장 금지.
|
||||
- 회의록에 할일이 여러 개면 각각 *별도 add_task* (한 태그에 욱여넣지 말 것).
|
||||
- 진척 보고가 들어오면 즉시 update / complete. "추적해뒀어요" 라고 말만 하지 말 것.
|
||||
- due 시각이 명확한 task 는 add_task + create_calendar_event 함께 emit (둘 다).
|
||||
|
||||
[OPERATIONAL RULES]
|
||||
1. Reply in the same language as the user.
|
||||
2. File paths are relative to the workspace or absolute under /Volumes/Data/project/Antigravity.
|
||||
|
||||
Reference in New Issue
Block a user