feat: v2.2.92 → v2.2.158 — god-file 분해 + Stocks feature + 대화 연속성
R56–R59: agent.ts 2731→1529줄 god-file 분해 (25 modules) · attrParsers + LLM 메서드 8개 (callNonStreaming, streamChatOnce 등) · executeActions 415줄 → 8 handler 그룹 (file/run/list/brain/calendar/sheets/tasks) · handlePrompt 1100줄 → 7 phase 모듈 (system prompt + budget + autoContinue 등) R50–R55: extension.ts 1145→349줄 (telegram/settings/provider commands 분리) Stocks feature 신규: /stocks slash command (v2.2.152~158) · .astra/stocks.json 저장소 + Yahoo Finance 현재가 갱신 · 8 키워드 필터 (ROE/성장성/유동성/수익성/영업효율/기술력/안정성/PBR) · Naver 시가총액 페이지 JSON API (m.stock.naver.com) 발굴 · LLM Top 5 매력도 분석 + Telegram 자동 보고서 · KST 09:00/15:00 watcher 자동 모니터링 대화 연속성 (v2.2.150~157): · [PRIOR TURN CONCLUSION] block 으로 직전 결론 anchor · thin follow-up 분류 → boilerplate 헤더 suppression · slash 명령 결과 chatHistory mirror (capture wrapper) · echo/parrot 금지 system prompt rule 기타: /stocks 슬래시 자동완성 dropdown UI, Naver JSON API 전환 (cheerio 제거) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { HandlerContext } from './types';
|
||||
import { findBrainFiles } from '../../utils';
|
||||
import { EXCLUDED_DIRS } from '../../config';
|
||||
|
||||
export async function applyBrainOpsActions(ctx: HandlerContext): Promise<void> {
|
||||
const { aiMessage, activeBrainDir, report } = ctx;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
// Action 7: Second Brain Knowledge (List/Read)
|
||||
const listBrainRegex = /<list_brain\s*path=['"]?([^'"]*)['"]?\s*\/?>(?:<\/list_brain>)?/gi;
|
||||
while ((match = listBrainRegex.exec(aiMessage)) !== null) {
|
||||
const relPath = match[1].trim() || '.';
|
||||
try {
|
||||
const brainDir = activeBrainDir;
|
||||
const absPath = path.join(brainDir, relPath);
|
||||
if (fs.existsSync(absPath) && fs.statSync(absPath).isDirectory()) {
|
||||
const entries = fs.readdirSync(absPath, { withFileTypes: true });
|
||||
let listing = entries
|
||||
.filter(e => !e.name.startsWith('.') && !EXCLUDED_DIRS.has(e.name))
|
||||
.map(e => e.isDirectory() ? `${e.name}/` : e.name)
|
||||
.join('\n');
|
||||
|
||||
if (listing.length > 5000) {
|
||||
listing = listing.slice(0, 5000) + "\n... (truncated for context)";
|
||||
}
|
||||
|
||||
report.push(`🧠 Brain Listed: ${relPath}`);
|
||||
ctx.chatHistory.push({ role: 'system', content: `[Result of list_brain ${relPath}]\n${listing}`, internal: true });
|
||||
} else {
|
||||
report.push(`❌ Brain List failed: ${relPath} not found`);
|
||||
}
|
||||
} catch (err: any) { report.push(`❌ Error Listing Brain: ${err.message}`); }
|
||||
}
|
||||
|
||||
const brainRegex = /<read_brain>([\s\S]*?)<\/read_brain>/gi;
|
||||
while ((match = brainRegex.exec(aiMessage)) !== null) {
|
||||
const fileName = match[1].trim();
|
||||
try {
|
||||
const brainDir = activeBrainDir;
|
||||
const files = findBrainFiles(brainDir);
|
||||
const targetFile = files.find((f: string) => path.basename(f) === fileName || f.endsWith(fileName));
|
||||
|
||||
if (targetFile && fs.existsSync(targetFile)) {
|
||||
const content = fs.readFileSync(targetFile, 'utf-8');
|
||||
report.push(`🧠 Brain Read: ${fileName}`);
|
||||
ctx.chatHistory.push({ role: 'system', content: `[Result of read_brain ${fileName}]\n\`\`\`\n${content}\n\`\`\``, internal: true });
|
||||
} else {
|
||||
report.push(`❌ Brain Read failed: ${fileName} not found in Second Brain`);
|
||||
}
|
||||
} catch (err: any) { report.push(`❌ Error Reading Brain: ${err.message}`); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { HandlerContext } from './types';
|
||||
import { _parseCalEventAttrs } from '../attrParsers';
|
||||
|
||||
export async function applyCalendarActions(ctx: HandlerContext): Promise<void> {
|
||||
const { aiMessage, report } = ctx;
|
||||
let match: RegExpExecArray | null;
|
||||
// Action 8: 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(ctx.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 인용 가능.
|
||||
ctx.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)}`); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { validatePath } from '../../security';
|
||||
import { FileSystemError } from '../../core/errors';
|
||||
import { HandlerContext } from './types';
|
||||
|
||||
/**
|
||||
* `<create_file>` + `<edit_file>` action handler.
|
||||
*
|
||||
* AI 가 한 턴에 여러 개의 create/edit 태그를 내뱉을 수 있으므로, 각 태그마다
|
||||
* regex 로 잡아서 순서대로 처리한다. validatePath() 로 sandbox 보장 + 매 파일
|
||||
* 쓰기 전에 transactionManager.record() 로 롤백 지점 기록.
|
||||
*/
|
||||
export async function applyFileCreateEditActions(ctx: HandlerContext): Promise<void> {
|
||||
const { aiMessage, rootPath, activeBrainDir, report } = ctx;
|
||||
|
||||
// Action 1: Create File
|
||||
const createRegex = /<create_file\s+path=['"]?([^'"]+)['"]?>([\s\S]*?)<\/create_file>/gi;
|
||||
let match;
|
||||
while ((match = createRegex.exec(aiMessage)) !== null) {
|
||||
const relPath = match[1].trim();
|
||||
const content = match[2].trim();
|
||||
try {
|
||||
const absPath = validatePath(rootPath, relPath);
|
||||
await ctx.transactionManager.record(absPath);
|
||||
|
||||
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
||||
fs.writeFileSync(absPath, content, 'utf-8');
|
||||
|
||||
report.push(`✅ Created: ${relPath}`);
|
||||
ctx.setFirstCreated(absPath);
|
||||
if (absPath.startsWith(activeBrainDir)) ctx.markBrainModified();
|
||||
} catch (err: any) {
|
||||
throw new FileSystemError(`Failed to create file ${relPath}: ${err.message}`, relPath, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Action 2: Edit File
|
||||
const editRegex = /<edit_file\s+path=['"]?([^'"]+)['"]?>([\s\S]*?)<\/edit_file>/gi;
|
||||
while ((match = editRegex.exec(aiMessage)) !== null) {
|
||||
const relPath = match[1].trim();
|
||||
const editContent = match[2].trim();
|
||||
try {
|
||||
const absPath = validatePath(rootPath, relPath);
|
||||
if (fs.existsSync(absPath)) {
|
||||
await ctx.transactionManager.record(absPath);
|
||||
|
||||
let currentContent = fs.readFileSync(absPath, 'utf-8');
|
||||
const searchMatch = editContent.match(/<search>([\s\S]*?)<\/search>\s*<replace>([\s\S]*?)<\/replace>/i);
|
||||
|
||||
if (searchMatch) {
|
||||
const searchStr = searchMatch[1];
|
||||
const replaceStr = searchMatch[2];
|
||||
if (currentContent.includes(searchStr)) {
|
||||
currentContent = currentContent.replace(searchStr, replaceStr);
|
||||
fs.writeFileSync(absPath, currentContent, 'utf-8');
|
||||
report.push(`📝 Updated: ${relPath}`);
|
||||
} else {
|
||||
report.push(`⚠️ Search string not found in ${relPath}`);
|
||||
}
|
||||
} else {
|
||||
fs.writeFileSync(absPath, editContent, 'utf-8');
|
||||
report.push(`📝 Updated (Full): ${relPath}`);
|
||||
}
|
||||
if (absPath.startsWith(activeBrainDir)) ctx.markBrainModified();
|
||||
} else {
|
||||
report.push(`❌ File not found: ${relPath}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
throw new FileSystemError(`Failed to edit file ${relPath}: ${err.message}`, relPath, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as fs from 'fs';
|
||||
import { validatePath } from '../../security';
|
||||
import { FileSystemError } from '../../core/errors';
|
||||
import { HandlerContext } from './types';
|
||||
|
||||
export async function applyFileDeleteReadActions(ctx: HandlerContext): Promise<void> {
|
||||
const { aiMessage, rootPath, report } = ctx;
|
||||
let match;
|
||||
|
||||
// Action 3: Delete File
|
||||
const deleteRegex = /<delete_file\s+path=['"]?([^'"]+)['"]?\s*\/?>(?:<\/delete_file>)?/gi;
|
||||
while ((match = deleteRegex.exec(aiMessage)) !== null) {
|
||||
const relPath = match[1].trim();
|
||||
try {
|
||||
const absPath = validatePath(rootPath, relPath);
|
||||
if (fs.existsSync(absPath)) {
|
||||
await ctx.transactionManager.record(absPath);
|
||||
fs.unlinkSync(absPath);
|
||||
report.push(`🗑 Deleted: ${relPath}`);
|
||||
} else {
|
||||
report.push(`⚠️ Delete failed: ${relPath} not found`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
throw new FileSystemError(`Failed to delete file ${relPath}: ${err.message}`, relPath, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Action 4: Read File (Non-state-changing, no transaction record needed)
|
||||
const readRegex = /<read_file\s+path=['"]?([^'"]+)['"]?\s*\/?>(?:<\/read_file>)?/gi;
|
||||
while ((match = readRegex.exec(aiMessage)) !== null) {
|
||||
const relPath = match[1].trim();
|
||||
try {
|
||||
const absPath = validatePath(rootPath, relPath);
|
||||
if (fs.existsSync(absPath)) {
|
||||
const content = fs.readFileSync(absPath, 'utf-8');
|
||||
const preview = content.length > 8000 ? content.slice(0, 8000) + "\n... (truncated)" : content;
|
||||
report.push(`📖 Read: ${relPath}`);
|
||||
ctx.chatHistory.push({ role: 'system', content: `[Result of read_file ${relPath}]\n\`\`\`\n${preview}\n\`\`\``, internal: true });
|
||||
} else {
|
||||
report.push(`❌ Read failed: ${relPath} not found`);
|
||||
}
|
||||
} catch (err: any) { report.push(`❌ Error Reading ${relPath}: ${err.message}`); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as fs from 'fs';
|
||||
import { HandlerContext } from './types';
|
||||
import { validatePath } from '../../security';
|
||||
import { EXCLUDED_DIRS } from '../../config';
|
||||
|
||||
export async function applyListFilesActions(ctx: HandlerContext): Promise<void> {
|
||||
const { aiMessage, rootPath, report } = ctx;
|
||||
let match: RegExpExecArray | null;
|
||||
// Action 6: List Files
|
||||
const listRegex = /<list_files\s+path=['"]?([^'"]+)['"]?\s*\/?>(?:<\/list_files>)?/gi;
|
||||
while ((match = listRegex.exec(aiMessage)) !== null) {
|
||||
const relPath = match[1].trim() || '.';
|
||||
try {
|
||||
const absPath = validatePath(rootPath, relPath);
|
||||
if (fs.existsSync(absPath) && fs.statSync(absPath).isDirectory()) {
|
||||
const entries = fs.readdirSync(absPath, { withFileTypes: true });
|
||||
let listing = entries
|
||||
.filter(e => !e.name.startsWith('.') && !EXCLUDED_DIRS.has(e.name))
|
||||
.map(e => e.isDirectory() ? `${e.name}/` : e.name)
|
||||
.join('\n');
|
||||
|
||||
if (listing.length > 5000) {
|
||||
listing = listing.slice(0, 5000) + "\n... (truncated for context)";
|
||||
}
|
||||
|
||||
report.push(`📂 Listed: ${relPath}`);
|
||||
ctx.chatHistory.push({ role: 'system', content: `[Result of list_files ${relPath}]\n${listing}`, internal: true });
|
||||
}
|
||||
} catch (err: any) { report.push(`❌ Listing failed: ${err.message}`); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { HandlerContext } from './types';
|
||||
import { sanitizeCommand } from '../../security';
|
||||
|
||||
export async function applyRunCommandActions(ctx: HandlerContext): Promise<void> {
|
||||
const { aiMessage, rootPath, report } = ctx;
|
||||
let match: RegExpExecArray | null;
|
||||
// Action 5: Run Command
|
||||
const cmdRegex = /<run_command>([\s\S]*?)<\/run_command>/gi;
|
||||
while ((match = cmdRegex.exec(aiMessage)) !== null) {
|
||||
const cmd = match[1].trim();
|
||||
try {
|
||||
const safeCmd = sanitizeCommand(cmd);
|
||||
const terminal = vscode.window.terminals.find(t => t.name === 'Astra Terminal') || vscode.window.createTerminal({ name: 'Astra Terminal', cwd: rootPath });
|
||||
terminal.show();
|
||||
terminal.sendText(safeCmd);
|
||||
report.push(`🚀 Executed: ${safeCmd}`);
|
||||
} catch (err: any) { report.push(`❌ Blocked: ${err.message}`); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { HandlerContext } from './types';
|
||||
import { _parseSheetAttrs } from '../attrParsers';
|
||||
|
||||
export async function applySheetsActions(ctx: HandlerContext): Promise<void> {
|
||||
const { aiMessage, report } = ctx;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
// 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(ctx.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)`);
|
||||
ctx.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(ctx.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(ctx.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)}`); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { HandlerContext } from './types';
|
||||
import { _parseTaskAttrs } from '../attrParsers';
|
||||
|
||||
// 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"/>
|
||||
export async function applyTasksActions(ctx: HandlerContext): Promise<void> {
|
||||
const { aiMessage, report } = ctx;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
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(ctx.context);
|
||||
const created = addTask(store, {
|
||||
title: a.title,
|
||||
owner: a.owner,
|
||||
due: a.due,
|
||||
notes: a.notes,
|
||||
status: a.status,
|
||||
});
|
||||
writeTaskStore(ctx.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(ctx.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(ctx.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(ctx.context);
|
||||
const closed = completeTask(store, a.id);
|
||||
if (!closed) {
|
||||
report.push(`❌ Complete Task: ${a.id} 못 찾음 (이미 done 이거나 존재 X)`);
|
||||
} else {
|
||||
writeTaskStore(ctx.context, store);
|
||||
report.push(`✅ Task Done: ${closed.id} · ${closed.title}`);
|
||||
}
|
||||
} catch (err: any) { report.push(`❌ Complete Task Error: ${err?.message ?? String(err)}`); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as vscode from 'vscode';
|
||||
import type { TransactionManager } from '../../core/transaction';
|
||||
import type { ChatMessage } from '../../agent';
|
||||
|
||||
/**
|
||||
* `executeActions` 가 매 턴 새로 만드는 공유 컨텍스트. 모든 action handler 가
|
||||
* 같은 객체를 받아서 작업 결과를 누적 — 결과 텍스트 (`report`), brain 수정
|
||||
* 플래그, 첫 생성 파일 경로 등이 모두 여기 모인다.
|
||||
*
|
||||
* 모든 handler 는 같은 signature: `apply<Group>Actions(ctx: HandlerContext): Promise<void>`.
|
||||
* 반환값은 없음 — 결과는 ctx 객체에 *mutate* 로 누적된다 (배열 push, 콜백 호출).
|
||||
*
|
||||
* 왜 free function + ctx 패턴인가:
|
||||
* - 15+ 종류의 action handler 가 transactionManager / report / chatHistory
|
||||
* 를 모두 공유. 매번 args 로 7-8개 던지면 호출부가 지저분해짐.
|
||||
* - executeActions 의 try/catch 가 transactionManager.rollback() 을 책임지므로
|
||||
* handler 들은 throw 만 잘 하면 됨 (FileSystemError 등).
|
||||
*/
|
||||
export interface HandlerContext {
|
||||
/** AI 가 한 턴에 내뱉은 raw text — handler 들이 자기 regex 로 자기 tag 만 추출. */
|
||||
aiMessage: string;
|
||||
/** 워크스페이스 루트 — validatePath() 로 sandbox 보장. */
|
||||
rootPath: string;
|
||||
/** 활성 brain 의 절대 디렉토리. brain 안 파일이면 brainModified 플래그 set. */
|
||||
activeBrainDir: string;
|
||||
/** Handler 들이 사용자에게 보일 결과 라인을 push 하는 곳. ex: "✅ Created: foo.ts". */
|
||||
report: string[];
|
||||
/** 일부 handler (read_file, list_files, read_brain, sheet_read, calendar) 는
|
||||
* 결과를 다음 턴의 컨텍스트에 주입하기 위해 internal system message 로 push. */
|
||||
chatHistory: ChatMessage[];
|
||||
/** brain 안 파일이 수정됐다고 표시 — executeActions 가 끝나서 자동 sync 결정. */
|
||||
markBrainModified: () => void;
|
||||
/** 새로 생성된 파일 절대경로를 기록 — executeActions 가 끝나서 editor 에 열기. */
|
||||
setFirstCreated: (absPath: string) => void;
|
||||
/** dry-run / commit / rollback 라이프사이클은 호출자(executeActions)가 책임.
|
||||
* handler 들은 record() 만 호출 (state-changing action 시점에). */
|
||||
transactionManager: TransactionManager;
|
||||
/** vscode.ExtensionContext — feature module (calendar/sheets/tasks) 들이 OAuth
|
||||
* 토큰 등 secrets / globalState 에 접근할 때 필요. */
|
||||
context: vscode.ExtensionContext;
|
||||
}
|
||||
Reference in New Issue
Block a user