v2.2.15: Astra Office Refactor & Multi-Service Integration

This commit is contained in:
g1nation
2026-05-16 22:07:06 +09:00
parent 9dcc98ad33
commit 9ca95ab997
46 changed files with 5648 additions and 1299 deletions
+279
View File
@@ -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;
}