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:
g1nation
2026-05-25 09:59:32 +09:00
parent 4153f640c2
commit 0a97324f1b
149 changed files with 14628 additions and 6927 deletions
+54
View File
@@ -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}`); }
}
}
+43
View File
@@ -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)}`); }
}
}
+73
View File
@@ -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);
}
}
}
+44
View File
@@ -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}`); }
}
}
+31
View File
@@ -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}`); }
}
}
+20
View File
@@ -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}`); }
}
}
+87
View File
@@ -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)}`); }
}
}
+69
View File
@@ -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)}`); }
}
}
+41
View File
@@ -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;
}