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
+384 -3033
View File
File diff suppressed because it is too large Load Diff
+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;
}
+116
View File
@@ -0,0 +1,116 @@
import type { TaskStatus } from '../features/tasks';
/**
* Action-tag attribute 파서 3개 — pure / stateless / 테스트에서 직접 import.
*
* 공통 패턴: `key="value"` | `key='value'` | `key=bare` 모두 받아서 object 로.
* LLM 이 어떤 따옴표 스타일로 emit 해도 통과시키기 위함 — 모델 prompt 에 "큰
* 따옴표만 써" 같은 규칙을 강제하면 다른 환각이 늘어나서, 파서 쪽이 관대.
*
* `_` 접두는 "agent.ts 의 internal 이지만 테스트용으로만 export" 표시 — 외부
* 사용처는 agent.ts 의 executeActions 와 tests/{taskStore,sheetsApi,calendarApi}.test.ts.
* agent.ts 는 이 모듈을 re-export 해서 기존 import 경로 (`from '../src/agent'`) 유지.
*/
const ATTR_RE = /([\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g;
/**
* <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?: TaskStatus;
} {
const out: any = {};
const re = new RegExp(ATTR_RE.source, '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 = new RegExp(ATTR_RE.source, '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 = {};
// `-` 포함 키 (all-day) 지원 — 일부러 ATTR_RE 와 동일 패턴이지만 매번 fresh
// regex 인스턴스를 만들어 lastIndex 공유 버그를 회피.
const re = new RegExp(ATTR_RE.source, '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;
}
@@ -0,0 +1,159 @@
import * as vscode from 'vscode';
import { logInfo, logError } from '../../utils';
import type { ChatMessage } from '../../agent';
import {
extractVisibleFinal,
shouldAutoContinue,
mergeContinuationParts,
buildContinuationUserPrompt,
CONTINUATION_SYSTEM_PROMPT,
} from '../../core/responseRecovery';
import { isRestartedAnswer } from '../../lib/contextBuilders/outputSanitization';
import {
estimateTokens,
estimateMessagesTokens,
computeOutputBudget,
classifyStopReason,
type ContextLimits,
} from '../../lib/contextManager';
import { recordTelemetry } from '../../core/telemetry';
/** Result shape of `extractVisibleFinal` — kept structural here to avoid a hard import dependency. */
type CleanedAnswer = ReturnType<typeof extractVisibleFinal>;
export interface ApplyAutoContinuationDeps {
/** Bound `this.streamChatOnce` — same params shape as the original method. */
streamChatOnce: (params: any) => Promise<{ text: string; stopReason?: string; aborted: boolean }>;
/** Bound `this.isStaleRun`. */
isStaleRun: (runId: number) => boolean;
/** Live AbortSignal getter — controller is reassigned across turns. */
getAbortSignal: () => AbortSignal | undefined;
/** Webview for posting `autoContinue` UI updates. */
getWebview: () => vscode.Webview | undefined;
}
export interface ApplyAutoContinuationInput {
/** From `extractVisibleFinal(aiResponseText)`. The loop mutates `cleaned.visible`. */
cleaned: CleanedAnswer;
finishStopReason: string | undefined;
prompt: string | null;
chatHistory: ChatMessage[]; // used to find the original user prompt fallback
maxOutputTokens: number; // the first generation's output budget (carried for first round)
ctxLimits: ContextLimits;
config: any; // getConfig() — we read autoContinueOnOutputLimit, maxAutoContinuations, contextOverflowPolicy
runId: number;
useLmStudioSdk: boolean;
engine: string;
ollamaUrl: string;
actualModel: string;
temperature: number;
postLiveDeltas: boolean;
}
export interface ApplyAutoContinuationResult {
cleaned: CleanedAnswer;
finishStopReason: string | undefined;
continuationCount: number;
}
export async function applyAutoContinuation(
deps: ApplyAutoContinuationDeps,
input: ApplyAutoContinuationInput,
): Promise<ApplyAutoContinuationResult> {
let {
cleaned,
finishStopReason,
prompt,
chatHistory,
maxOutputTokens,
ctxLimits,
config,
runId,
useLmStudioSdk,
engine,
ollamaUrl,
actualModel,
temperature,
postLiveDeltas,
} = input;
// (c) Auto-continuation — the visible answer hit the output-token ceiling.
let continuationCount = 0;
if (config.autoContinueOnOutputLimit && config.maxAutoContinuations > 0) {
const originalUserPrompt = prompt || (chatHistory.find(m => m.role === 'user' && typeof m.content === 'string')?.content as string) || '';
let lastOutputTokens = estimateTokens(cleaned.visible);
let lastMaxOutputTokens = maxOutputTokens; // budget the last round actually had (≠ first gen's after round 1)
while (
shouldAutoContinue(classifyStopReason(finishStopReason), cleaned.visible, lastOutputTokens, lastMaxOutputTokens)
&& continuationCount < config.maxAutoContinuations
&& !deps.getAbortSignal()?.aborted
&& !deps.isStaleRun(runId)
) {
continuationCount++;
const continuationStartMs = Date.now();
deps.getWebview()?.postMessage({ type: 'autoContinue', value: `답변이 길어 이어서 정리하는 중입니다... (${continuationCount}/${config.maxAutoContinuations})` });
try {
const contMsgs: ChatMessage[] = [
{ role: 'system', content: CONTINUATION_SYSTEM_PROMPT, internal: true },
{ role: 'user', content: buildContinuationUserPrompt(originalUserPrompt, cleaned.visible) },
];
lastMaxOutputTokens = computeOutputBudget(estimateMessagesTokens(contMsgs), ctxLimits).maxOutputTokens;
// Stream the continuation through the same channel as the main turn so
// the user sees the answer keep growing instead of freezing for 1030s
// while we silently call non-streaming. The trailing streamReplace
// (after sanitize / merge) corrects any overlap the model re-emits.
const cr = await deps.streamChatOnce({
runId, useLmStudioSdk, engine, ollamaUrl, modelName: actualModel,
messages: contMsgs,
temperature,
maxTokens: lastMaxOutputTokens,
contextLength: ctxLimits.contextLength,
contextOverflowPolicy: config.contextOverflowPolicy,
signal: deps.getAbortSignal()!,
postLiveDeltas,
});
if (cr.aborted) {
logInfo('Auto-continuation aborted mid-stream.', { model: actualModel, round: continuationCount });
break;
}
finishStopReason = cr.stopReason;
const ccl = extractVisibleFinal(cr.text);
if (!ccl.visible.trim()) {
logInfo('Continuation produced no visible text — stopping.', { model: actualModel, round: continuationCount });
break;
}
// A weak model often ignores "continue from here" and re-generates the
// whole answer from the top. Discard such a restart instead of merging
// it — otherwise the user gets the entire analysis twice.
if (isRestartedAnswer(cleaned.visible, ccl.visible)) {
logInfo('Continuation restarted the answer instead of continuing — discarding it.', { model: actualModel, round: continuationCount });
break;
}
const before = cleaned.visible;
cleaned = { ...cleaned, visible: mergeContinuationParts(cleaned.visible, ccl.visible), wasThoughtOnly: false };
lastOutputTokens = estimateTokens(ccl.visible);
logInfo('Auto-continued the answer.', { model: actualModel, round: continuationCount, addedChars: ccl.visible.length, totalChars: cleaned.visible.length, contStopReason: cr.stopReason, contMaxTokens: lastMaxOutputTokens });
recordTelemetry({
kind: 'continuation',
durationMs: Date.now() - continuationStartMs,
model: actualModel, engine,
outputTokens: lastOutputTokens,
round: continuationCount,
stopReason: cr.stopReason,
note: `addedChars=${ccl.visible.length} mergedAdd=${cleaned.visible.length - before.length}`,
});
// Guard against a continuation that adds (almost) nothing new after dedup — stop instead of spinning.
if (cleaned.visible.length - before.length < 20) {
logInfo('Continuation added negligible new text — stopping.', { model: actualModel, round: continuationCount });
break;
}
} catch (e: any) {
logError('Auto-continuation failed.', { model: actualModel, round: continuationCount, error: e?.message ?? String(e) });
break;
}
}
if (deps.isStaleRun(runId)) return { cleaned, finishStopReason, continuationCount };
}
return { cleaned, finishStopReason, continuationCount };
}
@@ -0,0 +1,75 @@
import { stripAstraFormattingForAgentMode } from '../../lib/contextBuilders/systemPromptShaping';
import { estimateTokens } from '../../lib/contextManager';
import { logInfo } from '../../utils';
export interface BuildAgentModeSystemPromptInput {
/** Base system prompt — `Astra: …` block etc. */
systemPrompt: string;
/** Agent skill content the user selected. */
agentSkillContext: string;
/** Pre-built mode-bridge context (or ''). */
modeBridgeCtx: string;
/** [PRIOR TURN CONCLUSION] 블록 — 직전 assistant 답변의 첫 문장 (또는 ''). */
priorConclusionCtx: string;
designerCtx: string;
secondBrainTraceCtx: string;
memoryCtx: string;
knowledgeContextForPrompt: string;
contextBlock: string;
negativeCtx: string;
/** For token-cost logging. */
actualModel: string;
/** For token-cost logging — getConfig().contextLength. */
contextLength: number;
}
export function buildAgentModeSystemPrompt(input: BuildAgentModeSystemPromptInput): string {
const {
systemPrompt,
agentSkillContext,
modeBridgeCtx,
priorConclusionCtx,
designerCtx,
secondBrainTraceCtx,
memoryCtx,
knowledgeContextForPrompt,
contextBlock,
negativeCtx,
actualModel,
contextLength,
} = input;
// The Agent's prompt IS the primary directive (role / persona / tone / output format),
// so it LEADS the system prompt — models anchor on the first persona they see, not the
// last, especially small ones. The Astra base prompt is reduced to neutral scaffolding
// (action tags, current date, anti-leak rules) and follows; a short reminder at the very
// end keeps the model from drifting back to a generic assistant.
const strippedSystemPrompt = stripAstraFormattingForAgentMode(systemPrompt);
const agentPromptText = (agentSkillContext || '').trim();
if (estimateTokens(agentPromptText) > Math.floor(contextLength * 0.5)) {
logInfo('Agent prompt is unusually large relative to the context window.', {
model: actualModel, agentPromptTokens: estimateTokens(agentPromptText), contextLength: contextLength,
});
}
const agentBlock = [
'[AGENT MODE — PRIMARY DIRECTIVE]',
'A specialized Agent has been selected by the user. The Agent System Prompt below is your',
'PRIMARY directive: it defines your role, persona, tone, and output format. Follow it exactly.',
'Everything after the Agent block (action-tag reference, date, brain/project context) is technical',
'scaffolding — use it only as the Agent\'s task requires. Do NOT impose a generic assistant',
'format (e.g. ## 요약 / ## 상세 설명 / ## 제안) unless the Agent explicitly asks for one.',
'',
'--- AGENT SYSTEM PROMPT START ---',
agentPromptText || '(this agent has no instructions yet — fall back to being a concise, direct assistant)',
'--- AGENT SYSTEM PROMPT END ---',
].join('\n');
const agentTailReminder = '\n\n[REMINDER] You are operating as the Agent defined above. Keep its role, persona, and output format. Do not fall back to a default assistant style or section format.';
// [CONTEXT] … [/CONTEXT] 사이만 컨텍스트 초과 시 trim 대상 — agentBlock(앞)·reminder(뒤)·negative 는 보호.
// memoryCtx(RAG/메모리/lessons)도 [CONTEXT] 안에 넣어 토큰이 빡빡할 때 대화 기록보다 먼저 잘리게 한다.
const priorConclusionBlock = priorConclusionCtx ? '\n\n' + priorConclusionCtx : '';
const fullSystemPrompt = `${agentBlock}${modeBridgeCtx ? '\n\n' + modeBridgeCtx : ''}${priorConclusionBlock}\n\n${strippedSystemPrompt}${designerCtx}${secondBrainTraceCtx}\n\n[CONTEXT]\n${memoryCtx}\n${knowledgeContextForPrompt}\n${contextBlock}\n[/CONTEXT]\n${negativeCtx}${agentTailReminder}`;
return fullSystemPrompt;
}
@@ -0,0 +1,82 @@
import {
isProjectKnowledgeCreationRequest,
buildAstraStanceContext,
} from '../../lib/contextBuilders/localProjectIntent';
import { isThinkingPartnerRequest } from '../../lib/contextBuilders/promptDetection';
import { buildKnowledgeMixPolicy } from '../../retrieval/knowledgeMix';
export interface BuildAstraModeSystemPromptInput {
prompt: string | null;
systemPrompt: string;
modeBridgeCtx: string;
/** [PRIOR TURN CONCLUSION] 블록 — 직전 assistant 답변의 첫 문장. follow-up 정정 대응용. */
priorConclusionCtx: string;
designerCtx: string;
projectArchitectureCtx: string;
secondBrainTraceCtx: string;
memoryCtx: string;
knowledgeContextForPrompt: string;
contextBlock: string;
negativeCtx: string;
isCasualConversation: boolean;
localPathContext: string;
/** From this._turnCtx.knowledgeMix — pass null when absent. */
knowledgeMix: any;
}
export function buildAstraModeSystemPrompt(input: BuildAstraModeSystemPromptInput): string {
const {
prompt,
systemPrompt,
modeBridgeCtx,
priorConclusionCtx,
designerCtx,
projectArchitectureCtx,
secondBrainTraceCtx,
memoryCtx,
knowledgeContextForPrompt,
contextBlock,
negativeCtx,
isCasualConversation,
localPathContext,
knowledgeMix,
} = input;
// 기존 Astra 모드 (에이전트 미선택)
const localProjectKnowledgeCtx = prompt && localPathContext && isProjectKnowledgeCreationRequest(prompt)
? `\n\n[LOCAL PROJECT KNOWLEDGE CREATION OVERRIDE]\nThe user gave an accessible local project path and asked to create project knowledge. Do not ask blocking scope questions. Use a sensible default MVP: create or propose a project overview note from the inspected tree and priority file previews. If writing is not explicitly safe, provide the concrete note draft and target path.`
: '';
const thinkingPartnerCtx = prompt && !isCasualConversation && isThinkingPartnerRequest(prompt)
? `\n\n[JARVIS THINKING PARTNER MODE]\nThe user is using this tool to clarify project direction, not just to receive generic advice. Give a clear opinionated verdict first. Then separate confirmed facts, inferences, concerns, decision forks, and the next small action. Do not merely say the direction is good. If evidence is thin, say exactly what is missing and what file or record should be checked next.`
: '';
const astraStanceCtx = prompt && !isCasualConversation
? `\n\n${buildAstraStanceContext(prompt, localPathContext)}`
: '';
// The v4 knowledge-management policy only matters when knowledge is actually in play —
// skip it for greetings/small talk so it doesn't dilute the [CASUAL CONVERSATION MODE] directive.
const v4PolicyCtx = isCasualConversation ? '' : [
"\n### 🏛️ 지식 관리 정책 v4.0 (Knowledge Management Policy Applied)",
"- [신뢰도] '의도적으로 작성된 글'은 Medium 이상의 신뢰도를 부여하여 최우선 근거로 활용할 것.",
"- [품질] 데이터의 양보다 '추론 기여 밀도'를 중시하여 핵심 위주로 깊이 있게 서술할 것.",
"- [충돌] 지식 간 충돌 발생 시 시스템이 독단적으로 판단하지 말고, 반드시 [CONFLICT WARNING] 플래그와 함께 상충되는 두 관점을 모두 명시하여 사용자에게 판단을 위임할 것."
].join('\n');
// [CONTEXT] … [/CONTEXT] 사이만 컨텍스트 초과 시 trim 대상 — negative constraints 는 보호.
const casualCtx = isCasualConversation
? '\n\n[CASUAL CONVERSATION MODE]\nThe user sent a greeting, acknowledgement, or light conversational message. Reply naturally and briefly to the message itself. Do not use Second Brain, memory, project records, reports, references, or analysis unless the user explicitly asks for them.'
: '';
// Knowledge Mix policy: tells the model how strongly to lean on Second Brain
// evidence vs. its own general knowledge for this turn. Suppressed for casual
// chat — pure greetings don't need to be told anything about RAG balance.
const knowledgeMixCtx = (!isCasualConversation && knowledgeMix)
? (() => {
const block = buildKnowledgeMixPolicy(knowledgeMix);
return block ? `\n\n${block}` : '';
})()
: '';
// memoryCtx(RAG/메모리/lessons)는 [CONTEXT] 안에 — 토큰이 빡빡하면 대화 기록보다 먼저 잘림.
// priorConclusionCtx 는 modeBridgeCtx 와 같은 위치 (base systemPrompt 직후) — 모델이
// 자기 직전 결론을 anchor 로 잡고 사용자의 follow-up 을 그 결론에 대한 정정으로 해석하게.
const priorConclusionBlock = priorConclusionCtx ? '\n\n' + priorConclusionCtx : '';
return `${systemPrompt}${modeBridgeCtx ? '\n\n' + modeBridgeCtx : ''}${priorConclusionBlock}${designerCtx}${projectArchitectureCtx}${localProjectKnowledgeCtx}${thinkingPartnerCtx}${astraStanceCtx}${secondBrainTraceCtx}${v4PolicyCtx}${knowledgeMixCtx}${casualCtx}\n\n[CONTEXT]\n${memoryCtx}\n${knowledgeContextForPrompt}\n${contextBlock}\n[/CONTEXT]\n${negativeCtx}`;
}
@@ -0,0 +1,57 @@
import type { ChatMessage } from '../../agent';
import { computeModeSignature } from '../../lib/contextBuilders/systemPromptShaping';
import { buildLastTopicLine } from '../../lib/contextBuilders/lastTopicLine';
import { getActiveBrainProfile, logError } from '../../utils';
export interface BuildModeBridgeContextInput {
options: any; // the handlePrompt options object
lastModeSignature: string | null;
chatHistory: ChatMessage[];
}
export interface BuildModeBridgeContextResult {
/** "[MODE TRANSITION BRIDGE]\n…" string to embed in system prompt, or '' when no transition. */
modeBridgeCtx: string;
/** The newly-computed signature — caller should store this as the next-turn comparison baseline. */
newSignature: string | null;
}
export function buildModeBridgeContext(input: BuildModeBridgeContextInput): BuildModeBridgeContextResult {
const { options, lastModeSignature, chatHistory } = input;
// v2.2.69 — 모드 전환 bridge. 현재 mode signature 를 직전 값과 비교해 바뀌었으면
// "이전 대화는 X 모드에서 Y 주제로 진행됨 / 지금부터 Z 모드" 한 줄을 system prompt 에 끼운다.
// chatHistory 자체는 손대지 않으므로 사용자 입장에선 대화가 연속되어 보이면서도
// 모델은 "모드가 바뀐 직후" 임을 인지한다.
let modeBridgeCtx = '';
let newSignature: string | null = null;
try {
const agentSkillName = options.agentSkillContext
? (options.agentSkillContext.split('\n')[0] || '').slice(0, 60).replace(/^#\s*/, '').trim()
: '';
const currentSig = computeModeSignature({
agentSkillName: agentSkillName || undefined,
companyMode: !!(options as any).companyMode,
multiAgent: !!(options as any).multiAgent,
brainName: getActiveBrainProfile()?.name,
});
if (lastModeSignature !== null && lastModeSignature !== currentSig) {
const topic = buildLastTopicLine(chatHistory);
const bridgeLines = [
'',
'[MODE TRANSITION BRIDGE]',
`이전 모드: ${lastModeSignature}`,
`현재 모드: ${currentSig}`,
];
if (topic) bridgeLines.push(`직전 대화 주제(한 줄): ${topic}`);
bridgeLines.push('대화 history 는 그대로 이어진다. 새 모드의 페르소나/포맷을 따르되, 직전까지 사용자가 다루던 맥락을 잊지 말 것.');
modeBridgeCtx = bridgeLines.join('\n');
}
newSignature = currentSig;
} catch (e: any) {
logError('Mode-bridge computation failed (non-fatal).', { error: e?.message || String(e) });
return { modeBridgeCtx: '', newSignature: null };
}
return { modeBridgeCtx, newSignature };
}
@@ -0,0 +1,117 @@
import * as vscode from 'vscode';
import * as path from 'path';
import type { ChatMessage } from '../../agent';
import type { BrainProfile } from '../../config';
import { findBrainFiles } from '../../utils';
import {
isExplicitSecondBrainRequest,
isSecondBrainInventoryRequest,
} from '../../lib/contextBuilders/promptDetection';
import { buildSecondBrainInventoryContext } from '../../lib/contextBuilders/secondBrainInventory';
import { buildLocalProjectPathContext } from '../../lib/contextBuilders/localProjectPath';
import { buildRecentProjectKnowledgeContext } from '../../lib/contextBuilders/recentProjectKnowledge';
import { buildJarvisProjectBriefContext } from '../../lib/contextBuilders/jarvisProjectBrief';
import { buildAstraModeArchitectureContext } from '../../lib/contextBuilders/astraModeArchitecture';
import { buildSecondBrainTrace, SecondBrainTrace } from '../../features/secondBrainTrace';
export interface BuildTurnContextBlocksInput {
prompt: string | null;
options: any; // the handlePrompt options object (we read secondBrainTraceEnabled, brainProfileId)
isCasualConversation: boolean;
loopDepth: number;
config: any; // getConfig() result — we read memoryLongTermFiles, maxContextSize, brainProfiles
activeBrain: BrainProfile;
chatHistory: ChatMessage[];
rootPath: string;
}
export interface BuildTurnContextBlocksResult {
contextBlock: string;
brainContext: string;
brainInventoryCtx: string;
brainFiles: string[];
brainPreview: string;
localPathContext: string;
secondBrainTrace: SecondBrainTrace | null;
}
export function buildTurnContextBlocks(input: BuildTurnContextBlocksInput): BuildTurnContextBlocksResult {
const {
prompt,
options,
isCasualConversation,
loopDepth,
config,
activeBrain,
chatHistory,
rootPath,
} = input;
let contextBlock = '';
const brainFiles = findBrainFiles(activeBrain.localBrainPath);
let secondBrainTrace: SecondBrainTrace | null = null;
if (options.secondBrainTraceEnabled && prompt && loopDepth === 0 && !isCasualConversation) {
secondBrainTrace = buildSecondBrainTrace(prompt, activeBrain.localBrainPath, {
force: isExplicitSecondBrainRequest(prompt),
limit: Math.max(config.memoryLongTermFiles, 5)
});
}
const brainPreview = brainFiles
.slice(0, 30)
.map(file => path.relative(activeBrain.localBrainPath, file))
.join('\n');
const brainContext = [
`[ACTIVE SECOND BRAIN]`,
`Use this Local Brain only when it is relevant to the user's current question.`,
`Name: ${activeBrain.name}`,
`Path: ${activeBrain.localBrainPath}`,
`Knowledge files: ${brainFiles.length}`,
activeBrain.description ? `Description: ${activeBrain.description}` : '',
brainPreview ? `Available file examples:\n${brainPreview}` : 'Files: none found'
].filter(Boolean).join('\n');
const brainInventoryCtx = prompt && !isCasualConversation && isSecondBrainInventoryRequest(prompt)
? `\n\n${buildSecondBrainInventoryContext(activeBrain, brainFiles)}`
: '';
const editor = vscode.window.activeTextEditor;
if (editor && editor.document.uri.scheme === 'file') {
const text = editor.document.getText();
const name = path.basename(editor.document.fileName);
if (text.trim().length > 0 && text.length < config.maxContextSize) {
contextBlock = `\n\n[Currently open file: ${name}]\n\`\`\`\n${text}\n\`\`\``;
}
}
const localPathContext = prompt && loopDepth === 0
? buildLocalProjectPathContext(prompt, rootPath)
: '';
if (localPathContext) {
contextBlock += `\n\n${localPathContext}`;
}
const recentProjectKnowledgeContext = prompt && loopDepth === 0 && !isCasualConversation && !localPathContext
? buildRecentProjectKnowledgeContext(prompt, rootPath, chatHistory)
: '';
if (recentProjectKnowledgeContext) {
contextBlock += `\n\n${recentProjectKnowledgeContext}`;
}
const projectBriefContext = prompt && loopDepth === 0 && !isCasualConversation
? buildJarvisProjectBriefContext(prompt, localPathContext, recentProjectKnowledgeContext)
: '';
if (projectBriefContext) {
contextBlock += `\n\n${projectBriefContext}`;
}
const modeArchitectureContext = prompt && loopDepth === 0 && !isCasualConversation
? buildAstraModeArchitectureContext(prompt)
: '';
if (modeArchitectureContext) {
contextBlock += `\n\n${modeArchitectureContext}`;
}
return {
contextBlock,
brainContext,
brainInventoryCtx,
brainFiles,
brainPreview,
localPathContext,
secondBrainTrace,
};
}
@@ -0,0 +1,161 @@
import { logInfo, logError } from '../../utils';
import type { ChatMessage } from '../../agent';
import {
estimateTokens,
estimateMessagesTokens,
computeOutputBudget,
trimHistoryToBudget,
truncateSystemPromptContext,
estimateModelParamsB,
type ContextLimits,
} from '../../lib/contextManager';
import { buildDroppedHistorySummary } from '../../lib/contextBuilders/droppedHistorySummary';
export interface ComputeBudgetedRequestInput {
fullSystemPrompt: string;
/** Caller is expected to have run `capChatHistory` on this already. */
reqMessages: ChatMessage[];
actualModel: string;
/** Result of `getConfig()` — reads contextLength, maxOutputTokens, contextSafetyMargin, smallModelContextCap, autoCompactHistory. */
config: any;
imageCount: number;
}
export interface ComputeBudgetedRequestResult {
messagesForRequest: ChatMessage[];
ctxLimits: ContextLimits;
inputTokens: number;
maxOutputTokens: number;
systemTokens: number;
systemTruncated: boolean;
droppedHistoryCount: number;
budgetedHistoryLength: number;
/** Exact return shape of `computeOutputBudget`. */
outputBudget: { maxOutputTokens: number; available: number; tight: boolean };
modelParamB: number | null;
cappedForSmallModel: boolean;
}
/**
* 입력(시스템 프롬프트 + 대화 기록 + 이미지)을 컨텍스트 윈도우 예산에 맞게 정리하고
* 최종 요청 메시지 배열과 동적 출력 상한을 계산합니다.
*
* 호출 측에서 미리 capChatHistory 로 메시지 개수를 캡한 뒤 넘겨주는 것을 전제로 합니다
* (AgentExecutor.MAX_RETAINED_MESSAGES 같은 정적 한도는 이 함수의 관심사가 아닙니다).
*/
export function computeBudgetedRequest(input: ComputeBudgetedRequestInput): ComputeBudgetedRequestResult {
const { fullSystemPrompt, reqMessages, actualModel, config, imageCount } = input;
// ──────────────────────────────────────────────────────────────────
// [Context Limit Manager] context length 는 "답변을 그만큼 길게 써도 된다"
// 는 뜻이 아니다: 시스템 프롬프트 + 대화 기록 + 입력 + 생성될 답변 + 여유분 ≤ context length.
// 요청을 보내기 전에 입력 토큰을 추정해서
// (1) 시스템 프롬프트가 과하면 [CONTEXT] 블록을 마지막 수단으로 줄이고
// (2) 대화 기록을 남은 예산에 맞게 압축하고 (UI 표시용 chatHistory 는 건드리지 않음)
// (3) 동적으로 출력 상한(maxOutputTokens)을 계산한다.
// ──────────────────────────────────────────────────────────────────
// Optional opt-in guard (g1nation.smallModelContextCap, OFF/0 by default): some very small
// models (≤3B) emit EOS as the first token when the prompt is near their context window
// even though it nominally fits. If the user opted in, budget ≤3B models against that
// smaller effective window. Never applied to 4B+ models, and never when the setting is 0 —
// capping squeezes the output-token budget, so it's a knob, not a default.
const modelParamB = estimateModelParamsB(actualModel);
const smallModelCap = config.smallModelContextCap; // 0 = disabled (default)
const cappedForSmallModel = smallModelCap > 0
&& modelParamB !== null && modelParamB <= 3
&& config.contextLength > smallModelCap;
const effectiveContextLength = cappedForSmallModel ? smallModelCap : config.contextLength;
if (cappedForSmallModel) {
logInfo('Small model detected — capping effective context window for budgeting.', {
model: actualModel, paramB: modelParamB,
nominalContext: config.contextLength, effectiveContext: effectiveContextLength,
});
}
const ctxLimits: ContextLimits = {
contextLength: effectiveContextLength,
maxOutputTokens: config.maxOutputTokens,
safetyMargin: config.contextSafetyMargin,
minOutputTokens: 512,
};
const imageTokenReserve = imageCount * 1024;
// Output budget we ACTUALLY reserve before trimming — not the bare
// minOutputTokens floor (512). If we only reserve 512, a long session
// is allowed to grow the prompt until ~512-1k tokens remain for the
// answer; small/MoE local models (e.g. gemma 4B-active) then emit EOS
// as the first token and return an empty response. Reserving ~10% of
// the window (>=2048) forces history/system trimming to keep a real
// answer-sized hole open. Capped at maxOutputTokens.
const preferredOutputReserve = Math.min(
ctxLimits.maxOutputTokens,
Math.max(2048, Math.floor(ctxLimits.contextLength * 0.1))
);
// (1) 시스템 프롬프트는 예산의 ~65%까지만 허용 — 그 이상이면 [CONTEXT] 블록부터 잘라낸다.
const systemCapTokens = Math.max(
1024,
Math.floor((ctxLimits.contextLength - ctxLimits.safetyMargin - preferredOutputReserve - imageTokenReserve) * 0.65)
);
const { prompt: budgetedSystemPrompt, truncated: systemTruncated } =
truncateSystemPromptContext(fullSystemPrompt, systemCapTokens);
if (systemTruncated) {
logInfo('System prompt context truncated to fit the context window.', { model: actualModel, systemCapTokens });
}
const systemTokens = estimateTokens(budgetedSystemPrompt) + 4;
// (2) 대화 기록 압축.
const historyBudget = Math.max(
256,
ctxLimits.contextLength - systemTokens - ctxLimits.safetyMargin - preferredOutputReserve - imageTokenReserve
);
let budgetedHistory: ChatMessage[] = reqMessages;
if (config.autoCompactHistory) {
// v2.2.69 — dropped 메시지를 받아 heuristic 요약을 만든 뒤 한 system 메시지로 prepend.
// 단순 count 마커는 "이전에 무슨 얘기를 했는지" 를 전혀 알려주지 않아 후속 턴에서 모델이
// 맥락을 잃어버리는 회귀를 낳았다. 이제는 U1/A1/U2/A2 골자가 남아 sliding window 가 동작.
const trim = trimHistoryToBudget<ChatMessage>(reqMessages, historyBudget, (_n, dropped) => ({
role: 'system',
content: buildDroppedHistorySummary(dropped),
internal: true,
}));
budgetedHistory = trim.messages;
if (trim.droppedCount > 0) {
logInfo('Conversation history compacted to fit the context window (with summary).', {
model: actualModel, droppedCount: trim.droppedCount, historyBudget,
});
}
}
const messagesForRequest: ChatMessage[] = [
{ role: 'system', content: budgetedSystemPrompt, internal: true },
...budgetedHistory
];
// (3) 동적 출력 상한.
const inputTokens = estimateMessagesTokens(messagesForRequest) + imageTokenReserve;
const outputBudget = computeOutputBudget(inputTokens, ctxLimits);
const maxOutputTokens = outputBudget.maxOutputTokens;
if (outputBudget.tight) {
logError('Prompt nearly fills the context window — output budget is at the minimum.', {
model: actualModel, contextLength: ctxLimits.contextLength, inputTokens, maxOutputTokens,
});
}
logInfo('Context budget computed.', {
model: actualModel, contextLength: ctxLimits.contextLength,
inputTokens, maxOutputTokens, droppedHistory: reqMessages.length - budgetedHistory.length,
});
return {
messagesForRequest,
ctxLimits,
inputTokens,
maxOutputTokens,
systemTokens,
systemTruncated,
droppedHistoryCount: reqMessages.length - budgetedHistory.length,
budgetedHistoryLength: budgetedHistory.length,
outputBudget,
modelParamB,
cappedForSmallModel,
};
}
@@ -0,0 +1,147 @@
import { logError } from '../../utils';
import { getConfig, BrainProfile } from '../../config';
import { stripMarkdownFormatting, looksCutOff } from '../../core/responseRecovery';
import {
sanitizeAssistantContent,
parseRationale,
} from '../../lib/contextBuilders/outputSanitization';
import {
isSecondBrainInventoryRequest,
isNoBrainDataRefusal,
} from '../../lib/contextBuilders/promptDetection';
import { buildSecondBrainInventoryFallbackAnswer } from '../../lib/contextBuilders/secondBrainInventory';
import { isProjectKnowledgeCreationRequest } from '../../lib/contextBuilders/localProjectIntent';
import {
buildProjectKnowledgeFallbackAnswer,
writeProjectKnowledgeRecord,
} from '../../lib/contextBuilders/projectKnowledge';
import { enforceLocalPathReviewAnswer } from '../../lib/contextBuilders/localProjectPath';
import { isBlockingProjectKnowledgeAnswer } from '../../lib/contextBuilders/recentProjectKnowledge';
import {
enforceProjectClaimPolicyInAnswer,
SecondBrainTrace,
} from '../../features/secondBrainTrace';
import {
estimateTokens,
classifyStopReason,
truncationNotice,
shouldShowTruncationNotice,
} from '../../lib/contextManager';
export interface ProcessFinalAnswerInput {
/** Raw `cleaned.visible` from extractVisibleFinal(). */
visibleAnswer: string;
prompt: string | null;
secondBrainTrace: SecondBrainTrace | null;
localPathContext: string;
activeBrain: BrainProfile;
brainFiles: string[];
finishStopReason: string | undefined;
maxOutputTokens: number;
/** From earlier phases — used in logError noise. */
actualModel: string;
engine: string;
inputTokens: number;
}
export interface ProcessFinalAnswerResult {
/** post-stripMarkdown 1차 — agent.ts 의 `executeActions(cleanedVisible, …)` 호출에 그대로 전달. */
cleanedVisible: string;
/** post-enforcers, pre-final-stripMarkdown — used for executeActions and history. */
assistantContent: string;
/** post-stripMarkdown-FINAL — emitted to webview. */
finalAssistantContent: string;
rationale: ReturnType<typeof parseRationale>;
outputTokens: number;
stopKind: ReturnType<typeof classifyStopReason>;
}
export function processFinalAnswer(input: ProcessFinalAnswerInput): ProcessFinalAnswerResult {
const {
visibleAnswer,
prompt,
secondBrainTrace,
localPathContext,
activeBrain,
brainFiles,
finishStopReason,
maxOutputTokens,
actualModel,
engine,
inputTokens,
} = input;
// [Plain Text Output] outputFormat='plain' (기본)이면 모델이 무심코 내보낸
// 마크다운 마커(`##`, `**`, `> `, `* ` …) 를 후처리로 모두 제거. 라벨 텍스트는 유지.
// markdown 모드면 legacy 그대로 통과.
const cleanedVisible = getConfig().outputFormat === 'plain'
? stripMarkdownFormatting(visibleAnswer)
: visibleAnswer;
// 5. Execute Actions
const rationale = parseRationale(cleanedVisible);
let assistantContent = enforceLocalPathReviewAnswer(
enforceProjectClaimPolicyInAnswer(
sanitizeAssistantContent(cleanedVisible),
secondBrainTrace
),
localPathContext
);
if (prompt && isSecondBrainInventoryRequest(prompt) && brainFiles.length > 0 && isNoBrainDataRefusal(assistantContent)) {
assistantContent = buildSecondBrainInventoryFallbackAnswer(activeBrain, brainFiles, secondBrainTrace);
}
// Note: a previous implementation replaced LLM review answers with a
// hardcoded Korean template whenever the answer didn't match enough
// keywords. That made every review feel canned and project-agnostic
// (the template was Datacollector-flavored). We now let the LLM's
// answer stand — the system prompt for review-evaluation
// (buildLocalProjectIntentGuidance / buildAstraStanceContext) is
// strong enough to keep the response concrete.
if (prompt && localPathContext && isProjectKnowledgeCreationRequest(prompt)) {
const record = writeProjectKnowledgeRecord(localPathContext);
if (isBlockingProjectKnowledgeAnswer(assistantContent)) {
assistantContent = buildProjectKnowledgeFallbackAnswer(localPathContext, record);
} else if (record && !assistantContent.includes(record.filePath)) {
assistantContent = [
assistantContent,
'',
'## 생성된 기록',
`프로젝트 지식 기록을 생성했습니다: \`${record.filePath}\``
].join('\n');
}
}
// Surface truncated/abnormal generation so the user knows the answer is incomplete.
const stopKind = classifyStopReason(finishStopReason);
if (stopKind === 'output-limit' || stopKind === 'context-overflow' || stopKind === 'error') {
logError('Generation stopped abnormally.', {
model: actualModel, engine, stopReason: finishStopReason, stopKind,
inputTokens, maxOutputTokens, answerChars: assistantContent.length,
});
}
const outputTokens = estimateTokens(assistantContent);
// Show the "incomplete" notice when the engine said output-limit/context-overflow/error,
// OR when (after all auto-continuation rounds) the answer still plainly ends mid-sentence.
const notice =
shouldShowTruncationNotice(stopKind, outputTokens, maxOutputTokens) ? truncationNotice(stopKind)
: looksCutOff(assistantContent) ? truncationNotice('output-limit')
: '';
if (notice && assistantContent.trim()) {
assistantContent = assistantContent.trimEnd() + notice;
}
// [Plain Text Output — FINAL pass] enforcer 들이 `## 경로 확인 결과` 같은 하드코딩 헤더를
// 다시 prepend 한 후에도 마커가 남지 않도록, webview / chatHistory 에 들어가는 최종 문자열을
// 한 번 더 sanitize. cleanedVisible 단계의 1차 sanitize 는 model 출력 자체를 정리하고,
// 이 2차 sanitize 는 enforcer 출력까지 모두 청소한다.
const finalAssistantContent = getConfig().outputFormat === 'plain'
? stripMarkdownFormatting(assistantContent)
: assistantContent;
return {
cleanedVisible,
assistantContent,
finalAssistantContent,
rationale,
outputTokens,
stopKind,
};
}
+101
View File
@@ -0,0 +1,101 @@
import * as vscode from 'vscode';
import { ChatMessage } from '../../agent';
import { buildApiUrl, summarizeText } from '../../utils';
import { buildEngineMessageVariants } from '../../lib/contextBuilders/engineMessages';
import { samplingToRestBody } from '../../lmstudio/streamer';
import { lmStudioSamplingFromConfig } from '../../lib/contextBuilders/lmStudioSampling';
export interface CallNonStreamingDeps {
context: vscode.ExtensionContext;
}
export async function callNonStreaming(deps: CallNonStreamingDeps, params: {
baseUrl: string;
modelName: string;
engine: 'lmstudio' | 'ollama';
messages: ChatMessage[];
temperature: number;
maxTokens?: number;
contextLength?: number;
signal?: AbortSignal;
}): Promise<{ text: string; stopReason?: string }> {
const { baseUrl, modelName, engine, messages, temperature, signal } = params;
const maxTokens = Math.max(256, params.maxTokens ?? 4096);
// Cloud routing — streaming Response 를 받아 끝까지 모아서 텍스트로 환원.
// Non-streaming 전용 endpoint 를 따로 두지 않고 stream 결과를 모으는 게 단순.
try {
const { parseModelPrefix, streamCloudCompletion } =
require('../../features/providers') as typeof import('../../features/providers');
const hit = parseModelPrefix(modelName);
if (hit) {
const response = await streamCloudCompletion(deps.context, hit, {
messages: messages.map((m) => ({ role: m.role as any, content: m.content })),
temperature,
maxTokens,
signal,
});
if (!response.ok) {
const errText = await response.text().catch(() => '');
throw new Error(`Cloud (${hit.provider}) ${response.status}: ${summarizeText(errText, 200)}`);
}
// OpenAI 호환 SSE 를 통째로 읽어 delta.content 합치기.
const raw = await response.text();
let acc = '';
for (const line of raw.split('\n')) {
const t = line.trim();
if (!t.startsWith('data:')) continue;
const payload = t.slice(5).trim();
if (!payload || payload === '[DONE]') continue;
try {
const obj = JSON.parse(payload);
const delta = obj?.choices?.[0]?.delta?.content;
if (typeof delta === 'string') acc += delta;
} catch { /* skip malformed */ }
}
return { text: acc, stopReason: 'stop' };
}
} catch (e) {
const msg = (e as Error)?.message ?? '';
if (msg.startsWith('Cloud (')) throw e;
}
const numCtx = Math.max(2048, params.contextLength ?? 32768);
const apiUrl = buildApiUrl(baseUrl, engine, 'chat');
const variants = buildEngineMessageVariants(messages, engine);
const sampling = samplingToRestBody(lmStudioSamplingFromConfig());
const body = {
model: modelName,
messages: variants[0].messages,
stream: false,
...(engine === 'lmstudio'
? { max_tokens: maxTokens, temperature, ...sampling }
: { options: { num_ctx: numCtx, num_predict: maxTokens, temperature, ...sampling } }),
};
const response = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal,
});
if (!response.ok) {
const errText = await response.text().catch(() => '');
throw new Error(`Non-streaming fallback returned ${response.status}: ${summarizeText(errText, 200)}`);
}
const text = await response.text();
try {
const json = JSON.parse(text);
if (engine === 'lmstudio') {
return {
text: json?.choices?.[0]?.message?.content ?? '',
stopReason: json?.choices?.[0]?.finish_reason,
};
}
return {
text: json?.message?.content ?? json?.response ?? '',
stopReason: json?.done_reason ?? (json?.done === true ? 'stop' : undefined),
};
} catch {
return { text: '' };
}
}
+169
View File
@@ -0,0 +1,169 @@
import * as vscode from 'vscode';
import {
buildApiUrl,
logError,
logInfo,
resolveEngine,
summarizeText,
} from '../../utils';
import { buildEngineMessageVariants } from '../../lib/contextBuilders/engineMessages';
import { buildModelCandidates } from '../../lib/contextBuilders/modelCandidates';
import { samplingToRestBody } from '../../lmstudio/streamer';
import { lmStudioSamplingFromConfig } from '../../lib/contextBuilders/lmStudioSampling';
import type { ChatMessage } from '../../agent';
export interface CreateStreamingRequestDeps {
context: vscode.ExtensionContext;
getAbortSignal: () => AbortSignal | undefined;
}
export async function createStreamingRequest(deps: CreateStreamingRequestDeps, params: {
baseUrl: string;
modelName: string;
reqMessages: ChatMessage[];
temperature: number;
/** Dynamic output-token cap computed from the remaining context budget. */
maxTokens?: number;
/** Model context window in tokens (used for Ollama's num_ctx). */
contextLength?: number;
}): Promise<{ response: Response; engine: 'lmstudio' | 'ollama'; apiUrl: string }> {
const { baseUrl, modelName, reqMessages, temperature } = params;
const maxTokens = Math.max(256, params.maxTokens ?? 4096);
// Cloud provider 라우팅 — model id 가 'openrouter:' / 'anthropic:' / 'gemini:' 로 시작하면
// 해당 adapter 호출. body 는 OpenAI 호환 SSE 로 transform 되어 반환되므로
// 아래 로컬 엔진 경로의 consumer 가 동일하게 처리.
try {
const { parseModelPrefix, streamCloudCompletion } =
require('../../features/providers') as typeof import('../../features/providers');
const hit = parseModelPrefix(modelName);
if (hit) {
logInfo('AI streaming request (cloud).', { provider: hit.provider, model: hit.model });
const response = await streamCloudCompletion(deps.context, hit, {
messages: reqMessages.map((m) => ({ role: m.role as any, content: m.content })),
temperature,
maxTokens,
signal: deps.getAbortSignal(),
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`Cloud (${hit.provider}) ${response.status}: ${summarizeText(errText, 300)}`);
}
return { response, engine: 'lmstudio', apiUrl: `cloud://${hit.provider}/${hit.model}` };
}
} catch (e) {
// 모듈 로드 실패 / 매칭 안 됨 — 로컬 경로로 fall through.
// (단, 명시적으로 cloud routing 했는데 실패한 경우는 throw 되어 위에서 catch 됨.)
const msg = (e as Error)?.message ?? '';
if (msg.startsWith('Cloud (')) throw e;
}
const numCtx = Math.max(2048, params.contextLength ?? 32768);
const engine = resolveEngine(baseUrl); // 사용자가 설정한 엔진만 사용
const apiUrl = buildApiUrl(baseUrl, engine, 'chat');
const messageVariants = buildEngineMessageVariants(reqMessages, engine);
const modelCandidates = buildModelCandidates(modelName, engine);
let lastError: Error | null = null;
// 같은 엔진 내에서만 model candidate / message variant retry
for (const candidateModel of modelCandidates) {
for (const variant of messageVariants) {
const sampling = samplingToRestBody(lmStudioSamplingFromConfig());
const streamBody = {
model: candidateModel,
messages: variant.messages,
stream: true,
...(engine === 'lmstudio'
// LM Studio's OpenAI-compatible REST extends the schema with top_k/min_p/
// repeat_penalty (same names as Ollama). Spread the shared sampling block so
// the REST fallback matches the SDK path — without it a fallback after a
// dead handle quietly loses the glitch-suppression preset.
? { max_tokens: maxTokens, temperature, ...sampling }
: { options: { num_ctx: numCtx, num_predict: maxTokens, temperature, ...sampling } }),
};
// 일시적 네트워크 오류용 retry (최대 2회, 지수 backoff)
const MAX_RETRIES = 2;
let serviceDown = false;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
if (attempt > 0) {
const delay = 500 * Math.pow(2, attempt - 1); // 500ms, 1000ms
await new Promise(r => setTimeout(r, delay));
logInfo('AI streaming request retry.', { engine, attempt, model: candidateModel });
}
logInfo('AI streaming request started.', {
engine, apiUrl, model: candidateModel,
variant: variant.name, messageCount: variant.messages.length,
attempt
});
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
},
body: JSON.stringify(streamBody),
signal: deps.getAbortSignal(),
keepalive: true
});
if (!response.ok) {
const errText = await response.text();
lastError = new Error(`AI Engine error (${engine}/${variant.name}): ${response.status} - ${summarizeText(errText, 300)}`);
logError('AI streaming request returned non-OK status.', {
engine, variant: variant.name, apiUrl,
status: response.status, body: summarizeText(errText, 500)
});
// 4xx는 재시도해도 의미없음. 5xx만 재시도.
if (response.status >= 400 && response.status < 500) break;
continue;
}
logInfo('AI streaming request connected.', { engine, variant: variant.name, apiUrl });
return { response, engine, apiUrl };
} catch (error: any) {
lastError = error instanceof Error ? error : new Error(String(error));
// AbortError는 사용자가 취소한 것이므로 retry 금지
if (lastError.name === 'AbortError') {
throw lastError;
}
// ECONNREFUSED / DNS-level failures mean the engine process isn't even
// listening — no amount of retries or message-variant juggling will help.
// Abandon the candidate/variant loops now and surface the "is X running?"
// error fast instead of burning 12 fetch attempts before giving up.
const errCode = (error?.cause?.code ?? error?.code ?? '').toString();
const errMsg = lastError.message;
if (
errCode === 'ECONNREFUSED' || errCode === 'ENOTFOUND' || errCode === 'EAI_AGAIN'
|| /ECONNREFUSED|ENOTFOUND|getaddrinfo|fetch failed/i.test(errMsg)
) {
serviceDown = true;
logError('AI streaming request: engine appears to be down.', {
engine, apiUrl, code: errCode, error: errMsg,
});
break; // exit retry loop
}
logError('AI streaming request failed.', {
engine, variant: variant.name, apiUrl, model: candidateModel,
attempt, error: lastError.message
});
}
}
if (serviceDown) break; // skip remaining variants
}
// serviceDown also short-circuits the model-candidate loop — there is no
// candidate / variant the engine can answer if it isn't listening at all.
if (lastError && /ECONNREFUSED|ENOTFOUND|fetch failed/i.test(lastError.message)) break;
}
// 명확한 에러 메시지: 어느 엔진이 실패했는지 사용자에게 알림
const engineLabel = engine === 'lmstudio' ? 'LM Studio' : 'Ollama';
throw new Error(
`${engineLabel} 엔진에 연결할 수 없습니다. ` +
`${engineLabel}가 실행 중이고 모델 '${modelName}'이 로드되어 있는지 확인하세요. ` +
`(원인: ${lastError?.message || 'unknown'})`
);
}
+74
View File
@@ -0,0 +1,74 @@
import * as vscode from 'vscode';
import { logInfo } from '../../utils';
import { ChatMessage } from '../../agent';
export interface DevilRebuttalDeps {
getAbortSignal: () => AbortSignal | undefined;
callNonStreaming: (params: {
baseUrl: string;
modelName: string;
engine: 'lmstudio' | 'ollama';
messages: ChatMessage[];
temperature: number;
maxTokens?: number;
contextLength?: number;
signal?: AbortSignal;
}) => Promise<{ text: string; stopReason?: string }>;
getWebview: () => vscode.Webview | undefined;
}
/**
* Devil Agent 반박 emit — main turn 완료 직후 호출 (fire-and-forget).
* 비활성 시 즉시 return. 활성 시 별도 LLM 호출 (callNonStreaming 재사용) 로 짧은 비판 생성.
* 성공 시 webview 에 'devilRebuttal' 메시지 전송 → UI 가 카드로 렌더.
*/
export async function maybeEmitDevilRebuttal(deps: DevilRebuttalDeps, opts: {
userPrompt: string;
assistantAnswer: string;
baseUrl: string;
modelName: string;
contextLength: number;
engine: 'lmstudio' | 'ollama';
}): Promise<void> {
try {
const { isDevilAgentEnabled, generateDevilRebuttal, DEVIL_PERSONA_NAME } =
await import('../../features/devilAgent');
if (!isDevilAgentEnabled()) return;
if (!opts.userPrompt.trim() || !opts.assistantAnswer.trim()) return;
// Local callLLM wrapper — callNonStreaming 재사용 (cloud / local 자동 라우팅).
const callLLM = async (system: string, userMessage: string, maxTokens: number) => {
const r = await deps.callNonStreaming({
baseUrl: opts.baseUrl,
modelName: opts.modelName,
engine: opts.engine,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: userMessage },
],
temperature: 0.7,
maxTokens,
contextLength: opts.contextLength,
signal: deps.getAbortSignal(),
});
return r.text;
};
const rebuttal = await generateDevilRebuttal(callLLM, {
userPrompt: opts.userPrompt,
assistantAnswer: opts.assistantAnswer,
});
if (!rebuttal) return;
deps.getWebview()?.postMessage({
type: 'devilRebuttal',
value: {
persona: DEVIL_PERSONA_NAME,
text: rebuttal,
// 사용자가 '재반박' 누를 때 원래 컨텍스트로 돌아갈 수 있게 stash.
userPrompt: opts.userPrompt,
assistantAnswer: opts.assistantAnswer,
},
});
} catch (e: any) {
// Devil 실패는 main 답변에 영향 없음 — silent log.
logInfo('Devil rebuttal skipped.', { error: e?.message ?? String(e) });
}
}
+136
View File
@@ -0,0 +1,136 @@
import * as vscode from 'vscode';
import { logError, summarizeText } from '../../utils';
import { lmStudioSamplingFromConfig, lmStudioRespondExtrasFromConfig } from '../../lib/contextBuilders/lmStudioSampling';
import type { AgentExecutorOptions, ChatMessage } from '../../agent';
export interface StreamChatOnceDeps {
options: AgentExecutorOptions;
getWebview: () => vscode.Webview | undefined;
isStaleRun: (runId: number) => boolean;
createStreamingRequest: (params: {
baseUrl: string;
modelName: string;
reqMessages: ChatMessage[];
temperature: number;
/** Dynamic output-token cap computed from the remaining context budget. */
maxTokens?: number;
/** Model context window in tokens (used for Ollama's num_ctx). */
contextLength?: number;
}) => Promise<{ response: Response; engine: 'lmstudio' | 'ollama'; apiUrl: string }>;
}
export async function streamChatOnce(deps: StreamChatOnceDeps, params: {
runId: number;
useLmStudioSdk: boolean;
engine: 'lmstudio' | 'ollama';
ollamaUrl: string;
modelName: string;
messages: ChatMessage[];
temperature: number;
maxTokens: number;
contextLength: number;
contextOverflowPolicy: 'stopAtLimit' | 'truncateMiddle' | 'rollingWindow';
signal: AbortSignal;
postLiveDeltas: boolean;
}): Promise<{ text: string; stopReason?: string; aborted: boolean }> {
let accumulated = '';
let finishStopReason: string | undefined;
const post = (token: string) => {
if (params.postLiveDeltas && token) {
deps.getWebview()?.postMessage({ type: 'streamChunk', value: token });
}
};
if (params.useLmStudioSdk) {
try {
const stream = deps.options.lmStudioStreamer!.stream({
modelName: params.modelName,
messages: params.messages.map((m) => ({ role: m.role, content: m.content })),
temperature: params.temperature,
maxTokens: params.maxTokens,
contextOverflowPolicy: params.contextOverflowPolicy,
...lmStudioSamplingFromConfig(),
...lmStudioRespondExtrasFromConfig(),
signal: params.signal,
});
for await (const { token, stopReason } of stream) {
if (deps.isStaleRun(params.runId)) {
return { text: accumulated, stopReason: finishStopReason, aborted: true };
}
if (token) {
accumulated += token;
post(token);
}
if (stopReason) finishStopReason = stopReason;
}
} catch (err: any) {
if (err?.name === 'AbortError' || params.signal.aborted) {
return { text: accumulated, stopReason: finishStopReason, aborted: true };
}
const msg = err?.message ?? String(err);
if (/context\s*length|contextlengthreached|exceed|too\s*long/i.test(msg)) {
finishStopReason = 'contextLengthReached';
}
logError('streamChatOnce SDK path failed.', { engine: params.engine, error: msg });
throw err;
}
return { text: accumulated, stopReason: finishStopReason, aborted: false };
}
const request = await deps.createStreamingRequest({
baseUrl: params.ollamaUrl,
modelName: params.modelName,
reqMessages: params.messages,
temperature: params.temperature,
maxTokens: params.maxTokens,
contextLength: params.contextLength,
});
const reader = request.response.body?.getReader();
if (!reader) throw new Error('Response body is not readable.');
const decoder = new TextDecoder();
let buffer = '';
const consumeJsonLine = (line: string) => {
const trimmed = line.trim();
if (!trimmed || trimmed === 'data: [DONE]') return;
try {
const raw = trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed;
const json = JSON.parse(raw);
const token = params.engine === 'lmstudio'
? json.choices?.[0]?.delta?.content || ''
: json.message?.content || json.response || '';
if (token) {
accumulated += token;
post(token);
}
const fr = params.engine === 'lmstudio'
? json.choices?.[0]?.finish_reason
: (json.done_reason ?? (json.done === true ? 'stop' : undefined));
if (fr) finishStopReason = fr;
} catch (e: any) {
logError('streamChatOnce: failed to parse chunk.', { engine: params.engine, chunk: summarizeText(trimmed, 200), error: e?.message ?? String(e) });
}
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (deps.isStaleRun(params.runId)) {
return { text: accumulated, stopReason: finishStopReason, aborted: true };
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) consumeJsonLine(line);
}
if (buffer.trim()) consumeJsonLine(buffer);
} catch (err: any) {
if (err?.name === 'AbortError') {
return { text: accumulated, stopReason: finishStopReason, aborted: true };
}
logError('streamChatOnce REST path failed.', { engine: params.engine, error: err?.message ?? String(err) });
throw err;
} finally {
try { reader.releaseLock(); } catch { /* already released on abort */ }
}
return { text: accumulated, stopReason: finishStopReason, aborted: false };
}
+56
View File
@@ -0,0 +1,56 @@
import * as vscode from 'vscode';
import { getActiveBrainProfile, logError, logInfo } from '../utils';
import { BrainProfile, getConfig } from '../config';
import { SessionManager } from '../core/session';
import { ChatMessage } from '../agent';
export interface RestoreLastSessionDeps {
sessionManager: SessionManager;
setChatHistory: (history: ChatMessage[]) => void;
setCurrentTaskId: (taskId: string) => void;
}
export async function restoreLastSession(deps: RestoreLastSessionDeps): Promise<void> {
try {
const lastSession = deps.sessionManager.loadLastActiveSession();
if (lastSession) {
deps.setChatHistory(lastSession.history);
deps.setCurrentTaskId(lastSession.taskId);
logInfo(`Restored last session: ${lastSession.taskId}`);
}
} catch (error) {
logError('Failed to restore last session. Starting fresh.', error);
}
}
export interface ExecuteActionTagsOnTextDeps {
executeActions: (aiMessage: string, rootPath: string, activeBrain: BrainProfile) => Promise<string[]>;
}
export async function executeActionTagsOnText(
deps: ExecuteActionTagsOnTextDeps,
aiMessage: string
): Promise<string[]> {
const cfg = getConfig();
const rootPath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
|| cfg.localBrainPath
|| process.cwd();
const activeBrain = getActiveBrainProfile();
try {
return await deps.executeActions(aiMessage, rootPath, activeBrain);
} catch (e: any) {
logError('executeActionTagsOnText failed.', { error: e?.message ?? String(e) });
return [`❌ Action 실행 중 오류: ${e?.message ?? e}`];
}
}
export function syncBrain(brainDir: string): void {
try {
const { execSync } = require('child_process');
execSync(`git add .`, { cwd: brainDir });
execSync(`git commit -m "[Astra] Knowledge Update"`, { cwd: brainDir });
execSync(`git push`, { cwd: brainDir });
} catch (err) {
logError('Second Brain sync failed.', err);
}
}
+104
View File
@@ -0,0 +1,104 @@
import { getConfig } from '../../config';
import { logError, resolveEngine } from '../../utils';
import { estimateMessagesTokens, computeOutputBudget } from '../../lib/contextManager';
import { lmStudioSamplingFromConfig, lmStudioRespondExtrasFromConfig } from '../../lib/contextBuilders/lmStudioSampling';
import { AGENT_PROMPTS, type AgentRole, type AgentExecutorOptions, type ChatMessage } from '../../agent';
export interface CallRoleAgentDeps {
getAbortSignal: () => AbortSignal | undefined;
createStreamingRequest: (params: {
baseUrl: string;
modelName: string;
reqMessages: ChatMessage[];
temperature: number;
maxTokens?: number;
contextLength?: number;
}) => Promise<{ response: Response; engine: 'lmstudio' | 'ollama'; apiUrl: string }>;
options: AgentExecutorOptions;
}
export async function callRoleAgent(deps: CallRoleAgentDeps, role: AgentRole, prompt: string, modelName: string, options: any): Promise<string> {
const persona = AGENT_PROMPTS[role];
const { ollamaUrl, contextLength, maxOutputTokens, contextSafetyMargin, contextOverflowPolicy } = getConfig();
const messages: ChatMessage[] = [
{ role: 'system', content: persona },
{ role: 'user', content: prompt }
];
// Dynamic output cap so input + output stays within the context window.
const inputTokens = estimateMessagesTokens(messages);
const { maxOutputTokens: subMaxTokens } = computeOutputBudget(inputTokens, {
contextLength, maxOutputTokens, safetyMargin: contextSafetyMargin, minOutputTokens: 512,
});
const engine = resolveEngine(ollamaUrl);
let responseText = '';
if (engine === 'lmstudio' && deps.options.lmStudioStreamer) {
try {
const stream = deps.options.lmStudioStreamer.stream({
modelName,
messages: messages.map((m) => ({ role: m.role, content: m.content })),
temperature: 0.3,
maxTokens: subMaxTokens,
contextOverflowPolicy,
...lmStudioSamplingFromConfig(),
...lmStudioRespondExtrasFromConfig(),
signal: deps.getAbortSignal(),
});
let subStopReason: string | undefined;
for await (const { token, stopReason } of stream) {
if (token) responseText += token;
if (stopReason) subStopReason = stopReason;
}
// Sub-agent answers that got cut mid-sentence corrupt the pipeline silently
// (Planner produces a half-step, Writer can't recover). Surface a warn log so
// the operator can raise subMaxTokens or pick a less aggressive output budget.
if (subStopReason && /maxPredicted|context|truncat/i.test(subStopReason)) {
logError('Sub-agent answer hit a generation limit.', {
role, model: modelName, stopReason: subStopReason,
chars: responseText.length, maxTokens: subMaxTokens,
});
}
return responseText;
} catch (err: any) {
if (err?.name === 'AbortError' || deps.getAbortSignal()?.aborted) return responseText;
logError('LM Studio SDK callAgent stream failed.', { role, error: err?.message ?? String(err) });
throw err;
}
}
const request = await deps.createStreamingRequest({
baseUrl: ollamaUrl,
modelName: modelName,
reqMessages: messages,
temperature: 0.3, // Use lower temperature for planning and research
maxTokens: subMaxTokens,
contextLength
});
const reader = request.response.body?.getReader();
if (!reader) throw new Error("Agent response body is not readable.");
const decoder = new TextDecoder();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed === 'data: [DONE]') continue;
try {
const json = JSON.parse(trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed);
const content = json.choices?.[0]?.delta?.content || json.message?.content || '';
responseText += content;
} catch (e) { }
}
}
} finally {
try { reader.releaseLock(); } catch { /* already released */ }
}
return responseText;
}
+117
View File
@@ -0,0 +1,117 @@
import * as vscode from 'vscode';
import { findBrainFiles, getActiveBrainProfile, logError } from '../../utils';
import { getConfig } from '../../config';
import { AgentWorkflowManager } from '../../agents/AgentWorkflowManager';
import { ErrorTranslator } from '../../core/errorHandler';
import { StatusBarManager, AgentStatus } from '../../core/statusBar';
import { stripMarkdownFormatting } from '../../core/responseRecovery';
import type { AgentExecutorOptions, ChatMessage } from '../../agent';
export interface WorkflowDeps {
emitHistoryChanged: () => void;
chatHistory: ChatMessage[];
options: AgentExecutorOptions;
statusBarManager: StatusBarManager;
getWebview: () => vscode.Webview | undefined;
getAbortSignal: () => AbortSignal | undefined;
}
export async function executeMultiAgentWorkflow(
deps: WorkflowDeps,
prompt: string,
modelName: string,
options: any
) {
if (!deps.getWebview()) return;
// NOTE: 호출자 (AgentExecutor wrapper) 가 stop() + new AbortController() 를
// *먼저* 마쳐야 한다 — extracted fn 내부에서 stop 을 부르면 호출자가 막
// 만든 controller 가 즉시 폐기되기 때문. getAbortSignal() 은 그 새 controller 의
// signal 을 반환해야 함.
const signal = deps.getAbortSignal();
if (!signal) return;
const webview = deps.getWebview();
if (!webview) return;
deps.statusBarManager.updateStatus(AgentStatus.Thinking, 'Multi-Agent Workflow Running');
webview.postMessage({ type: 'streamStart' });
deps.options.onStreamLifecycle?.start();
try {
let brainContext = 'No specific context available';
try {
const config = getConfig();
const activeBrain = options.brainProfileId
? (config.brainProfiles.find((profile) => profile.id === options.brainProfileId) || getActiveBrainProfile())
: getActiveBrainProfile();
const brainFiles = findBrainFiles(activeBrain.localBrainPath);
brainContext = `Brain: ${activeBrain.name}, Files: ${brainFiles.length}`;
} catch (ctxErr) {
logError('Failed to load brain context for agents', ctxErr);
}
const selectedAgentContext = options.agentSkillContext
? `\nSelected Agent Reference:\n${options.agentSkillContext}`
: '';
const designerContext = options.designerContext
? `\nProject Chronicle Guard:\n${options.designerContext}`
: '';
// 워크플로우 매니저에게 설정 기반 실행 위임
// [Clean Stream] 단계 진행 메시지는 채팅 본문(streamChunk) 이 아닌 사이드바
// 상단의 workflowStage 인디케이터로만 표시한다 → "생각 단계가 본문에 계속 보임"
// 답답함 제거. 채팅 버블에는 최종 답변만 한 번에 들어간다.
const rawFinalReport = await AgentWorkflowManager.runStrictWorkflow(
prompt,
modelName,
`${brainContext}${selectedAgentContext}${designerContext}`,
signal,
(step, msg) => {
deps.getWebview()?.postMessage({
type: 'workflowStage',
value: { step, message: msg, done: step === '완료' || step === '오류' }
});
}
);
const wv2 = deps.getWebview();
if (signal.aborted || !wv2) return;
// [Plain Text Output] Synthesizer가 잘 따라줬어도 작은 모델은 `##` `**` 를 흘리는 경우가 있어
// 최종 후처리로 한 번 더 마커를 벗긴다. 채팅 history 에도 정제된 결과만 남겨 다음 턴 컨텍스트에서
// 마커가 재학습되는 일을 막는다.
const finalReport = getConfig().outputFormat === 'plain'
? stripMarkdownFormatting(rawFinalReport)
: rawFinalReport;
wv2.postMessage({ type: 'streamChunk', value: finalReport });
wv2.postMessage({ type: 'workflowStage', value: { step: '완료', message: '', done: true } });
wv2.postMessage({ type: 'streamEnd' });
deps.chatHistory.push({ role: 'assistant', content: finalReport });
deps.emitHistoryChanged();
deps.statusBarManager.updateStatus(AgentStatus.Success, 'Workflow Complete');
wv2.postMessage({ type: 'autoContinue', value: '✅ 모든 분석이 성공적으로 완료되었습니다.' });
} catch (error: any) {
// 어떤 종료 경로에서든 stage indicator 는 반드시 닫는다 — 안 닫으면 사이드바에 영원히 "③ 자기 검증..." 가 남는다.
deps.getWebview()?.postMessage({ type: 'workflowStage', value: { step: '완료', message: '', done: true } });
if (error.name === 'AbortError' || error.message?.includes('cancelled')) {
deps.statusBarManager.updateStatus(AgentStatus.Idle, 'Workflow Cancelled');
return;
}
const friendly = ErrorTranslator.translate(error);
logError('Workflow failed', error);
const wvErr = deps.getWebview();
wvErr?.postMessage({ type: 'autoContinue', value: '' });
wvErr?.postMessage({
type: 'error',
value: `### ${friendly.title}\n\n**상태:** ${friendly.message}\n\n**해결 방법:** ${friendly.action}`
});
deps.statusBarManager.updateStatus(AgentStatus.Idle, 'Error occurred');
} finally {
deps.options.onStreamLifecycle?.end();
}
}
+61
View File
@@ -0,0 +1,61 @@
import * as vscode from 'vscode';
import { logError, logInfo, resolveEngine } from '../../utils';
import { getConfig } from '../../config';
import type { ChatMessage } from '../../agent';
export interface CompressSummaryDeps {
context: vscode.ExtensionContext;
callNonStreaming: (params: {
baseUrl: string;
modelName: string;
engine: 'lmstudio' | 'ollama';
messages: ChatMessage[];
temperature: number;
maxTokens?: number;
contextLength?: number;
signal?: AbortSignal;
}) => Promise<{ text: string; stopReason?: string }>;
}
export async function compressSessionSummary(deps: CompressSummaryDeps, taskId: string, history: ChatMessage[]): Promise<void> {
const visible = history.filter((m) => !m.internal && (m.role === 'user' || m.role === 'assistant'));
if (visible.length < 3) return;
const cfg = getConfig();
const transcript = visible
.map((m) => `${m.role.toUpperCase()}: ${String(m.content).replace(/\s+/g, ' ').slice(0, 400)}`)
.join('\n\n');
const messages: ChatMessage[] = [
{
role: 'system',
content: [
'You compress chat transcripts into a 2-3 sentence summary.',
'Capture: (1) the user\'s topic or task, (2) the main decision or answer reached, (3) any open issue.',
'Reply in the user\'s primary language (mirror Korean ↔ English exactly as in the transcript).',
'Reply with ONLY the summary text. No headers, no quotes, no preamble.',
].join(' '),
internal: true,
},
{ role: 'user', content: `[TRANSCRIPT]\n${transcript}\n[END]` },
];
try {
const result = await deps.callNonStreaming({
baseUrl: cfg.ollamaUrl,
modelName: cfg.defaultModel,
engine: resolveEngine(cfg.ollamaUrl),
messages,
temperature: 0.3,
maxTokens: 256,
contextLength: cfg.contextLength,
});
const summary = (result.text || '').trim().replace(/^["'`]+|["'`]+$/g, '');
if (!summary || summary.length < 12) return;
const sessions = deps.context.globalState.get<any[]>('chat_sessions', []) || [];
const idx = sessions.findIndex((s) => String(s?.id) === String(taskId));
if (idx < 0) return;
sessions[idx].summary = summary;
await deps.context.globalState.update('chat_sessions', sessions);
logInfo('Session summary stored for medium-term recall.', { taskId, length: summary.length });
} catch (e: any) {
logError('Session summary compression failed.', { taskId, error: e?.message ?? String(e) });
}
}
+9 -2
View File
@@ -1,5 +1,6 @@
import { ChunkedWriter } from './factory';
import { ChunkedWriter, PersonaOverrides } from './factory';
import { AgentEngine, PipelineStage, AgentExecuteOptions } from '../lib/engine';
import { getConfig } from '../config';
/**
* Multi-agent 워크플로우를 외부에 노출하는 얇은 매니저.
@@ -20,7 +21,13 @@ export class AgentWorkflowManager {
signal: AbortSignal,
onProgress: (step: string, message: string) => void
): Promise<string> {
const writer = new ChunkedWriter(modelName);
// 사용자 config 의 polish persona override 가 있으면 ChunkedWriter 에 주입.
// 비어 있으면 기본 polish persona 사용.
const cfg = getConfig();
const overrides: PersonaOverrides | undefined = cfg.polishPersonaOverride
? { polish: cfg.polishPersonaOverride }
: undefined;
const writer = new ChunkedWriter(modelName, overrides);
const engine = new AgentEngine(writer);
const missionId = `mission_${Date.now()}`;
+56 -16
View File
@@ -110,6 +110,22 @@ export interface SectionOutline {
scope: string;
}
/**
* ChunkedWriter 의 4개 persona 를 *외부에서 주입* 가능하게 하는 컨테이너.
*
* 의도: 사용자가 ChunkedWriter 를 그대로 쓰면서 일부 role 의 톤만 바꾸고 싶을 때
* (예: 자기 회사 도메인 어휘로 polish 톤 커스텀, 또는 영어 답변 모드 outline)
* 새 클래스 wrap 없이 한 줄로 처리 가능하게.
*
* 각 필드 미지정 시 기본 persona (`DEFAULT_*_PERSONA`) 사용.
*/
export interface PersonaOverrides {
outline?: string;
section?: string;
polish?: string;
direct?: string;
}
/**
* ChunkedWriter — single-agent replacement for the old 5-stage pipeline.
*
@@ -137,15 +153,9 @@ export interface SectionOutline {
* abstraction loss. The only thing that changes is the per-call system
* prompt picked here based on `options.config.role`.
*/
export class ChunkedWriter extends BaseAgent {
/**
* Hard ceiling — *사용자 config 가 어떤 값이든 이걸 넘을 수 없다*. 안전망 의미.
* 실제 사용 상한은 `getConfig().chunkedMaxSections` (default 3). 사용자가
* Astra Settings 에서 1~10 사이 조정 가능, 이 상수가 그 위 절대 한도.
*/
static readonly MAX_SECTIONS_HARD_CEILING = 10;
// ─── Default personas (module-level exports — 외부에서 import / 부분 override 가능) ───
private readonly outlinePersona = `You are a concise editor planning the structure of a Korean answer.
export const DEFAULT_OUTLINE_PERSONA = `You are a concise editor planning the structure of a Korean answer.
Decide how many sections the answer needs. The exact upper bound (MAX_N) is given in the user message below — never exceed it. Pick the *smallest* count that still covers the request well — a short factual question should be 0-1 section, a meaty analysis up to MAX_N.
Output STRICTLY a JSON array of objects: \`[{"heading": "...", "scope": "..."}]\`. No prose, no fences, no leading text.
@@ -159,7 +169,7 @@ Output STRICTLY a JSON array of objects: \`[{"heading": "...", "scope": "..."}]\
If the user attached source content (article/code/log) the sections must cover *that content*, not analysis methodology.`;
private readonly sectionPersona = `You are writing ONE section of a longer Korean answer. You will be given:
export const DEFAULT_SECTION_PERSONA = `You are writing ONE section of a longer Korean answer. You will be given:
- the user's original request (possibly with attached content),
- this section's heading + scope,
- the full outline (for context only — DO NOT write other sections),
@@ -173,7 +183,7 @@ Rules:
- If the user attached source content, cite from it; do not invent facts.
- Do NOT output the heading itself — only the body of this section.`;
private readonly polishPersona = `You are the final editor producing the user-facing Korean answer from a sectioned draft.
export const DEFAULT_POLISH_PERSONA = `You are the final editor producing the user-facing Korean answer from a sectioned draft.
[Job]
1. Fix typos, broken markdown, inconsistent terminology.
@@ -215,12 +225,12 @@ B. **짧은 직답 (1~3문장 정도로 충분한 경우)**:
- 추론 과정·\`<think>\`·"Thinking Process:" 같은 hidden reasoning 절대 노출 금지.
- 본문 분기를 LLM 자신이 판단 — 사용자가 모드 명시 안 함.`;
/**
* Single-pass 직답 persona. 짧은 질문·정의 묻기·간단한 사실 확인처럼
* 쪼갤 필요 없는 입력을 1회 호출로 끝낸다. outline → section → polish 의
* 3회 LLM 호출을 통째로 우회 → 작은 모델로 즉답 가능.
*/
private readonly directPersona = `You are answering a Korean user request in one shot. No outline, no drafting — just the final answer.
/**
* Single-pass 직답 persona. 짧은 질문·정의 묻기·간단한 사실 확인처럼
* 쪼갤 필요 없는 입력을 1회 호출로 끝낸다. outline → section → polish 의
* 3회 LLM 호출을 통째로 우회 → 작은 모델로 즉답 가능.
*/
export const DEFAULT_DIRECT_PERSONA = `You are answering a Korean user request in one shot. No outline, no drafting — just the final answer.
Rules:
- 첫 문장이 결론 / 직답이다. "분석해보겠습니다" "좋은 질문입니다" 같은 서문 금지.
@@ -231,6 +241,36 @@ Rules:
- 사용자가 본문(코드·기사·로그)을 첨부했으면 그 본문에서 인용. 본문에 없는 사실 지어내지 말 것.
- 추론 과정·"Thinking:"·<think> 노출 금지.`;
export class ChunkedWriter extends BaseAgent {
/**
* Hard ceiling — *사용자 config 가 어떤 값이든 이걸 넘을 수 없다*. 안전망 의미.
* 실제 사용 상한은 `getConfig().chunkedMaxSections` (default 3). 사용자가
* Astra Settings 에서 1~10 사이 조정 가능, 이 상수가 그 위 절대 한도.
*/
static readonly MAX_SECTIONS_HARD_CEILING = 10;
/**
* 활성 persona. 기본은 DEFAULT_*_PERSONA, constructor 의 overrides 로 부분 교체.
* private 가 아닌 protected 로 둬서 subclass 가 진화시킬 수 있게.
*/
protected readonly outlinePersona: string;
protected readonly sectionPersona: string;
protected readonly polishPersona: string;
protected readonly directPersona: string;
/**
* @param modelName Ollama / LM Studio 모델 식별자
* @param overrides 4개 persona 중 일부만 교체 가능 (미지정 필드는 default 유지).
* 외부 plugin / 도메인 특화 사용처에서 톤 조정 시 사용.
*/
constructor(modelName: string, overrides?: PersonaOverrides) {
super(modelName);
this.outlinePersona = overrides?.outline ?? DEFAULT_OUTLINE_PERSONA;
this.sectionPersona = overrides?.section ?? DEFAULT_SECTION_PERSONA;
this.polishPersona = overrides?.polish ?? DEFAULT_POLISH_PERSONA;
this.directPersona = overrides?.direct ?? DEFAULT_DIRECT_PERSONA;
}
async execute(input: string, context?: string, signal?: AbortSignal, options?: AgentExecuteOptions): Promise<string> {
const role = (options?.config?.role as string | undefined) || 'section';
switch (role) {
+12
View File
@@ -168,6 +168,17 @@ export interface IAgentConfig {
* 피드백("6회 이상은 과하다") 반영. 1~10 범위 clamp.
*/
chunkedMaxSections: number;
/**
* ChunkedWriter 의 polish persona 를 사용자가 override 할 텍스트. 비어 있으면
* 기본 polish persona (DEFAULT_POLISH_PERSONA) 사용. 입력이 있으면 그 텍스트가
* 그대로 system prompt 로 들어가 polish 단계의 톤·구조 룰을 정의.
*
* 의도: 사용자가 답변 톤을 (예: 격식체·반말·법률 문서·마케팅 카피) 도메인에
* 맞게 직접 조정 가능. 코드 변경 없이 Settings 패널 textarea 만으로.
*
* 빈 문자열 / 공백만이면 default 사용 — 잘못 입력해도 답변이 깨지진 않음.
*/
polishPersonaOverride: string;
// ─── Stream 표시 ───
/**
* 모델 토큰을 받는 즉시 채팅 버블에 흘려보낼지 여부.
@@ -324,6 +335,7 @@ export function getConfig(): IAgentConfig {
)),
chunkedSwitchTokens: Math.max(1000, cfg.get<number>('chunkedSwitchTokens', 50000)),
chunkedMaxSections: Math.max(1, Math.min(10, cfg.get<number>('chunkedMaxSections', 3))),
polishPersonaOverride: (cfg.get<string>('polishPersonaOverride', '') || '').trim(),
liveStreamTokens: cfg.get<boolean>('liveStreamTokens', true),
outputFormat: ((): 'plain' | 'markdown' => {
const v = (cfg.get<string>('outputFormat', 'plain') || 'plain').trim().toLowerCase();
+41 -14
View File
@@ -1,43 +1,67 @@
import { logInfo } from '../utils';
/**
* AsyncLockManager: Prevents race conditions by ensuring only one task
* AsyncLockManager: Prevents race conditions by ensuring only one task
* can access a specific resource (e.g., a file path) at a time.
*
*
* Includes timeout protection to prevent indefinite lock-waiting,
* and proper cleanup on acquisition failure.
*
* v2 (unique token)
* cleanup `this.locks.get(resourceId) === previousLock.then(() => newLock)`
* *Promise * , `.then(...)` * Promise
* instance* * false* cleanup . release
* `delete(resourceId)` latest , resource
* task entry silent race.
*
* entry symbol token , cleanup / release * token
* Map latest * .
*/
interface LockEntry {
/** Previous lock chain + new lock — await 대상. */
promise: Promise<void>;
/** 이 entry 의 고유 식별자 — cleanup 시 자기 것만 지우게. */
token: symbol;
}
export class AsyncLockManager {
private locks: Map<string, Promise<void>> = new Map();
private locks: Map<string, LockEntry> = new Map();
private static readonly DEFAULT_TIMEOUT_MS = 30_000;
/**
* Acquires a lock for a specific resource.
* If the resource is already locked, waits until the previous task finishes.
* Times out after `timeoutMs` to prevent deadlocks.
*
*
* @returns A release function that MUST be called when the work is done (use try/finally).
*/
public async acquire(resourceId: string, timeoutMs: number = AsyncLockManager.DEFAULT_TIMEOUT_MS): Promise<() => void> {
const previousLock = this.locks.get(resourceId) || Promise.resolve();
const previousEntry = this.locks.get(resourceId);
const previousPromise = previousEntry?.promise ?? Promise.resolve();
const token = Symbol(`lock:${resourceId}`);
let release: () => void;
const newLock = new Promise<void>((resolve) => {
const newPromise = new Promise<void>((resolve) => {
release = resolve;
});
this.locks.set(resourceId, previousLock.then(() => newLock));
const myEntry: LockEntry = {
promise: previousPromise.then(() => newPromise),
token,
};
this.locks.set(resourceId, myEntry);
// Wait for previous lock with a timeout to prevent deadlocks
// Wait for previous lock with a timeout to prevent deadlocks.
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error(`Lock acquisition timed out for resource: ${resourceId}`)), timeoutMs);
});
try {
await Promise.race([previousLock, timeoutPromise]);
await Promise.race([previousPromise, timeoutPromise]);
} catch (error) {
// Clean up the dangling lock on timeout
if (this.locks.get(resourceId) === previousLock.then(() => newLock)) {
// 내 token 이 아직 latest 면만 정리 — newer entry 가 있으면 그 task 가 관리.
if (this.locks.get(resourceId)?.token === token) {
this.locks.delete(resourceId);
}
release!();
@@ -49,8 +73,11 @@ export class AsyncLockManager {
return () => {
logInfo(`Lock released for: ${resourceId}`);
release();
// Clean up the Map entry if this is the latest lock
this.locks.delete(resourceId);
// 내 token 이 latest 일 때만 Map 정리 — newer entry 가 등록돼 있으면
// 그 task 가 자기 release 시 정리. 옛 코드는 무조건 delete 해서 race.
if (this.locks.get(resourceId)?.token === token) {
this.locks.delete(resourceId);
}
};
}
+37 -944
View File
File diff suppressed because it is too large Load Diff
+214
View File
@@ -0,0 +1,214 @@
import * as vscode from 'vscode';
/**
* Google Calendar (iCal ) .
*
* :
* 1. "연결 해제 / URL 변경 / 지금 새로고침 / 취소"
* 2. 셋업: Google Calendar iCal URL
* 3. globalState
*
* OAuth read-only iCal 3, .
*/
export 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 .
*/
export 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;
}
}
// Settings 에 이미 채워져 있으면 그대로 쓰겠냐고 물어봄 — 매번 똑같은 값 다시 입력하기 귀찮음.
const haveBoth = !!(cur.clientId && cur.clientSecret);
let clientId: string | undefined = cur.clientId;
let clientSecret: string | undefined = cur.clientSecret;
if (haveBoth) {
const useExisting = await vscode.window.showInformationMessage(
`Settings (g1nation.google) 에 이미 Client ID/Secret 이 있습니다.\nID: ${cur.clientId!.slice(0, 20)}\n\n이 값으로 OAuth 진행할까요?`,
{ modal: false },
'예 (Settings 값 사용)',
'아니오 (새로 입력)',
'취소',
);
if (useExisting === '취소' || !useExisting) return;
if (useExisting === '아니오 (새로 입력)') {
clientId = undefined;
clientSecret = undefined;
}
}
if (!clientId) {
clientId = await vscode.window.showInputBox({
title: 'Google OAuth Client ID',
prompt: 'Credentials 페이지에서 복사한 Client ID — 자동으로 Settings(g1nation.google.clientId)에 저장됨',
placeHolder: 'xxxxxxxx.apps.googleusercontent.com',
value: cur.clientId,
ignoreFocusOut: true,
validateInput: (v) => (v || '').trim() ? null : '비어있어요',
});
if (!clientId) return;
}
if (!clientSecret) {
clientSecret = await vscode.window.showInputBox({
title: 'Google OAuth Client Secret',
prompt: '같은 화면의 Client Secret — Settings(g1nation.google.clientSecret)에 저장됨',
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 가 자동으로 일정을 생성합니다.`,
);
});
}
+60
View File
@@ -0,0 +1,60 @@
import * as vscode from 'vscode';
import { buildApiUrl, logInfo, logError } from '../utils';
/**
* Astra LM Studio/Ollama URL + .
* extension.ts `activate()` . URL
* skip.
*/
export async function runInitialSetup(context: vscode.ExtensionContext) {
// 이미 사용자가 URL을 설정했다면 자동 감지를 스킵
const existingUrl = vscode.workspace.getConfiguration('g1nation').get<string>('ollamaUrl');
if (existingUrl && existingUrl.trim()) {
context.globalState.update('setupComplete', true);
logInfo('Initial setup skipped: ollamaUrl already configured.', { existingUrl });
return;
}
try {
let engineName = '';
let modelName = '';
try {
const res = await fetch(buildApiUrl('http://127.0.0.1:1234', 'lmstudio', 'models'), { signal: AbortSignal.timeout(2000) });
const data = await res.json() as any;
if (data?.data?.length > 0) {
engineName = 'LM Studio';
modelName = data.data[0].id;
await vscode.workspace.getConfiguration('g1nation').update('ollamaUrl', 'http://127.0.0.1:1234', vscode.ConfigurationTarget.Global);
await vscode.workspace.getConfiguration('g1nation').update('defaultModel', modelName, vscode.ConfigurationTarget.Global);
logInfo('Initial setup detected LM Studio.', { modelName });
}
} catch (err) {
logInfo('Initial setup could not reach LM Studio.', err);
}
if (!engineName) {
try {
const res = await fetch('http://127.0.0.1:11434/api/tags', { signal: AbortSignal.timeout(2000) });
const data = await res.json() as any;
if (data?.models?.length > 0) {
engineName = 'Ollama';
modelName = data.models[0].name;
await vscode.workspace.getConfiguration('g1nation').update('ollamaUrl', 'http://127.0.0.1:11434', vscode.ConfigurationTarget.Global);
await vscode.workspace.getConfiguration('g1nation').update('defaultModel', modelName, vscode.ConfigurationTarget.Global);
logInfo('Initial setup detected Ollama.', { modelName });
}
} catch (err) {
logInfo('Initial setup could not reach Ollama.', err);
}
}
context.globalState.update('setupComplete', true);
if (engineName) {
vscode.window.showInformationMessage(`Setup Complete: ${engineName} detected with model ${modelName}`);
}
} catch (e) {
logError('Initial setup failed.', e);
context.globalState.update('setupComplete', true);
}
}
+46
View File
@@ -0,0 +1,46 @@
import * as vscode from 'vscode';
import { openKnowledgeMapEditor } from '../skills/agentKnowledgeMap';
import { createLessonCard, manageLessons } from './lessons';
import type { AgentExecutor } from '../agent';
/**
* Experience Memory knowledge map / lesson cards CRUD entrypoint.
* activate() 4 command . `fromConversation`
* agent history deps . stateless.
*
* agent getter activate() agent **
* commands undefined . getter
* .
*/
export interface LessonCommandsDeps {
getAgent: () => AgentExecutor;
}
export function registerLessonCommands(deps: LessonCommandsDeps): vscode.Disposable[] {
return [
vscode.commands.registerCommand('g1nation.skills.editKnowledgeMap', async () => {
await openKnowledgeMapEditor();
}),
// Experience Memory — create / browse lesson cards in the active brain.
vscode.commands.registerCommand('g1nation.lesson.create', () => createLessonCard()),
vscode.commands.registerCommand('g1nation.lesson.fromConversation', () => {
// Pre-fill the Situation section from the most recent user request + assistant reply.
const history = deps.getAgent().getHistory().filter((m: any) => !m.internal);
const lastUser = [...history].reverse().find((m: any) => m.role === 'user');
const lastAssistant = [...history].reverse().find((m: any) => m.role === 'assistant');
if (!lastUser && !lastAssistant) {
vscode.window.showInformationMessage('현재 대화 내용이 없습니다. 먼저 대화를 한 뒤 사용하세요. (빈 교훈을 만들려면 "Astra: New Lesson" 사용)');
return;
}
const clip = (s: any, n: number) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n) + '…' : t; };
const situation = [
lastUser ? `요청: ${clip(lastUser.content, 600)}` : '',
lastAssistant ? `Astra 답변(요약): ${clip(lastAssistant.content, 800)}` : '',
'',
'<위 작업에서 무엇이 잘못됐거나 위험했는지를 아래 Mistake/Root Cause 에 적으세요>',
].filter(Boolean).join('\n');
return createLessonCard(situation);
}),
vscode.commands.registerCommand('g1nation.lesson.manage', () => manageLessons()),
];
}
+135
View File
@@ -0,0 +1,135 @@
import * as fs from 'fs';
import * as path from 'path';
import * as vscode from 'vscode';
import { findBrainFiles, getActiveBrainProfile, openInEditorGroup } from '../utils';
import { getBrainTokenIndex } from '../retrieval';
import { lessonTemplate, lessonSlug, parseLessonFrontmatter, normalizeLessonTitle, bumpLessonOccurrences } from '../retrieval/lessonHelpers';
/**
* Experience Memory lesson card ** (create / manage).
*
* 코드: extension.ts `activate()` nested function 3 (listLessonFiles,
* createLessonCard, manageLessons). `activate()`
* ( module-level import ) nested .
* (a) extension.ts activate() , (b) lesson
* , (c) (: chat handler) .
*/
/** All lesson/playbook/qa-finding cards in the active brain. Uses the brain index for the lesson-kind
* filter (cheap when warm), then reads only those few files for their frontmatter title/occurrences. */
function listLessonFiles(brainDir: string): Array<{ filePath: string; rel: string; title: string; kind: string; occurrences: number }> {
const out: Array<{ filePath: string; rel: string; title: string; kind: string; occurrences: number }> = [];
let files: string[] = [];
try { files = findBrainFiles(brainDir); } catch { return out; }
for (const d of getBrainTokenIndex(brainDir, files)) {
if (!d.kind) continue;
let content = '';
try { content = fs.readFileSync(d.filePath, 'utf8').slice(0, 4000); } catch { continue; }
const fm = parseLessonFrontmatter(content);
out.push({ filePath: d.filePath, rel: d.relativePath, title: (fm.title || d.title).trim(), kind: d.kind, occurrences: fm.occurrences ?? 1 });
}
return out.sort((a, b) => a.rel.localeCompare(b.rel));
}
/** Shared lesson-card creator used by the lesson commands. Dedup-merges into an existing lesson with the same title. */
export async function createLessonCard(situation?: string): Promise<void> {
const brain = getActiveBrainProfile();
const brainDir = brain?.localBrainPath;
if (!brainDir || !path.isAbsolute(brainDir)) {
vscode.window.showErrorMessage('활성 Second Brain 경로가 설정되지 않았습니다. Settings에서 localBrainPath / brainProfiles를 먼저 설정하세요.');
return;
}
const title = (await vscode.window.showInputBox({
title: 'New Lesson — Experience Memory',
prompt: '이 교훈의 제목 (예: "Telegram 원격 실행은 allowlist 필수")',
placeHolder: '한 줄 요약 — 다음에 같은 실수를 안 하려면 뭘 기억해야 하나',
ignoreFocusOut: true,
}))?.trim();
if (!title) return;
const today = new Date().toISOString().slice(0, 10);
// Dedup-merge: a recurring mistake should get LOUDER (occurrences++), not spawn a duplicate card.
const norm = normalizeLessonTitle(title);
const existing = norm ? listLessonFiles(brainDir).find((l) => normalizeLessonTitle(l.title) === norm) : undefined;
if (existing) {
const pick = await vscode.window.showInformationMessage(
`이미 같은 제목의 교훈이 있습니다: "${existing.title}" (occurrences: ${existing.occurrences}). 갱신할까요?`,
{ modal: false },
'갱신 (occurrences +1)', '새로 만들기',
);
if (!pick) return;
if (pick === '갱신 (occurrences +1)') {
try {
const cur = fs.readFileSync(existing.filePath, 'utf8');
fs.writeFileSync(existing.filePath, bumpLessonOccurrences(cur, today), 'utf8');
} catch (e: any) {
vscode.window.showErrorMessage(`교훈 갱신 실패: ${e?.message ?? e}`);
return;
}
await openInEditorGroup(existing.filePath);
vscode.window.showInformationMessage(`기존 교훈을 갱신했습니다 (occurrences: ${existing.occurrences + 1}). 필요하면 내용을 보강하세요.`);
return;
}
// else fall through and create a new one
}
const dir = path.join(brainDir, 'lessons');
try { fs.mkdirSync(dir, { recursive: true }); } catch { /* fall through to error below */ }
let filePath = path.join(dir, `${today}-${lessonSlug(title)}.md`);
let n = 2;
while (fs.existsSync(filePath)) { filePath = path.join(dir, `${today}-${lessonSlug(title)}-${n++}.md`); }
try {
fs.writeFileSync(filePath, lessonTemplate(title, today, situation), 'utf8');
} catch (e: any) {
vscode.window.showErrorMessage(`교훈 파일 생성 실패: ${e?.message ?? e}`);
return;
}
await openInEditorGroup(filePath);
vscode.window.showInformationMessage('교훈 카드를 만들었습니다. 내용을 채워 저장하면 다음 유사 작업 시 자동으로 체크리스트에 들어갑니다.');
}
/** Browse lesson cards: open one, or delete one (trash button). Also the "manage" surface for ignoring bad lessons. */
export async function manageLessons(): Promise<void> {
const brain = getActiveBrainProfile();
const brainDir = brain?.localBrainPath;
if (!brainDir || !path.isAbsolute(brainDir)) {
vscode.window.showErrorMessage('활성 Second Brain 경로가 설정되지 않았습니다.');
return;
}
const lessons = listLessonFiles(brainDir);
if (lessons.length === 0) {
const make = await vscode.window.showInformationMessage('아직 교훈 카드가 없습니다.', '새 교훈 만들기');
if (make) await createLessonCard();
return;
}
const deleteBtn: vscode.QuickInputButton = { iconPath: new vscode.ThemeIcon('trash'), tooltip: '이 교훈 삭제' };
const qp = vscode.window.createQuickPick<vscode.QuickPickItem & { _file: string }>();
qp.title = 'Lessons — Experience Memory';
qp.placeholder = '교훈을 선택하면 열립니다. 휴지통 아이콘으로 삭제. (삭제 = 더 이상 주입 안 됨)';
qp.items = lessons.map((l) => ({
label: `$(${l.kind === 'playbook' ? 'book' : l.kind === 'qa-finding' ? 'bug' : 'lightbulb'}) ${l.title}`,
description: l.occurrences > 1 ? `×${l.occurrences}` : '',
detail: l.rel,
buttons: [deleteBtn],
_file: l.filePath,
}));
qp.onDidTriggerItemButton(async (e) => {
const file = e.item._file;
const ok = await vscode.window.showWarningMessage(`교훈 "${e.item.label}" 을(를) 삭제할까요?`, { modal: true }, '삭제');
if (ok === '삭제') {
try { fs.unlinkSync(file); } catch (err: any) { vscode.window.showErrorMessage(`삭제 실패: ${err?.message ?? err}`); return; }
qp.items = qp.items.filter((it) => it._file !== file);
vscode.window.showInformationMessage('교훈을 삭제했습니다.');
if (qp.items.length === 0) qp.hide();
}
});
qp.onDidAccept(async () => {
const sel = qp.selectedItems[0];
qp.hide();
if (sel) {
await openInEditorGroup(sel._file);
}
});
qp.onDidHide(() => qp.dispose());
qp.show();
}
+136
View File
@@ -0,0 +1,136 @@
import * as fs from 'fs';
import * as path from 'path';
import * as vscode from 'vscode';
import type { SidebarChatProvider } from '../sidebarProvider';
import { runConnectGoogleCalendarIcal, runConnectGoogleCalendarOAuth } from './calendarSetup';
/**
* Provider architecture / company / calendar / devil.
* 4 `provider._X()`
* thin shell deps (`context` + `provider getter`).
*
* 코드: extension.ts `activate()` 100 inline command block.
* (a) activate() , (b) command / , (c)
* cluster (`registerXCommands(context, deps)`) .
*
* `provider` getter activate() provider
* commands , undefined . getter
* .
*/
export interface ProviderCommandsDeps {
/** activate() 의 `let provider` 를 호출 시점에 회수. registration 시점엔 undefined 일 수 있음. */
getProvider: () => SidebarChatProvider | undefined;
}
export function registerProviderCommands(
context: vscode.ExtensionContext,
deps: ProviderCommandsDeps,
): vscode.Disposable[] {
return [
// ── Project Architecture (Feature 2) ─────────────────────────────────
// Thin shells — 모든 state mutation 은 provider 가 갖고 있게 (chip / watcher).
vscode.commands.registerCommand('g1nation.architecture.refresh', async () => {
const provider = deps.getProvider();
if (!provider) return;
await provider._refreshArchitecture();
vscode.window.showInformationMessage('Astra: Project architecture context refreshed.');
}),
vscode.commands.registerCommand('g1nation.architecture.detach', async () => {
const provider = deps.getProvider();
if (!provider) return;
await provider._detachArchitecture();
vscode.window.showInformationMessage('Astra: Project architecture auto-attach turned off.');
}),
vscode.commands.registerCommand('g1nation.architecture.attach', async () => {
const provider = deps.getProvider();
if (!provider) return;
await provider._attachArchitecture();
vscode.window.showInformationMessage('Astra: Project architecture auto-attach turned on.');
}),
vscode.commands.registerCommand('g1nation.architecture.open', async () => {
const provider = deps.getProvider();
if (!provider) return;
await provider._openArchitectureDoc();
}),
// Active subproject 재해상 — 사용자가 서브폴더 사이 editor 를 옮기면 chip 갱신.
// 400ms debounce — 빠른 editor flick 이 watcher 를 churn 시키지 않게.
// resync 자체는 idempotent — 활성 서브프로젝트가 안 바뀌면 noop.
(() => {
let timer: NodeJS.Timeout | undefined;
return vscode.window.onDidChangeActiveTextEditor(() => {
const provider = deps.getProvider();
if (!provider) return;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
provider._sendArchitectureStatus().catch(() => { /* chip 은 best-effort */ });
}, 400);
});
})(),
// ── 1인 기업 (Company) Mode ──────────────────────────────────────────
vscode.commands.registerCommand('g1nation.company.toggle', async () => {
const provider = deps.getProvider();
if (!provider) return;
const { readCompanyState, setCompanyEnabled } = await import('../features/company');
const cur = readCompanyState(context);
const next = await setCompanyEnabled(context, !cur.enabled);
await provider._sendCompanyStatus();
vscode.window.showInformationMessage(`Astra: 1인 기업 모드 ${next.enabled ? 'ON' : 'OFF'}`);
}),
vscode.commands.registerCommand('g1nation.company.manage', async () => {
const provider = deps.getProvider();
if (!provider) return;
await vscode.commands.executeCommand('g1nation-v2-view.focus');
provider._view?.webview.postMessage({ type: 'openCompanyManageOverlay' });
await provider._sendCompanyAgents();
await provider._sendCompanyResumable();
}),
vscode.commands.registerCommand('g1nation.company.openSessions', async () => {
const { resolveCompanyBase } = await import('../features/company');
const base = resolveCompanyBase(context);
const target = path.join(base, 'sessions');
try {
if (!fs.existsSync(target)) fs.mkdirSync(target, { recursive: true });
await vscode.env.openExternal(vscode.Uri.file(target));
} catch (e: any) {
vscode.window.showErrorMessage(`Sessions 폴더 열기 실패: ${e?.message ?? e}`);
}
}),
vscode.commands.registerCommand('g1nation.company.pixelOffice.open', () => {
// 사이드바 mini 패널과 별도로 editor area 에 전체 사무실 뷰. 같은
// pixelOfficeUpdate 스트림 공유 → 백엔드 변경 최소.
deps.getProvider()?.openPixelOfficePanel();
}),
// ── Google Calendar (iCal 읽기 / OAuth 쓰기) ────────────────────────
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);
}),
// ── Devil Agent (도현) — 답변 직후 비판적 반박 토글 ─────────────────
vscode.commands.registerCommand('g1nation.devilAgent.toggle', async () => {
const { isDevilAgentEnabled, setDevilAgentEnabled, DEVIL_PERSONA_NAME } =
await import('../features/devilAgent');
const wasOn = isDevilAgentEnabled();
await setDevilAgentEnabled(!wasOn);
const nowOn = !wasOn;
vscode.window.showInformationMessage(
nowOn
? `🎭 ${DEVIL_PERSONA_NAME} 활성화됨 — 이제 매 답변 뒤에 비판적 반박 카드가 떠요.`
: `🎭 ${DEVIL_PERSONA_NAME} 비활성화됨.`,
);
}),
];
}
+50
View File
@@ -0,0 +1,50 @@
import * as vscode from 'vscode';
import { FileSystemProjectScaffolder } from '../scaffolder/projectScaffolder';
import type { ProjectTemplateId } from '../scaffolder/templates';
/**
* Project Scaffolder Astra Developer (`g1nation.scaffoldProject`).
*
* activate() inline ~35 wizard . scaffolder
* 1 new
* .
*/
export function registerScaffoldCommand(): vscode.Disposable {
const scaffolder = new FileSystemProjectScaffolder();
return vscode.commands.registerCommand('g1nation.scaffoldProject', async () => {
const folders = vscode.workspace.workspaceFolders;
if (!folders || folders.length === 0) {
vscode.window.showErrorMessage('워크스페이스 폴더를 먼저 여세요.');
return;
}
const name = await vscode.window.showInputBox({
placeHolder: '프로젝트 이름 (영문/숫자/_/-, 2~40자)',
prompt: 'Astra가 워크스페이스 안에 만들 프로젝트 폴더 이름',
validateInput: (v) => /^[a-zA-Z0-9_-]{2,40}$/.test(v.trim()) ? null : '영문/숫자/_/- 만, 2~40자',
});
if (!name) return;
const picked = await vscode.window.showQuickPick(
scaffolder.listTemplates().map(t => ({ label: t.label, detail: t.detail, id: t.id })),
{ placeHolder: '템플릿 선택' }
);
if (!picked) return;
const result = await scaffolder.scaffold({
name: name.trim(),
template: picked.id as ProjectTemplateId,
rootDir: folders[0].uri.fsPath,
});
if (!result.ok) {
vscode.window.showErrorMessage(`프로젝트 생성 실패: ${result.error}`);
return;
}
const action = await vscode.window.showInformationMessage(
`${name} 생성 완료 — ${result.projectPath}`,
'폴더 열기',
'닫기'
);
if (action === '폴더 열기') {
await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(result.projectPath));
}
});
}
+64
View File
@@ -0,0 +1,64 @@
import * as vscode from 'vscode';
import { SettingsPanelProvider } from '../features/settings/settingsPanelProvider';
import { clearBrainTokenIndex } from '../retrieval';
import { TELEGRAM_TOKEN_SECRET_KEY } from './telegramCommands';
import type { TelegramBot } from '../integrations/telegram/telegramBot';
import type { TelegramHttpClient } from '../integrations/telegram/telegramClient';
export interface SettingsSetupDeps {
telegramClient: TelegramHttpClient;
telegramBot: TelegramBot;
}
/**
* Astra Settings + listener / . activate()
* inline ~40 (panel + 3 listener + 2 command) .
*
* panel `openAsPanel()` .
* disposable `context.subscriptions.push(...)` .
*
* - config change listener `g1nation.*` UI refresh
* - brain config change listener `brainProfiles` / `activeBrainId`
* in-memory (stale path )
* - secrets change listener Telegram SecretStorage UI refresh
* - g1nation.settings.focus
* - g1nation.settings.diagnose (Set )
*/
export function setupSettingsPanel(
context: vscode.ExtensionContext,
deps: SettingsSetupDeps,
): { settingsPanel: SettingsPanelProvider; disposables: vscode.Disposable[] } {
const settingsPanel = new SettingsPanelProvider({
extensionUri: context.extensionUri,
context,
secrets: context.secrets,
telegramClient: deps.telegramClient,
telegramBot: deps.telegramBot,
});
const disposables: vscode.Disposable[] = [
vscode.workspace.onDidChangeConfiguration((e) => {
if (e.affectsConfiguration('g1nation')) void settingsPanel.refresh();
}),
vscode.workspace.onDidChangeConfiguration((e) => {
if (e.affectsConfiguration('g1nation.brainProfiles')
|| e.affectsConfiguration('g1nation.activeBrainId')) {
clearBrainTokenIndex();
}
}),
context.secrets.onDidChange((e) => {
if (e.key === TELEGRAM_TOKEN_SECRET_KEY) void settingsPanel.refresh();
}),
vscode.commands.registerCommand('g1nation.settings.focus', () => settingsPanel.focus()),
vscode.commands.registerCommand('g1nation.settings.diagnose', async () => {
try {
await settingsPanel.focus();
vscode.window.showInformationMessage('Astra Settings 패널이 열렸습니다. 사이드바 Settings 항목을 확인하세요.');
} catch (e: any) {
vscode.window.showErrorMessage(`Settings 패널 열기 실패 (확장 reload가 필요할 수 있음): ${e?.message ?? e}`);
}
}),
];
return { settingsPanel, disposables };
}
+103
View File
@@ -0,0 +1,103 @@
import * as vscode from 'vscode';
import type { TelegramBot } from '../integrations/telegram/telegramBot';
import type { TelegramHttpClient } from '../integrations/telegram/telegramClient';
/** SecretStorage key for the bot token. Shared with Settings panel listener. */
export const TELEGRAM_TOKEN_SECRET_KEY = 'g1nation.telegram.botToken';
/**
* Single-element token cache shared by extension.ts and this module.
*
* `telegramClient` is constructed with `getToken: () => tokenStore.current`,
* so when this module updates `tokenStore.current` in response to a secrets
* change the client picks up the new value on its next HTTP call no need
* to rebuild the client.
*/
export interface TelegramTokenStore { current: string }
export interface TelegramCommandsDeps {
telegramBot: TelegramBot;
telegramClient: TelegramHttpClient;
tokenStore: TelegramTokenStore;
}
/**
* Telegram bot + . activate() inline 58
* (refresh helper + dispose hook + 2 listener + 3 command) .
*
* disposable `context.subscriptions.push(...)` .
*
* - refreshTelegramBot() config `telegram.enabled` + token start
* - dispose hook bot
* - config change listener `telegram.enabled` refresh
* - secrets change listener cache + refresh
* - g1nation.telegram.setBotToken InputBox
* - g1nation.telegram.clearBotToken
* - g1nation.telegram.testConnection `getMe`
*/
export function registerTelegramCommands(
context: vscode.ExtensionContext,
deps: TelegramCommandsDeps,
): vscode.Disposable[] {
const { telegramBot, telegramClient, tokenStore } = deps;
const refreshTelegramBot = async () => {
const enabled = vscode.workspace.getConfiguration('g1nation').get<boolean>('telegram.enabled', false);
const tokenPresent = !!tokenStore.current.trim();
if (enabled && tokenPresent) {
telegramBot.start();
} else if (telegramBot.isRunning()) {
await telegramBot.stop();
}
};
void refreshTelegramBot();
return [
{ dispose: () => { void telegramBot.stop(); } },
vscode.workspace.onDidChangeConfiguration(async (e) => {
if (e.affectsConfiguration('g1nation.telegram.enabled')) {
await refreshTelegramBot();
}
}),
context.secrets.onDidChange(async (e) => {
if (e.key !== TELEGRAM_TOKEN_SECRET_KEY) return;
tokenStore.current = (await context.secrets.get(TELEGRAM_TOKEN_SECRET_KEY)) || '';
await refreshTelegramBot();
}),
vscode.commands.registerCommand('g1nation.telegram.setBotToken', async () => {
const token = await vscode.window.showInputBox({
prompt: 'Telegram bot token (BotFather에서 발급, 형식: 123456:ABC...)',
placeHolder: '123456789:AA...',
password: true,
ignoreFocusOut: true,
validateInput: (v) => /^\d+:[A-Za-z0-9_-]{20,}$/.test((v || '').trim())
? null
: '형식이 올바르지 않습니다 (숫자ID:문자열).',
});
if (!token) return;
await context.secrets.store(TELEGRAM_TOKEN_SECRET_KEY, token.trim());
vscode.window.showInformationMessage(
'Telegram bot token이 저장되었습니다. settings에서 g1nation.telegram.enabled = true 로 켜세요.'
);
}),
vscode.commands.registerCommand('g1nation.telegram.clearBotToken', async () => {
await context.secrets.delete(TELEGRAM_TOKEN_SECRET_KEY);
vscode.window.showInformationMessage('Telegram bot token이 삭제되었습니다.');
}),
vscode.commands.registerCommand('g1nation.telegram.testConnection', async () => {
const token = (await context.secrets.get(TELEGRAM_TOKEN_SECRET_KEY)) || '';
if (!token) {
vscode.window.showErrorMessage('먼저 "Astra: Set Telegram Bot Token" 명령으로 토큰을 등록하세요.');
return;
}
try {
const me = await telegramClient.getMe();
vscode.window.showInformationMessage(
`Telegram 연결 성공: @${me.username || me.first_name} (id ${me.id})`
);
} catch (e: any) {
vscode.window.showErrorMessage(`Telegram 연결 실패: ${e?.message ?? e}`);
}
}),
];
}
+40 -1
View File
@@ -427,10 +427,21 @@ function _pauseManagedIntervals(){
function _resumeManagedIntervals(){
for(const rec of _managedIntervals){ if(rec.id===null){ rec.id=setInterval(rec.fn,rec.ms); } }
}
// 명시적 cleanup — webview iframe 이 destroy 되면 JS 컨텍스트 통째로 GC 되니
// 현실적 누수는 없지만, 미래에 같은 iframe 안에서 runtime 을 reinit 하는 코드
// 추가될 수 있으므로 *명시적* dispose 경로를 둔다. window 에 노출해서 panel
// 호스트 (sidebarProvider.openPixelOfficePanel) 측에서 명시 호출 가능.
function _disposeAllManagedIntervals(){
for(const rec of _managedIntervals){ if(rec.id!==null) clearInterval(rec.id); }
_managedIntervals.length=0;
}
(window).__astraOfficeDisposeIntervals=_disposeAllManagedIntervals;
document.addEventListener('visibilitychange',()=>{
if(document.hidden) _pauseManagedIntervals();
else _resumeManagedIntervals();
});
// pagehide / beforeunload — webview navigation 또는 panel dispose 시 fallback.
window.addEventListener('pagehide',_disposeAllManagedIntervals,{once:true});
_managedInterval(()=>{Object.keys(chars).forEach(k=>{const a=anim[k];if(a.mode==='walk'){a.frame=(a.frame+1)%5;setSprite(k,'walk',a.frame,a.dir)}else if(a.mode==='work'){a.frame=(a.frame+1)%4;setSprite(k,'work',a.frame)} });},286)
_managedInterval(()=>{Object.keys(chars).forEach(k=>{const a=anim[k];if(a.mode==='sit'){a.frame=(a.frame+1)%2;setSprite(k,'sit',a.frame)} });},700)
// ── 책상 회피 path planner ──
@@ -935,6 +946,12 @@ function routeBubble(b){
const role = roleMap[b?.agentId] || 'ceo';
bubble(role, b?.text || '');
}
// ⚠️ 새 phase 추가 시 다음 *세* 곳을 모두 갱신:
// 1. STATUS_COPY (이 객체) — label / note / tone
// 2. BANTER_SCRIPTS (위) — banter 시퀀스 (없으면 banter 없는 정적 phase)
// 3. PHASE_META.allPhases() 호출처 — 신규 phase 등장 시 fallback 검증
// 데이터 통합 (한 객체로 합치기) 은 BANTER 객체가 매우 커서 다음 라운드로. 지금은
// 통합된 *접근 API* (getPhaseMeta) 만 제공해 호출자가 두 객체를 직접 안 봐도 되게.
const STATUS_COPY = {
idle: { label:'대기 중', note:'새로운 작업 요청을 기다리고 있습니다.', tone:'neutral' },
intake: { label:'요청 수신', note:'요청을 읽고 작업 범위를 정리하고 있습니다.', tone:'neutral' },
@@ -948,8 +965,30 @@ const STATUS_COPY = {
error: { label:'주의 필요', note:'흐름을 멈춘 이슈를 확인해야 합니다.', tone:'danger' },
done: { label:'완료', note:'이번 작업 라운드가 정리되었습니다.', tone:'success' },
};
/**
* phase API STATUS_COPY BANTER_SCRIPTS * entry
* point* . .
*
* const meta = getPhaseMeta('executing');
* meta.label // '실행 중'
* meta.tone // 'neutral'
* meta.banter // 시퀀스 array 또는 null (banter 없는 정적 phase)
*
* phase idle fallback.
*/
function getPhaseMeta(phase){
const copy = STATUS_COPY[phase] || STATUS_COPY.idle;
return {
label: copy.label,
note: copy.note,
tone: copy.tone,
banter: BANTER_SCRIPTS[phase] || null,
};
}
function _statusMeta(status){
return STATUS_COPY[status] || STATUS_COPY.idle;
// 옛 _statusMeta 호출처 호환 — banter 까지 같이 반환해도 무해. 호출자가 label/note/tone 만 쓰면 banter 는 무시됨.
return getPhaseMeta(status);
}
function _pct(v){
return Math.max(0, Math.min(100, Math.round((typeof v === 'number' ? v : 0) * 100)));
+391 -71
View File
@@ -12,12 +12,20 @@
* - UX (researcher): (··)
*
* :
* - `name`
* - `role` ( )
* - `tagline` (UI )
* - `specialty` CEO가
* - `persona` · ()
* - `name` (UI )
* - `role` + (// )
* - `tagline` (UI , )
* - `specialty` CEO +
* - `persona` · + · ()
* . id는 (state ).
*
* (v2 ):
* 1. **** 8~15 . · .
* 2. ** ** "X 보면 먼저 Y 부터 확인" .
* 3. ** ** .
* 4. ** ** .
* 5. ** ** ( ) . agent.emoji
* UI .
*/
import { CompanyAgentDef } from './types';
@@ -25,149 +33,461 @@ export const COMPANY_AGENTS: Record<string, CompanyAgentDef> = {
ceo: {
id: 'ceo',
name: '대표',
role: '대표 · 최고 의사결정자',
role: '대표 · 최고 의사결정자 (CEO / Chief Strategist)',
emoji: '🧭',
color: '#F8FAFC',
specialty: '오케스트레이션, 작업 분해, 우선순위 판단, 다음 액션 결정, 에이전트 분배, 의사결정 종합',
specialty: '오케스트레이션, 작업 분해, 우선순위 판단, 다음 액션 결정, 에이전트 분배, 의사결정 종합, 자원 배분 (사람·시간·예산), 트레이드오프 정리, 보고서 합성, 사장님 의도와 팀 산출물 사이의 간극 메우기',
tagline: '회사 전체의 방향과 우선순위를 정하고 일을 나눕니다',
roleCategory: 'ceo',
alwaysOn: true,
// CEO 는 시스템이 항상 켜고, 자체 응답 톤보다는 *분배 정확도* 가 가치라
// persona 가 비어 있다. promptAssets.CEO_PLANNER_PROMPT 가 직접 통제.
},
business: {
id: 'business',
name: '도윤',
role: '서비스 기획자 · Game/Service Planner',
role: '시니어 서비스 기획자 · PRD Lead (Game / Service Planner, 10년+)',
emoji: '📝',
color: '#F5C518',
specialty: '기능 명세서(PRD/기획서) 작성, 사용자 스토리, 유저 플로우, 화면 흐름, 시스템 다이어그램, 데이터 모델 정의(엔티티·필드), 게임플레이/콘텐츠 기획, 밸런싱 기획, 페르소나·시나리오, 정책 정의, 엣지 케이스 사전 정리',
tagline: '무엇을·왜 만들지 명세서로 풀어냅니다',
specialty: '기능 명세서(PRD/기획서) 작성, 사용자 스토리(As a / I want / So that), 유저 플로우, 화면 흐름·상태 다이어그램, 시스템 다이어그램, 데이터 모델 정의(엔티티·필드·관계), 게임플레이/콘텐츠 기획, 재화·레벨·드롭률 밸런싱, 페르소나·시나리오, 정책 정의(인증·결제·환불), 엣지 케이스 사전 정리, 인수 기준(Acceptance Criteria) 작성, 명세서 → 디자이너·개발자 핸드오프 준비',
tagline: '"왜·누구·성공의 정의"부터 묻고 명세서로 떨어뜨립니다',
roleCategory: 'planner',
persona: '제품과 사용자 양쪽을 같이 보는 서비스 기획자. "이 기능을 왜 만들죠?·누구의 어떤 문제를 푸나요?·성공의 정의는?"부터 묻고 명세서로 떨어뜨립니다. 모호한 표현(잘·간단·예쁘게) 대신 측정 가능한 조건으로 정의. 엣지 케이스·실패 시 동작·권한·예외 처리를 미리 적어둠. 톤은 차분하고 명료. 이모지는 📝·🧭·🎯 정도만.',
persona: `시니어 서비스 기획자. 제품과 사용자 양쪽을 같이 보는 직업 본능.
[ ]
- 3 : "왜 만들죠?", "누구의 어떤 문제를 푸나요?", "성공의 정의는 무엇으로 측정하나요?". .
- ("잘", "간단하게", "예쁘게", "사용자 친화적으로") . "잘 동작" "응답 시간 200ms 이내 + 에러율 0.5% 이하" .
- "사용자가 중간에 뒤로가기 누르면?", "네트워크 끊기면?", "권한 없으면?", "동시 요청 충돌하면?", "데이터 0건이면?". .
[ ]
- "왜?" · . .
- (UI ). * * , UI .
- . / .
[ ]
- (): + + . .
- (): PRD + + + . API .
- (): ("이 기능을 사용자가 원할까?") .
[]
. . "확인이 필요한 부분은 명시" .
.`,
},
researcher: {
id: 'researcher',
name: '유진',
role: 'UX 리서처 · 데이터 분석가',
role: '시니어 UX 리서처 · 프로덕트 데이터 분석가 (Mixed-method, 8년+)',
emoji: '🔍',
color: '#60A5FA',
specialty: '사용자 인터뷰 가이드 설계, 설문지(척도·문항·표본), 사용성 테스트(UT) 시나리오, 코호트·퍼널·리텐션 분석, A/B 테스트 가설·메트릭, 경쟁사 분석, 사실 확인·인용 정리, 데이터 시각화 권고',
tagline: '사용자와 데이터로 가설을 검증합니다',
specialty: '사용자 인터뷰 가이드 설계(반구조화·라더링), 설문지(척도·문항 편향 제거·표본 산정), 사용성 테스트(UT) 시나리오·과제 설계, 다이어리·일기 연구, 코호트/퍼널/리텐션 분석, A/B 테스트 가설·핵심·가드레일 메트릭, 통계 유의성·MDE 산정, 경쟁사 분석(JTBD 프레임), 사실 확인·인용 정리, 데이터 시각화 권고, NPS/CSAT/CES 운영',
tagline: '"느낌" 대신 표본·신뢰구간·인용 출처로 결론을 냅니다',
roleCategory: 'researcher',
persona: '근거 우선의 분석가. "체감"·"느낌" 대신 표본 크기·신뢰구간·인용 출처·테스트 기간을 먼저 명시. 모르는 건 모른다고 솔직히. 결과 보고는 "근거 → 해석 → 권고" 3단으로 정리. 이모지는 🔍·📊·🧪 정도.',
persona: `시니어 UX 리서처 + 프로덕트 데이터 분석가. 근거가 약하면 결론도 약하다는 신념.
[ ]
- "표본 N=__, 기간 __, 출처 __" . **.
- : "이 분석은 '__' 가설을 검증/반증하기 위한 것" .
- · . 6 , ** .
[ ]
- (N<30) 결과를 "유의" 하다고 보고. 표본 작으면 *방향성 시사* 까지만.
- A/B ** ( stop). .
- "사용자가 원한다" . . UT .
- * * . "그래서 우리가 뭘 해야 한다" .
[ 3]
1. **** · (· )
2. **** ( )
3. **** (3 , trade-off )
[ ]
- (): PRD "왜" .
- (): UT fail point .
- PO(): A/B ( ) .
[]
·. . "데이터가 시사하는 바는 __ 지만 N=__ 라 정의적이지 않다" .
.`,
},
designer: {
id: 'designer',
name: '다온',
role: 'UX/UI 디자이너 · 프로덕트 디자인',
role: '리드 프로덕트 디자이너 · UX/UI · 디자인 시스템 (10년+)',
emoji: '🎨',
color: '#A78BFA',
specialty: '정보구조(IA), 유저 플로우, 와이어프레임, UI 시안 3안 비교, 디자인 시스템(컬러·타이포·컴포넌트·토큰), 인터랙션·모션 가이드, 반응형/플랫폼별 가이드, 접근성(WCAG) 체크, 게임 UI/HUD·아이콘 가이드',
tagline: '사용자 흐름과 화면을 설계합니다',
specialty: '정보구조(IA)·사이트맵·카드 소팅, 유저 플로우·태스크 분석, 와이어프레임(저충실도→고충실도), UI 시안 3안 비교 + trade-off 매트릭스, 디자인 시스템(컬러 토큰·타이포 스케일·spacing·컴포넌트·variant), 인터랙션 패턴·마이크로 인터랙션, 모션 가이드(easing·duration), 반응형 그리드·플랫폼별 가이드(iOS HIG/Material), 접근성(WCAG 2.2 AA, 색 대비·키보드·스크린리더·터치 타겟 44pt), 게임 UI/HUD·아이콘 시스템, Figma 라이브러리 운영',
tagline: '"이 화면 다음 행동" 이 명확한 흐름을 설계합니다',
roleCategory: 'designer',
persona: '사용자 흐름을 먼저 잡는 디자이너. "이 화면 다음에 뭘 해야 하나요?·이 정보가 여기 있어야 하는 이유는?"을 항상 검증. 시안은 항상 3안 이상 + trade-off 명시. 디테일(여백·정렬·tap target)에 깐깐. 이모지는 🎨·✨·🖼 정도.',
persona: `리드 프로덕트 디자이너. 예쁜 그림보다 *사용자 흐름* 을 먼저 잡는 직업 본능.
[ ]
- 3 : "사용자가 이 화면에 *왜* 왔나?", "이 화면 다음에 *무엇을* 해야 하나?", "실패하면 *어디로* 가나?". .
- *3 * + trade-off . / .
- (8px ), ( ), ( 44pt), (4.5:1 ). 4 .
- 2 . 3 .
[ ]
- "예쁘다/안 예쁘다" . (UT/) .
- . .
- "나중에" . AA .
- ** . * * * * .
[ ]
- (): "이 화면 빠진 거 아닌가?" .
- (): ·spacing· spec Figma dev mode Storybook .
- QA(): + - .
[]
, , . "이건 데이터로 검증 필요" .
.`,
},
developer: {
id: 'developer',
name: '코다리',
role: '시니어 풀스택/게임 엔지니어',
role: '시니어 풀스택 / 게임 엔지니어 · 코드 한 줄도 검증 (12년+)',
emoji: '💻',
color: '#22D3EE',
specialty: '프론트엔드·백엔드·API 구현, 게임 클라이언트(Unity/Unreal) 및 서버, 데이터 모델링·DB 스키마·마이그레이션, 자동화 스크립트, 디버깅, 코드 리뷰, 리팩토링, 단위·통합 테스트 작성, CI/CD 파이프라인, git 워크플로, 보안(인증·인가·입력 검증·시크릿 관리)·성능 프로파일링',
tagline: '읽고·생각하고·짜고·검증하는 시니어 엔지니어',
specialty: '프론트엔드(React/Vue/Svelte) · 백엔드(Node/Python/Go/Rust) · API(REST/GraphQL/gRPC) 구현, 게임 클라이언트(Unity/Unreal/Godot) 및 서버, 데이터 모델링·DB 스키마·마이그레이션(zero-downtime), 자동화 스크립트, 디버깅(스택 추적·바이너리 분기), 코드 리뷰·리팩토링, 단위·통합·E2E 테스트 작성, CI/CD 파이프라인(GitHub Actions/GitLab CI), git 워크플로(trunk-based / git-flow trade-off), 보안(OWASP Top 10·인증·인가·입력 검증·시크릿 관리·CSRF/XSS/SQLi), 성능 프로파일링(CPU·메모리·네트워크·DB 쿼리 N+1)',
tagline: '읽고 · 생각하고 · 짜고 · 검증하는 시니어 엔지니어',
roleCategory: 'developer',
persona: '시니어 풀스택/게임 엔지니어. 코드 한 줄도 그냥 안 넘김. "왜?·어떻게?·이게 깨지나?·예외는?"을 늘 묻고 검증. 친근하지만 프로페셔널. 보안·예외처리·동시성·롤백 시나리오를 항상 같이 생각. "확인 후 진행할게요"·"테스트 통과 확인했어요" 같은 책임감 있는 표현. 이모지는 💻·⚙️·🔧·✅·🐛 정도만.',
persona: `시니어 풀스택 / 게임 엔지니어. 코드 한 줄도 그냥 안 넘기는 직업 본능.
[ ]
- 4 : "왜?(요구사항)", "어떻게?(설계 옵션)", "이게 깨지나?(엣지·동시성·롤백)", "예외는?(권한·데이터 없음·timeout·재시도)". 4 .
- * * . .
- · · ** PR . "이 변경이 SQL injection 가능한가? 동시 요청 충돌하나? 롤백 어떻게?" 릿 .
- * * . "이게 동작한다" PR open.
[ ]
- ** over-engineering. 3 .
- "이상한" . * * git blame .
- . 90% .
- "내부 시스템이니까" . untrusted input ·SQL parameterization·릿 .
[ ]
- * read* (Read tool). edit .
- * * . .
- * PR * . PR 1 .
[ ]
- (): PRD . "이 경우 정책은?" .
- QA(): (impacted areas) + .
- PO(): (·· ) .
[]
. "확인 후 진행할게요", "테스트 통과 확인했어요", "이 부분은 추가 검증 필요해요" . "아마", "대충" .
.`,
},
qa: {
id: 'qa',
name: '재훈',
role: 'QA 엔지니어 · 품질 검증',
role: '시니어 QA 엔지니어 · 품질 책임자 (8년+, 자동화·게임 빌드 검증)',
emoji: '🧪',
color: '#10B981',
specialty: '테스트 케이스 설계(해피·엣지·실패), 회귀 테스트 슈트, 통합·시스템 테스트, 디바이스·OS·브라우저 매트릭스, 게임 빌드 검증(저사양·성능·메모리·크래시), 자동화 우선순위 추천, 버그 재현 절차·중요도·재현율 기록, 출시 전 체크리스트',
tagline: '기능 검증과 버그 발굴을 담당합니다',
specialty: '테스트 케이스 설계(해피·엣지·실패·보안·성능·접근성), 회귀 테스트 슈트 운영, 통합·시스템·E2E 테스트, 디바이스·OS·브라우저 매트릭스, 게임 빌드 검증(저사양·메모리·크래시·로딩 시간·프레임 드롭), 자동화 우선순위 추천(ROI 기반), 버그 재현 절차·심각도(Sev1-4)·재현율 기록, 출시 전 체크리스트, 부하·스트레스 테스트, 보안 테스트(인증·인가·입력 검증), 탐색적 테스트 세션',
tagline: '버그를 *재현 가능한 형태* 로 찾아내는 의심 많은 검증자',
roleCategory: 'qa',
persona: '꼼꼼하고 의심 많은 톤. "정상 동작합니다" 같은 모호함 대신 "케이스 A(iOS 17, 저사양): ✅ / 케이스 B(Android 12, 메모리 부족): ❌ (재현: 1.시작 → 2.…)" 식의 검증 가능한 결론. 버그는 반드시 "❌ 버그 발견:"으로 시작 — loop-back regex가 잡을 수 있게. 이모지는 🧪·🐞·✅·❌ 정도.',
persona: `시니어 QA 엔지니어. 모든 "정상 동작합니다" 를 일단 의심하는 직업 본능.
[ ]
- *4 * : (1) , (2) (0/1/N/max/overflow), (3) (//timeout), (4) · .
- * * . * * .
- Sev1( · ), Sev2( ), Sev3( ), Sev4(). * × * .
- * * . 1 , . .
[ ]
- "정상 동작합니다" . "케이스 A: ✅ / 케이스 B: ❌ (재현: 1.→2.→3.)" .
- . .
- "수정했어요" * * . .
- . (· ) .
[ ]
- ** **: 1. ___, 2. ___, 3. ___ ( )
- ** **: ___
- ** **: ___
- ****: OS ___, ___, ___
- ****: ___% ( )
- ****: Sev1/2/3/4
- ****: ··
[ loop-back regex ]
- "✅ 모든 케이스 통과"
- "❌ 버그 발견: <항목 나열>"
[ ]
- (): + Sev. "왜 깨졌는지" ( ).
- PO(): Sev1/2 Sev3/4 .
[]
. .
.`,
},
inspector: {
id: 'inspector',
name: '민지',
role: '프로덕트 오너 · 출시 감리',
role: '시니어 프로덕트 오너 · 출시 감리 · 회고 리드 (10년+)',
emoji: '🔎',
color: '#EF4444',
specialty: '백로그 우선순위 검토, 인수 기준(Acceptance Criteria) 점검, 기획·구현 정합성 감리, 누락 케이스·요구사항 충돌 지적, 출시 준비도 체크리스트(QA·문서·롤백 플랜·모니터링), 출시 후 회고 진행, 다음 사이클 개선 제안, 핵심 메트릭 추적',
tagline: '기획 의도와 결과물이 맞는지 감리합니다',
specialty: '백로그 우선순위(RICE·MoSCoW·Kano 모델), 인수 기준(Acceptance Criteria) 점검, 기획·구현 정합성 감리, 누락 케이스·요구사항 충돌 지적, 출시 준비도 체크리스트(QA·문서·롤백 플랜·모니터링·feature flag·rollout 단계), 출시 게이트 의사결정, 출시 후 회고(retrospective)·5 Whys 원인 분석, 다음 사이클 개선 제안, 핵심 메트릭(activation·retention·revenue·churn) 추적, 분기 OKR 정렬',
tagline: '"기획 의도" "결과물" 사이의 간극을 감리합니다',
roleCategory: 'inspector',
persona: '깐깐하지만 건설적인 톤. 무엇이 좋고 무엇이 부족한지 명확히 구분. 결론을 "✅ 승인" 또는 "❌ 재작업 필요: …"로 명시 — loop-back regex가 잡을 수 있게. 사장님(사용자)이 시간 낭비 안 하도록 핵심만. 이모지는 🔎·✅·❌ 정도.',
persona: `시니어 프로덕트 오너. 출시 직전에 *"이거 진짜 내보내도 되나?"* 를 마지막으로 묻는 책임자.
[ ]
- 5 : (1) (PRD) , (2) , (3) , (4) , (5) .
- "잘 만들었다" * * . 0.
- *RICE* (Reach × Impact × Confidence / Effort) *MoSCoW*. .
- ** . .
[ ]
- "사용자 의견" . * * .
- "더 좋게" . (good enough) ** (must fix) .
- ** . *·* . · .
[ loop-back regex ]
- "✅ 승인" + 1 + 1
- "❌ 재작업 필요: <항목 나열>" + ** + (must/should/nice)
[ ]
- [ ] PRD (QA )
- [ ] (Sev1/2 0)
- [ ] · ( 5 )
- [ ] (5 )
- [ ] ( · )
- [ ] ()
- [ ] feature flag rollout
[ ]
- CEO: 출시 trade-off ( vs ) .
- (): PRD .
- ·QA: 차단 deadline .
[]
. · . .
.`,
},
secretary: {
id: 'secretary',
name: '영숙',
role: '프로젝트 매니저 · PM',
role: '시니어 프로젝트 매니저 · Chief of Staff (8년+)',
emoji: '📅',
color: '#84CC16',
specialty: '일정·마일스톤 관리, 스프린트·간트 차트, 리소스 배분·우선순위 조정, 리스크 추적·완화, 회의 노트·의사결정 로그, 데일리 스탠드업, 다른 에이전트 산출물 요약 보고, 알림·리마인더, 이해관계자 커뮤니케이션',
tagline: '일정·리소스·소통을 챙기고 정리합니다',
specialty: '일정·마일스톤 관리(스프린트·간트·로드맵), 리소스 배분·우선순위 조정, 리스크 추적·완화(리스크 매트릭스), 회의 노트·의사결정 로그, 데일리 스탠드업·주간 보고, 다른 에이전트 산출물 요약·합성 보고, 알림·리마인더, 이해관계자 커뮤니케이션, 회의록 → 액션 아이템 추출 → 캘린더·태스크 트래커 자동 등록, blocker 즉시 escalation',
tagline: '일정·리소스·소통을 챙기고 실행 가능한 형태로 정리합니다',
roleCategory: 'support',
persona: `친근하고 정중하지만 일정 앞에서는 단호한 톤. 짧고 정리된 문장. 보고할 때는 한눈에 보이게 불릿 + 핵심만 (날짜·담당·상태). 이모지는 😊·📅·✅ 정도.
persona: `시니어 프로젝트 매니저 · Chief of Staff. 친근하고 정중하지만 *일정과 약속 앞에서는 단호* 한 직업 본능.
**·· ( ):**
4 * * * * emit:
[ ]
- · 4 : (1) , (2) , (3) , (4) . * * emit.
- 릿 + (··). .
- ** escalate. "혹시 ___ 일 가능성이 있어 미리 알려드립니다" . .
- "잘 챙겨드릴게요" ** ** (· emit) .
1. ** ** ( //) \`<create_calendar_event>\` 로 Google Calendar 등록 + \`<add_task>\` 로 추적기에도 동시 등록.
[ ]
- * * . .
- ("다음주에", "조만간") . * * .
- · * * .
- * * . "이 미팅과 ___ 마감 겹칩니다" .
[ ]
·· 4 * * * * emit:
1. ** ** ( ··) \`<create_calendar_event>\` 로 Google Calendar 등록 + \`<add_task>\` 로 추적기에도 동시 등록.
2. **** ( to-do, ) \`<add_task>\` 로 추적기에만 등록. 시각 확정 안 됐으면 due 비움.
3. ** ** (·) "## 결정" (decisions.md ).
4. ** ** ("다음주", "조만간") emit . "확정 필요: " .
3. ** ** (·) "## 결정" .
4. ** ** ("다음주", "조만간") emit . "확정 필요: ___" .
** **: "어제 X 끝냈어" / "Y 블락됐어" ** \`<update_task>\` 또는 \`<complete_task>\` emit. "잘 챙겨드릴게요" 라고 말만 하지 말고 태그로 실제 갱신.
[ ]
"어제 X 끝냈어" / "Y 블락됐어" ** \`<update_task>\` 또는 \`<complete_task>\` emit. 말로만 챙기는 척하지 말고 태그로 실제 갱신.
** ** ( ):
- 📅 등록: 제목 ·
- 📋 추가: 제목 · ·
- 완료: 제목`,
[ ]
- 등록: 제목 ·
- 추가: 제목 · ·
- 완료: 제목
[ ]
- CEO: 우선순위 · * * .
- 에이전트: 일정 .
- 사장님: 매일 * 3* ( · ·blocker) .
[]
. · .
.`,
},
writer: {
id: 'writer',
name: '글봄',
role: '테크니컬 라이터 · UX 라이터',
role: '시니어 테크니컬 라이터 · UX 라이터 · 콘텐츠 디자이너 (8년+)',
emoji: '✍️',
color: '#FBBF24',
specialty: '릴리스 노트·패치 노트·체인지로그, 사용자 가이드·도움말 센터, API 문서·튜토리얼, UX 마이크로카피(버튼·에러·빈 화면), 인앱 온보딩 카피, 마케팅 카피·후크, 출시 공지, 메일·블로그 톤앤매너',
tagline: '제품과 사용자 사이의 모든 글을 씁니다',
specialty: '릴리스 노트·패치 노트·체인지로그(사용자 가치 중심), 사용자 가이드·도움말 센터, API 문서·튜토리얼·코드 샘플, UX 마이크로카피(버튼·에러 메시지·빈 화면·로딩·확인 다이얼로그), 인앱 온보딩 카피, 마케팅 카피·후크·랜딩 페이지, 출시 공지·블로그·메일 톤앤매너, voice & tone 가이드 운영, terminology(용어 통일) 관리, A/B 테스트 카피 변형 작성',
tagline: '간결·정확·따뜻함을 한 문장에 동시에 담습니다',
roleCategory: 'planner',
persona: '간결·정확·따뜻함을 동시에 잡는 톤. 한 문장에 한 가지 메시지. 어려운 용어는 사용자 언어로 번역. UX 카피는 "사용자가 다음에 뭘 해야 하는가"를 명확히. 릴리스 노트는 "사용자에게 무엇이 좋아졌나" 관점으로 작성 (내부 jargon 금지). 이모지 자제, 강조용으로 가끔.',
persona: `시니어 테크니컬 / UX 라이터. 한 문장에 *한 가지 메시지* 만 담는 직업 본능.
[ ]
- 3 : "*누가* 이 글을 읽나?", "*언제* (어떤 상황에서) 읽나?", "읽고 나서 *무엇을* 하길 바라나?". .
- * * . "인증 토큰이 만료되었습니다" "로그인 시간이 끝났어요. 다시 로그인해 주세요."
- UX * * . "오류 발생" . * + + * 3 .
- * jargon * "리팩토링 완료" "검색이 2배 빨라졌어요".
[ ]
- **. .
- ("그것을 위해", "당신의 계정") . .
- ("실패", "할 수 없음") . · .
- * * . .
[ ]
- ** **: () + () + (). : "비밀번호가 일치하지 않아요. 다시 확인해 주세요. [재시도]"
- ** **: ( ) + ( ). : "아직 저장된 항목이 없어요. [첫 항목 추가하기]"
- ** **: ( ) + ().
- ** **: CTA. 3.
[ ]
- (): · . "이 카피는 한 줄 더 필요해요" .
- (): · * * .
- (): A/B .
[]
··. . .
.`,
},
editor: {
id: 'editor',
name: '루나',
role: '사운드 디렉터 · 게임/UI 사운드',
role: '시니어 사운드 디렉터 · 게임·UI·영상 오디오 (10년+)',
emoji: '🎵',
color: '#F472B6',
specialty: '게임 BGM 기획, UI 사운드(클릭·알림·전환), SFX(스킬·이펙트·환경), 보이스 톤 가이드, 영상 BGM, 음향 톤·믹스 가이드, BPM·키·길이 정의, 사운드-이벤트 매칭 가이드',
specialty: '게임 BGM 기획(레이어·트랜지션·인터랙티브 뮤직), UI 사운드(클릭·알림·전환·피드백), SFX(스킬·이펙트·환경·풋스텝·foley), 보이스 톤·디렉팅 가이드, 영상 BGM·SFX 운용, 음향 톤·믹스 가이드(LUFS·dynamic range), BPM·키·길이·loop point 정의, 사운드-이벤트 매칭 가이드, 3D 오디오·HRTF, 라이센스·로열티 프리 큐레이션',
tagline: '제품·게임·영상의 톤에 맞는 사운드를 설계합니다',
roleCategory: 'designer',
persona: '음악·사운드 감각이 좋고 톤을 한 마디로 잡아냄. "이 UI/씬은 [장르/분위기]가 어울려요" 식으로 제안. BPM·키·길이·믹싱 우선순위를 정확히 표기. 데이터 중심이지만 창작자 감수성도 있음. 이모지는 🎵·🎼·🎚 정도.',
persona: `시니어 사운드 디렉터. 한 마디 듣고 *톤* 을 잡아내는 직업 본능.
[ ]
- * 3* . "신비한 분위기" ··.
- * * , , , . .
- BPM··· ** . "차분한 분위기" "BPM 70-90, key Am/Em, 60s loop, soft pad 우세".
- * * . / .
[ ]
- ** . * * .
- (LUFS) . BGM .
- . .
- (: UI ) . variant 3 random .
[ ]
- ·
- 3 (·· )
- 사양: BPM, key, , loop point, LUFS
- 분류: 자작· ·
- ()
[ ]
- (): .
- (): UI () spec.
- (): (FMOD/Wwise) spec.
[]
+ . .
.`,
},
youtube: {
id: 'youtube',
name: '레오',
role: '마케팅 PD · 영상 콘텐츠',
role: '시니어 콘텐츠 PD · 마케팅 영상 / 유튜브 그로스 (8년+)',
emoji: '📺',
color: '#FF4444',
specialty: '제품 트레일러·출시 영상 기획, 튜토리얼·온보딩 영상 구성, 영상 후크·도입부 3안, 썸네일 브리프, 시청자 유지율 곡선 설계, 메타데이터(제목·태그·설명), 시리즈·플레이리스트 구성, 인플루언서 시드 영상',
tagline: '제품을 영상으로 알리는 일을 책임집니다',
specialty: '제품 트레일러·출시 영상 기획·구성, 튜토리얼·온보딩 영상, 영상 후크 (첫 15초)·도입부 3안 비교, 썸네일 브리프(텍스트·인물·색상 대비·CTR 가설), 시청자 유지율(retention) 곡선 설계, 메타데이터 SEO(제목·태그·설명·timestamp·챕터), 시리즈·플레이리스트 구성, 인플루언서 시드 영상 기획, A/B 썸네일 테스트, 알고리즘 행동 분석 (CTR·AVD·session start·suggested impressions)',
tagline: '"첫 15초" 와 "썸네일 CTR" 로 알고리즘과 협상합니다',
roleCategory: 'planner',
persona: '데이터 중심·솔직·자신감 있는 톤. 결론을 먼저 말한 뒤 데이터(retention·CTR)로 뒷받침. 추측보다 숫자. 따뜻함은 잃지 않음. 이모지는 자제, 🔥·📊·🎯 같은 강조용은 OK.',
persona: `시니어 콘텐츠 PD. 데이터 중심 · 솔직 · 자신감의 직업 본능.
[ ]
- *retention * . 30 vs 1 vs .
- (retention·CTR·AVD) . .
- 15 ** (1) (2) (3) . retention 50% .
- *CTR * . "이 썸네일은 ___ 사용자에게 ___ 약속" . .
[ ]
- * * . retention .
- * * . * * "왜 ___ 이 ___ 일까?" .
- (·) ** . * * .
- · . 24 .
[ ]
1. **· **: , ,
2. **· **: +
3. ** 3**: 15 +
4. **retention **: (3·5·8) * *
5. **CTA**: ·
6. ** + A/B** ()
[ ]
- (): PRD .
- SNS (): / / SNS .
- (): (·) .
[]
· · . . .
.`,
},
instagram: {
id: 'instagram',
name: '아라',
role: '마케팅 콘텐츠 매니저 · SNS',
role: '시니어 SNS 콘텐츠 매니저 · 커뮤니티 그로스 (6년+)',
emoji: '📷',
color: '#E1306C',
specialty: '인스타·X·TikTok 콘셉트 시트, 릴스·숏폼 기획, 캡션·해시태그 전략, 게시 시간 최적화, 스토리·하이라이트, 커뮤니티 운영(댓글·DM 가이드), 인플루언서 협업 브리프, 캠페인 KPI 측정',
tagline: 'SNS·커뮤니티에서 사용자와 만납니다',
specialty: '인스타그램·X·TikTok·Threads 콘셉트 시트, 릴스·숏폼 기획·편집 디렉팅, 캡션·해시태그 전략(브랜드/카테고리/롱테일), 게시 시간 최적화(audience 활성 시각), 스토리·하이라이트 운영, 커뮤니티 운영(댓글·DM 가이드·위기 응답), 인플루언서 협업 브리프, 캠페인 KPI 측정(reach·engagement rate·save·share·conversion), 트렌드 모니터링(audio·challenge·meme), UGC 캠페인 설계',
tagline: 'SNS·커뮤니티에서 사용자와 *지금* 만납니다',
roleCategory: 'planner',
persona: '시각·트렌드 감각이 빠른 콘텐츠 매니저. "이 콘셉트는 지금 통합니다·아닙니다"를 짧고 분명하게. 캡션은 후크 → 가치 → CTA. 커뮤니티 톤은 친근하고 빠른 응답. 이모지 적당히 (📷·✨·💬).',
persona: `시니어 SNS 콘텐츠 매니저. *시각·트렌드 감각* 이 빠른 직업 본능.
[ ]
- * CTA* 3. 1 () 3 ( ) .
- "이 콘셉트는 지금 통한다 / 아니다" . .
- *2 * . · . .
- *24 * algorithm . 1 reach 2 .
[ ]
- ** . audience · ( vs TikTok vs X ).
- ** . 30 < 5-10. ·· 3 .
- *follower * . engagement rate·audience .
- ( ·) **. 24 .
[ ]
- · (··)
- ( 1 + 3 )
-
- CTA (··DM·)
- 5-10 (// )
- (audience )
- (KPI + )
[ ]
- 긍정: 빠르게 + ( )
- 질문: 1시간 ( ** + )
- ·논란: 24시간 ( X, + )
- 스팸: 차단 +
[ ]
- PD(): / / .
- (): ·voice & tone .
- (): .
[]
· , . .
.`,
},
};
@@ -177,17 +497,17 @@ export const COMPANY_AGENTS: Record<string, CompanyAgentDef> = {
*/
export const COMPANY_AGENT_ORDER: string[] = [
'ceo',
'business', // 기획자 (Game/Service Planner)
'researcher', // UX 리서처
'designer', // UX/UI 디자이너
'developer', // 시니어 엔지니어
'qa', // QA 엔지니어
'inspector', // 프로덕트 오너 · 감리
'secretary', // PM (운영)
'writer', // 테크니컬 라이터
'editor', // 사운드 디렉터
'youtube', // 마케팅 영상
'instagram', // 마케팅 SNS
'business', // 시니어 서비스 기획자
'researcher', // 시니어 UX 리서처
'designer', // 리드 프로덕트 디자이너
'developer', // 시니어 풀스택 엔지니어
'qa', // 시니어 QA 엔지니어
'inspector', // 시니어 PO · 감리
'secretary', // 시니어 PM · Chief of Staff
'writer', // 시니어 테크니컬 / UX 라이터
'editor', // 시니어 사운드 디렉터
'youtube', // 시니어 콘텐츠 PD
'instagram', // 시니어 SNS 콘텐츠 매니저
];
/** Specialists only (everything except the CEO). */
+48 -126
View File
@@ -65,11 +65,20 @@ import {
writeResumeState,
} from './resumeStore';
import { buildTelegramReporter, formatCompanyTelegramReport } from './telegramReport';
// ── Self-reflector + intent alignment 모듈 정적 import (옛 dynamic require 8회 통합) ──
// 옛 코드는 매 stage 마다 `await import(...)` 로 모듈을 로드했음. 이유는 cyclic import
// 회피로 짐작됐지만 실제로 selfReflector / intentAlignment 모듈 어느 것도 dispatcher 를
// import 하지 않아 안전하게 정적 promote 가능. 코드 흐름 명확해지고, 매 dispatch 마다
// require 호출 8회 → 0회 (모듈 캐시 자동).
import { verifyResponse, formatIssuesForRetry } from '../selfReflector/selfReflectorVerifier';
import { verifyCreatedFiles } from '../selfReflector/selfReflectorExecution';
import { verifyHollow } from '../selfReflector/selfReflectorHollow';
import { formatContractForPrompt } from './intentAlignment';
import { getConfig as getDispatcherConfig } from '../../config';
import {
AgentRoleCategory, AgentTurnOutput, CompanyResumeState, CompanyState, CompanyTaskPlan,
PipelineDef, PipelineStage, RequirementContract, ROLE_CATEGORY_LABELS, SessionResult,
} from './types';
import { formatContractForPrompt } from './intentAlignment';
/** Trim length applied when an agent's output is fed into the next agent. */
const PEER_OUTPUT_BUDGET = 1500;
@@ -142,7 +151,10 @@ export type CompanyTurnEvent =
*/
| { phase: 'telegram-mirror'; ok: boolean | null; reason?: string }
| { phase: 'session-saved'; sessionDir: string }
| { phase: 'aborted'; reason: string };
| { phase: 'aborted'; reason: string }
// 일반 정보·경고·에러 메시지 — 진행 UI 와 별개로 사용자에게 전달할 텍스트.
// 예: resume state 저장 실패, optional feature 미설치 안내 등.
| { phase: 'log'; level: 'info' | 'warn' | 'error'; message: string };
export type CompanyTurnEmitter = (event: CompanyTurnEvent) => void;
@@ -248,7 +260,7 @@ export async function runCompanyTurn(
abortReason?: string;
},
): void => {
writeResumeState(sessionDir, {
const result = writeResumeState(sessionDir, {
version: 1,
timestamp,
userPrompt,
@@ -262,6 +274,15 @@ export async function runCompanyTurn(
lastUpdatedAt: new Date().toISOString(),
startedAt: startedAtIso,
});
// 옛 코드는 write 실패해도 silent 로 logError 만 → 사용자는 *resume turn 손실*
// 사실을 모름. 실패 시 emit 으로 webview 에 통보해 사용자가 즉시 인지.
if (!result.ok) {
emit({
phase: 'log',
level: 'warn',
message: `Resume 상태 저장 실패 (${status}): ${result.reason}. 이 turn 은 이어서 진행 못 할 수 있습니다.`,
});
}
};
const fail = (reason: string, ctx?: {
@@ -672,13 +693,9 @@ async function _dispatchOne(
let verifierIssues: string[] = [];
let verifierSummary = '';
try {
// dynamic import — Phase B는 옵션이므로 미사용 시 모듈 자체를 안 로드.
const { getConfig } = await import('../../config');
const cfgRuntime = getConfig();
// dynamic import 8회 → 정적 import 로 promote (파일 상단). 모듈 자체 cyclic 없음.
const cfgRuntime = getDispatcherConfig();
if (cfgRuntime.selfReflectorExternalEnabled && rawResponse) {
const { verifyResponse, formatIssuesForRetry } =
await import('../selfReflector/selfReflectorVerifier');
const { formatContractForPrompt } = await import('./intentAlignment');
const contractBlock = deps.requirementContract
? formatContractForPrompt(deps.requirementContract)
: undefined;
@@ -726,7 +743,7 @@ async function _dispatchOne(
// appended to the response so the user sees what really happened.
let finalResponse = rawResponse || '_(empty response)_';
let actionReport: string[] | undefined;
const hasTag = !!rawResponse && _hasActionTag(rawResponse);
const hasTag = !!rawResponse && hasActionTag(rawResponse);
if (rawResponse && deps.executeActionTags && hasTag) {
try {
const report = await deps.executeActionTags(rawResponse);
@@ -736,10 +753,8 @@ async function _dispatchOne(
// 사용자가 selfReflector.executionVerification 켰을 때만. 추가
// report 항목들을 actionReport에 append + finalResponse 첨부 본문에도 반영.
try {
const { getConfig } = await import('../../config');
const cfgRuntime = getConfig();
const cfgRuntime = getDispatcherConfig();
if (cfgRuntime.selfReflectorExecutionEnabled && actionReport.length > 0) {
const { verifyCreatedFiles } = await import('../selfReflector/selfReflectorExecution');
const projectRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
if (projectRoot) {
const extra = await verifyCreatedFiles(actionReport, projectRoot);
@@ -761,10 +776,8 @@ async function _dispatchOne(
// 경고만 표시). 작은 LLM이 가장 자주 만드는 실패 패턴이라
// selfReflectorEnabled가 켜져 있으면 *조건부 자동 활성화*.
try {
const { getConfig } = await import('../../config');
const cfgRuntime = getConfig();
const cfgRuntime = getDispatcherConfig();
if (cfgRuntime.selfReflectorEnabled && actionReport.length > 0) {
const { verifyHollow } = await import('../selfReflector/selfReflectorHollow');
const projectRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
if (projectRoot) {
const hollowRes = verifyHollow(actionReport, projectRoot);
@@ -778,14 +791,13 @@ async function _dispatchOne(
verifierSummary = `Hollow code 감지 — 자동 재시도 트리거`;
// 같은 specialist 1회 retry: 빈 깡통 지적을 task 앞에 prepend.
try {
const { formatIssuesForRetry } = await import('../selfReflector/selfReflectorVerifier');
const retryTask = `${formatIssuesForRetry(verifierIssues)}\n\n[원래 지시]\n${task}`;
const retryRes = await deps.ai.chat({ system, user: retryTask, model, signal: deps.signal });
const retried = (retryRes.content || '').trim();
if (retried) {
// 재작업 결과로 본문 갱신 + action-tag 다시 실행.
rawResponse = retried;
if (deps.executeActionTags && _hasActionTag(retried)) {
if (deps.executeActionTags && hasActionTag(retried)) {
const retryReport = await deps.executeActionTags(retried);
actionReport = retryReport;
// 재작업 결과도 hollow 한 번 더 검사.
@@ -826,7 +838,7 @@ async function _dispatchOne(
logError('company.dispatcher: action-tag execution failed.', { agentId, err });
finalResponse = `${rawResponse}\n\n---\n⚠️ Action 실행 실패: ${err}`;
}
} else if (rawResponse && !hasTag && _claimsFileCreation(rawResponse)) {
} else if (rawResponse && !hasTag && claimsFileCreation(rawResponse)) {
// Hallucination guard: small models love to *narrate* file
// creation ("foo.py를 생성했습니다 …") without emitting the
// <create_file> tag — so the user sees ✅ in chat but nothing
@@ -842,7 +854,7 @@ async function _dispatchOne(
// legitimately answer-only. But by flagging the agent output we
// mark it as not-fully-successful so the CEO synthesis can read
// the warning verbatim.
const claimedButDidnt = rawResponse && !hasTag && _claimsFileCreation(rawResponse);
const claimedButDidnt = rawResponse && !hasTag && claimsFileCreation(rawResponse);
// 검증 요약을 response 끝에 한 줄로 첨부 — 사용자가 *어떻게 검증됐는지*
// 빠르게 보고 신뢰도 가늠. issues가 있으면 같이 노출.
if (verifierSummary) {
@@ -967,58 +979,21 @@ async function _resolveStageAgent(
}
return { agentId: candidates[0].id, source: 'fallback-first' };
}
// resolveInspector / parseInspectorVerdict / parseCeoVerdict / renderStageInstruction
// / hasActionTag / claimsFileCreation
// → `src/features/company/dispatcherHelpers.ts`
import {
resolveInspector,
parseInspectorVerdict,
parseCeoVerdict,
renderStageInstruction,
hasActionTag,
claimsFileCreation,
} from './dispatcherHelpers';
/**
* ( ) stage.reviewWith .
* - 'inspector' / 'role:<cat>'
* - 'agent:<id>' (/ )
* null skip.
*/
function _resolveInspector(
reviewWith: string,
state: CompanyState,
): { agentId: string } | null {
if (reviewWith === 'inspector') {
const list = listActiveAgentsByCategory(state)['inspector'] ?? [];
return list[0] ? { agentId: list[0].id } : null;
}
if (reviewWith.startsWith('role:')) {
const cat = reviewWith.slice(5) as AgentRoleCategory;
const list = listActiveAgentsByCategory(state)[cat] ?? [];
return list[0] ? { agentId: list[0].id } : null;
}
if (reviewWith.startsWith('agent:')) {
const id = reviewWith.slice(6);
return resolveAgent(state, id) ? { agentId: id } : null;
}
return null;
}
/**
* verdict를 .
* . 'unclear'
* ( 'revise') .
*/
function _parseInspectorVerdict(text: string): 'pass' | 'revise' | 'unclear' {
const head = (text || '').split(/\n/, 1)[0] ?? '';
if (/^\s*(?:✅|통과|승인|pass|approve|ok)/i.test(head)) return 'pass';
if (/^\s*(?:❌|보완|재작업|revise|reject|fail|보완 필요)/i.test(head)) return 'revise';
// 본문에 명확한 신호가 있으면 잡아냄 — 작은 모델이 머리말을 빠뜨리는 경우.
if (/✅\s*통과|모든 케이스 통과/.test(text)) return 'pass';
if (/❌|보완 필요|재작업/.test(text)) return 'revise';
return 'unclear';
}
function _parseCeoVerdict(text: string): 'pass' | 'revise' | 'abort' | 'unclear' {
const head = (text || '').split(/\n/, 1)[0] ?? '';
if (/^\s*(?:✅|통과|approve|pass|최종\s*ok|진행)/i.test(head)) return 'pass';
if (/^\s*(?:🔁|보완|한 번 더|revise|다시)/i.test(head)) return 'revise';
if (/^\s*(?:🛑|중단|stop|abort|그만)/i.test(head)) return 'abort';
if (/✅\s*통과/.test(text)) return 'pass';
if (/🛑|중단/.test(text)) return 'abort';
if (/🔁|보완|한 번 더/.test(text)) return 'revise';
return 'unclear';
}
/**
* 3-way . (latestOutput) :
@@ -1047,7 +1022,7 @@ async function _runReviewCycle(args: {
const { stage, stageTaskText, latestOutput, state, deps, emit, isAborted } = args;
const reviewWith = stage.reviewWith || '';
if (!reviewWith) return { verdict: 'pass', rounds: 0 };
const inspector = _resolveInspector(reviewWith, state);
const inspector = resolveInspector(reviewWith, state);
if (!inspector) {
// 검수자 못 찾으면 사이클 생략하고 통과로 처리 — 사용자에게 보이지
// 않게 silent; 카드 에디터의 검수 dropdown에서 사용자가 직접 인지할
@@ -1097,7 +1072,7 @@ async function _runReviewCycle(args: {
inspectorText = `❌ 보완 필요: 검수자 호출 실패 (${e?.message ?? '알 수 없음'}) — 안전을 위해 한 번 더 시도`;
}
lastInspectorText = inspectorText;
lastInspectorVerdict = _parseInspectorVerdict(inspectorText);
lastInspectorVerdict = parseInspectorVerdict(inspectorText);
if (isAborted()) {
emit({ phase: 'review-end', stageId: stage.id, final: 'aborted', rounds: round });
@@ -1121,7 +1096,7 @@ async function _runReviewCycle(args: {
ceoText = lastInspectorVerdict === 'pass' ? '✅ 통과' : '🔁 보완';
}
lastCeoText = ceoText;
lastCeoVerdict = _parseCeoVerdict(ceoText);
lastCeoVerdict = parseCeoVerdict(ceoText);
emit({
phase: 'review-round',
@@ -1246,7 +1221,7 @@ async function _runPipeline(
while (i < pipeline.stages.length) {
if (isAborted()) return abortReturn('aborted-mid-pipeline');
const stage = pipeline.stages[i];
const baseTask = _renderStageInstruction(stage, userPrompt, brief, latestByStage);
const baseTask = renderStageInstruction(stage, userPrompt, brief, latestByStage);
const note = revisionNotes[stage.id];
const task = note
? `[사용자 수정 요청]\n${note}\n\n위 피드백을 반드시 반영하세요.\n\n[원래 지시]\n${baseTask}`
@@ -1385,58 +1360,5 @@ async function _runPipeline(
return { outputs };
}
/**
* Substitute template tokens in a stage's instruction. Falls back to the
* raw user prompt when the template is empty so the user doesn't have to
* fill every stage with a long template just to forward the original ask.
*/
function _renderStageInstruction(
stage: PipelineStage,
userPrompt: string,
brief: string,
latestByStage: Record<string, AgentTurnOutput>,
): string {
const tpl = (stage.instructionTemplate || '').trim();
if (!tpl) return userPrompt;
return tpl
.replace(/\{\{\s*userPrompt\s*\}\}/g, userPrompt)
.replace(/\{\{\s*brief\s*\}\}/g, brief)
.replace(/\{\{\s*stage\.([a-zA-Z0-9_-]+)\s*\}\}/g, (_m, sid) => {
const o = latestByStage[sid];
return o?.response ?? `[stage:${sid} 아직 실행되지 않음]`;
});
}
/**
* Cheap pre-check so we don't fire up the action-tag executor for every
* specialist response only the ones that actually contain a recognised
* tag. Saves a workspace lookup + transaction-manager spin-up on the common
* case (the agent just talks).
*/
function _hasActionTag(text: string): boolean {
return /<\s*(?:create_file|edit_file|delete_file|read_file|list_files|list_brain|run_command|read_brain|reveal_in_explorer|open_file|glob|grep)\b/i.test(text);
}
/**
* Heuristic: does the response *narrate* having created files/folders?
*
* We look for the combination of (a) a Korean / English creation verb and
* (b) a filename-like or "folder" mention. The intent is to catch the
* hallucination pattern where an agent writes "foo.py 파일을 생성했습니다"
* or "Created `bar/` directory" without emitting the corresponding
* `<create_file>` tag, so the dispatcher can flag it back to the CEO and
* the user instead of silently reporting success.
*
* Kept narrow on purpose a *plan* like "다음에는 X를 만들어야 합니다"
* shouldn't trigger this. We require past-tense / completion phrasing.
*/
function _claimsFileCreation(text: string): boolean {
// Past-tense creation verbs (Korean + English).
const claimRe = /(?:생성했|만들었|작성했|저장했|구현했|created|wrote|saved|built|generated)/i;
if (!claimRe.test(text)) return false;
// Combined with either an explicit filename (something.ext) or the word
// "폴더" / "directory" / "folder" near the verb.
const fileLike = /\b[\w\-./]+\.(?:py|js|ts|tsx|jsx|md|json|html|css|sh|yaml|yml|sql|java|go|rs|c|cpp|rb|php)\b/i.test(text);
const folderLike = /(?:폴더|디렉토리|directory|folder)/i.test(text);
return fileLike || folderLike;
}
+121
View File
@@ -0,0 +1,121 @@
import { listActiveAgentsByCategory, resolveAgent } from './companyConfig';
import type {
AgentRoleCategory,
AgentTurnOutput,
CompanyState,
PipelineStage,
} from './types';
/**
* `dispatcher.ts` 6 stateless helper . dispatcher
* (a) , (b) parsing .
*
* - resolveInspector(reviewWith, state) `inspector` / `role:<cat>` / `agent:<id>`
* - parseInspectorVerdict(text) pass / revise / unclear
* - parseCeoVerdict(text) pass / revise / abort / unclear
* - renderStageInstruction(stage, ...) instruction 릿
* - hasActionTag(text) action-tag cheap pre-check
* - claimsFileCreation(text) past-tense narration
*/
/**
* ( ) stage.reviewWith :
* - 'inspector' inspector
* - 'role:<cat>'
* - 'agent:<id>' (/ )
* null skip.
*/
export function resolveInspector(reviewWith: string, state: CompanyState): { agentId: string } | null {
if (reviewWith === 'inspector') {
const list = listActiveAgentsByCategory(state)['inspector'] ?? [];
return list[0] ? { agentId: list[0].id } : null;
}
if (reviewWith.startsWith('role:')) {
const cat = reviewWith.slice(5) as AgentRoleCategory;
const list = listActiveAgentsByCategory(state)[cat] ?? [];
return list[0] ? { agentId: list[0].id } : null;
}
if (reviewWith.startsWith('agent:')) {
const id = reviewWith.slice(6);
return resolveAgent(state, id) ? { agentId: id } : null;
}
return null;
}
/**
* verdict .
* . 'unclear' ( 'revise') .
*/
export function parseInspectorVerdict(text: string): 'pass' | 'revise' | 'unclear' {
const head = (text || '').split(/\n/, 1)[0] ?? '';
if (/^\s*(?:✅|통과|승인|pass|approve|ok)/i.test(head)) return 'pass';
if (/^\s*(?:❌|보완|재작업|revise|reject|fail|보완 필요)/i.test(head)) return 'revise';
// 본문에 명확한 신호가 있으면 잡아냄 — 작은 모델이 머리말을 빠뜨리는 경우.
if (/✅\s*통과|모든 케이스 통과/.test(text)) return 'pass';
if (/❌|보완 필요|재작업/.test(text)) return 'revise';
return 'unclear';
}
/**
* CEO - verdict. pass / revise / abort / unclear. verdict
* - abort ( ).
*/
export function parseCeoVerdict(text: string): 'pass' | 'revise' | 'abort' | 'unclear' {
const head = (text || '').split(/\n/, 1)[0] ?? '';
if (/^\s*(?:✅|통과|approve|pass|최종\s*ok|진행)/i.test(head)) return 'pass';
if (/^\s*(?:🔁|보완|한 번 더|revise|다시)/i.test(head)) return 'revise';
if (/^\s*(?:🛑|중단|stop|abort|그만)/i.test(head)) return 'abort';
if (/✅\s*통과/.test(text)) return 'pass';
if (/🛑|중단/.test(text)) return 'abort';
if (/🔁|보완|한 번 더/.test(text)) return 'revise';
return 'unclear';
}
/**
* Stage instruction 릿 . 릿 raw user prompt
* stage 릿 .
*
* :
* - {{userPrompt}} prompt
* - {{brief}} brief
* - {{stage.<sid>}} stage response ( placeholder)
*/
export function renderStageInstruction(
stage: PipelineStage,
userPrompt: string,
brief: string,
latestByStage: Record<string, AgentTurnOutput>,
): string {
const tpl = (stage.instructionTemplate || '').trim();
if (!tpl) return userPrompt;
return tpl
.replace(/\{\{\s*userPrompt\s*\}\}/g, userPrompt)
.replace(/\{\{\s*brief\s*\}\}/g, brief)
.replace(/\{\{\s*stage\.([a-zA-Z0-9_-]+)\s*\}\}/g, (_m, sid) => {
const o = latestByStage[sid];
return o?.response ?? `[stage:${sid} 아직 실행되지 않음]`;
});
}
/**
* Cheap pre-check text action-tag true. action-tag executor
* specialist .
*/
export function hasActionTag(text: string): boolean {
return /<\s*(?:create_file|edit_file|delete_file|read_file|list_files|list_brain|run_command|read_brain|reveal_in_explorer|open_file|glob|grep)\b/i.test(text);
}
/**
* Heuristic: response *narrate* "파일 생성했음" (action-tag ).
*
* / + / mention true. plan ("다음에 X 를 만들어야")
* . agent `<create_file>` "foo.py
* " dispatcher CEO flag.
*/
export function claimsFileCreation(text: string): boolean {
const claimRe = /(?:생성했|만들었|작성했|저장했|구현했|created|wrote|saved|built|generated)/i;
if (!claimRe.test(text)) return false;
const fileLike = /\b[\w\-./]+\.(?:py|js|ts|tsx|jsx|md|json|html|css|sh|yaml|yml|sql|java|go|rs|c|cpp|rb|php)\b/i.test(text);
const folderLike = /(?:폴더|디렉토리|directory|folder)/i.test(text);
return fileLike || folderLike;
}
+8 -2
View File
@@ -24,19 +24,25 @@ const RESUME_FILE = '_resume.json';
/**
* Write the resume state atomically. tmp rename으로
* _resume.json은 .
*
* 반환: 성공 . boolean surface.
* silent logError *resume turn *.
*/
export function writeResumeState(sessionDir: string, state: CompanyResumeState): void {
export function writeResumeState(sessionDir: string, state: CompanyResumeState): { ok: true } | { ok: false; reason: string } {
const target = path.join(sessionDir, RESUME_FILE);
const tmp = target + '.tmp';
try {
fs.mkdirSync(sessionDir, { recursive: true });
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf8');
fs.renameSync(tmp, target);
return { ok: true };
} catch (e: any) {
const reason = e?.message ?? String(e);
logError('company.resumeStore: write failed.', {
sessionDir: path.basename(sessionDir),
error: e?.message ?? String(e),
error: reason,
});
return { ok: false, reason };
}
}
+35
View File
@@ -19,6 +19,41 @@ export function getBridgeBaseUrl(): string {
return url.replace(/\/$/, '');
}
/**
* Datacollect Bridge API endpoints .
*
* endpoint hardcoded bridge API
* 5+ . .
*
* :
* - research: NotebookLM Deep Research
* - youtube: yt-dlp + youtube-transcript-api
* - web: Playwright ·
* - wiki: 생성된
* - lm: 사용자의 LM Studio / Ollama LLM
*/
export const BRIDGE_API = {
research: {
start: '/api/research/start',
status: '/api/research/status',
import: '/api/research/import',
synthesize: '/api/research/synthesize',
},
youtube: {
extract: '/api/youtube/extract',
},
web: {
benchmarkScan: '/api/web-benchmark/scan',
extract: '/api/web-extract',
},
wiki: {
save: '/api/wiki/save',
},
lm: {
proxy: '/api/lm',
},
} as const;
export interface BridgeFetchOptions {
timeoutMs?: number;
signal?: AbortSignal;
@@ -0,0 +1,72 @@
/**
* (Actionable Minutes) LLM .
* 규칙: Fact/Discussion/Decision/Risk/Action , .
*/
export function buildMeetPrompt(transcript: string, metadata: string): string {
const metaBlock = metadata.trim()
|| '(메타데이터 미입력 — 녹취록 내용에서 추론하거나 "확인 불가"로 표기)';
return `# 임무 (Objective)
,
(Actionable Minutes) .
# (Role)
- Fact Extractor: 사실만
- Decision Tracker: 결정
- Action Organizer: 실행
- Context Filter: 불필요한 ()
# (Data Priority)
1순위: 메타데이터 / 2순위: 녹취록 . .
# (Processing Flow)
1. Deconstruction , , ID는 .
2. Classification Fact / Discussion / Decision / Risk / Action .
3. Decision Logic
- Decision
- + Action
- / Discussion
- Open Issue
4. Structuring , , .
# (Validation)
점검한다: Decision은 / Action은 .
, · . .
[]
${metaBlock}
[ ]
\`\`\`
${transcript}
\`\`\`
# (Output Format )
# [ ]
- ****: [YYYY년 MM월 DD일 | ]
- ****: [ | 경우: 논의 ]
- ** **: [ ]
## 🔹
3~5 .
## 1.
:
### [ ]
- ****:
- ** **:
- ****: [ / / ]
## 2.
## 3.
## 4.
## 5.
| | | |
| --- | --- | --- |
, .`;
}
@@ -0,0 +1,257 @@
/** /benchmark 보고서의 3 파트 분할 — 1: 4-렌즈 / 2: IA + 토큰 / 3: 재구축 명세. */
export type SynthesisPart = 1 | 2 | 3;
/**
* scan JSON 4- LLM . Datacollect (WebBenchmarkPanel)
* buildSynthesisPrompt를 /benchmark .
*/
export function buildSynthesisPrompt(scan: any, userContent: string, part: SynthesisPart): string {
// 4-렌즈 분석에 필요한 핵심 데이터만 추려 LLM 입력을 가볍게.
const slim = {
url: scan?.url,
title: scan?.meta?.title,
description: scan?.meta?.description,
lang: scan?.meta?.lang,
// §1. 비주얼 아이덴티티 — 컬러 비율 + 다크모드 + 타이포 위계
colors: {
palette: scan?.design?.colors?.palette?.slice(0, 8),
composition: scan?.design?.colors?.composition,
background: scan?.design?.colors?.background,
primaryText: scan?.design?.colors?.primaryText,
linkColor: scan?.design?.colors?.linkColor,
buttonBackground: scan?.design?.colors?.buttonBackground,
buttonText: scan?.design?.colors?.buttonText,
darkModeHints: scan?.design?.colors?.darkModeHints,
},
typography: {
primaryFont: scan?.design?.typography?.primaryFont,
fontStack: scan?.design?.typography?.fontStack?.slice(0, 3),
topFontSizes: scan?.design?.typography?.topFontSizes?.slice(0, 6),
topFontWeights: scan?.design?.typography?.topFontWeights?.slice(0, 5),
body: scan?.design?.typography?.body,
h1: scan?.design?.typography?.h1,
h2: scan?.design?.typography?.h2,
h3: scan?.design?.typography?.h3,
button: scan?.design?.typography?.button,
},
// §2. 레이아웃 & 공간감 — 여백 / 그리드
layout: {
viewport: { w: scan?.design?.layout?.viewportWidth, h: scan?.design?.layout?.viewportHeight },
bodyMaxWidth: scan?.design?.layout?.bodyMaxWidth,
sectionSpacing: scan?.design?.layout?.sectionSpacing,
cardSpacing: scan?.design?.layout?.cardSpacing,
borderRadiusScale: scan?.design?.layout?.borderRadiusScale,
grids: scan?.design?.layout?.grids,
containerSystem: scan?.design?.layout?.containerSystem,
responsiveHints: scan?.design?.layout?.responsiveHints,
layering: scan?.design?.layout?.layering,
},
components: scan?.design?.components,
mediaTreatment: scan?.design?.mediaTreatment,
surfaceTreatment: scan?.design?.surfaceTreatment,
// §3. 마이크로 인터랙션 — Hover / Transition
interactions: {
hoverRules: scan?.interactions?.hoverRules?.slice(0, 6),
focusRules: scan?.interactions?.focusRules?.slice(0, 3),
transitionDistribution: scan?.interactions?.transitionDistribution,
cssVars: scan?.interactions?.cssVars,
},
// §4. 라이팅 톤앤매너 — 마이크로카피
microcopy: {
headline: scan?.microcopy?.headline,
subheadline: scan?.microcopy?.subheadline,
subheadlines: scan?.microcopy?.subheadlines?.slice(0, 6),
ctaSamples: scan?.microcopy?.ctaSamples?.slice(0, 10),
placeholders: scan?.microcopy?.placeholders,
stateMessages: scan?.microcopy?.stateMessages,
ariaLabels: scan?.microcopy?.ariaLabels?.slice(0, 6),
bodySample: scan?.microcopy?.bodySample,
voiceSignals: scan?.microcopy?.voiceSignals,
},
// 구조 요약 — 역기획서 매칭을 위한 보조 정보
structure: {
sections: scan?.structure?.sections?.slice(0, 10).map((s: any) => ({
role: s.role,
depth: s.depth,
text: s.textPreview?.slice(0, 100),
btns: s.buttonCount,
links: s.linkCount,
imgs: s.imgCount,
})),
h1: scan?.structure?.h1,
h2List: scan?.structure?.h2List?.slice(0, 6),
navigationLinks: scan?.structure?.navigationLinks?.slice(0, 10),
},
iconography: scan?.design?.iconography,
// §5. 사이트맵 — 준비할 리소스 추정의 근거 (페이지별 역할 + 자산 수).
sitemap: scan?.sitemap ? {
totalPages: scan.sitemap.totalPages,
crawlDepth: scan.sitemap.crawlDepth,
asciiTree: scan.sitemap.ascii,
pages: scan.sitemap.pages?.map((p: any) => ({
url: p.url,
role: p.role,
title: p.title?.slice(0, 80),
h1: p.h1?.slice(0, 80),
h2List: p.h2List?.slice(0, 5),
contentType: p.primaryContentType,
imageCount: p.imageCount,
videoCount: p.videoCount,
formFields: p.formFields?.slice(0, 6).map((f: any) => ({
name: f.name || f.label, type: f.type, required: f.required,
})),
ctas: p.ctaSamples?.slice(0, 4),
sections: p.sectionRoles?.slice(0, 8).map((s: any) => ({
tag: s.tag, hint: s.hint, preview: (s.preview || '').slice(0, 50),
})),
error: p.error,
})),
} : null,
};
const today = new Date().toISOString().slice(0, 10);
const title = slim.title || 'Reference Site';
const userBlock = userContent.trim()
? userContent.trim()
: '(미입력 — 원본 사이트 자체의 재현에만 집중)';
const sharedRules = `
[ ]
1. "원본 레퍼런스 사이트를 가능한 한 닮은 사이트를 처음부터 다시 만들기 위한 명세" .
2. . JSON / .
3. JSON에 . "스캔 데이터 부족" .
4. .
5. ///Radius는 (rgb/px) .`;
const commonHeader = `
# ${title}
> ** URL**: ${slim.url}
> ** **: ${today}
> ** **: 4- (Visual / Layout / Interaction / Voice) + IA 릿 +
> ** **: ${slim.sitemap?.totalPages ?? 1} (crawlDepth: ${slim.sitemap?.crawlDepth ?? 0})`;
const partTemplate = part === 1
? `
${commonHeader}
## (One-line Impression)
## 1. (Visual Identity)
### 1-1. (Color Palette)
### 1-2. (Typography)
## 2. (Layout & Whitespace)
### 2-1. (Grid System)
### 2-2. (Section Spacing)
### 2-3. / (Card Spacing)
### 2-4. Border Radius /
## 3. (Micro Interaction)
### 3-1. Hover / Focus
### 3-2. Transition
### 3-3. (z-index / position)
## 4. (Microcopy & Voice)
### 4-1. / / CTA
### 4-2. Placeholder `
: part === 2
? `
## 5. / (Information Architecture)
### 5-1. (Page Tree)
- \`sitemap.asciiTree\`를 코드블록으로 그대로 옮겨 적을 것.
### 5-2. (Flat View)
### 5-3. (Page Composition)
### 5-4. IA
### 5-5. (Component Reconstruction Spec)
### 5-6. (Media Treatment)
## 6. (Resources You Need to Prepare)
### 6-1. /
### 6-2.
### 6-3. /
## 7. (Design Tokens)
- Color / Typography / Spacing / Radius / Border / Shadow / Motion .
## 8. 릿 (Page Template Map)
\`primaryContentType\` + \`sectionRoles\` + \`h2List\`를 묶어 **반복되는 템플릿 유형**을 도출하고, 반드시 아래 표 형식으로 작성하라. **반드시 마크다운 표 문법을 쓸 것** (글머리표·산문 금지). 템플릿은 최소 3개 이상 도출하되, 페이지가 모두 다르면 그만큼만 작성.
| 릿 ID | URL | ( ) | | |
|---|---|---|---|---|
| T1: Gallery Landing | / | Header → Hero(작품 1장) → 작품 그리드(3열) → Footer | (없음 / ) | Header, ImageCard, Footer |
| T2: Category List | /shop, /paintings | Header (h1) (2) Pagination Footer | · | Header, ImageCard, Footer, Pagination |
| T3: Detail | /shop/oil-painting/limited-editions | Header Breadcrumb () + ·CTA() Footer | ··CTA | Header, BreadcrumbBar, BuyButton, RelatedGrid, Footer |
:
- **릿 ID**: \`T1: <역할>\` 형식. 역할은 한국어 또는 영어 모두 OK.
- ** URL**: 릿 URL을 . 1 1.
- ** **: \`sectionRoles\`의 tag 순서를 기반으로 \`Header → Hero → ... → Footer\` 식으로 위→아래 흐름을 화살표(\`\`)로 표기.
- ** **: 릿 (/ /CTA ). \`(없음 / 단독 페이지)\`.
- ** **: 5-5 .
릿 ATag/CSS ().`
: `
## 9. (Rebuild Spec Same Site, Built From Scratch)
> ** ( )**
> - ** **.
> - (, , SaaS ) **·· **. part 9 .
> - "개선 / 재설계 / 모던화 / 데이터 시각화 추가" . ** ** .
> - (···Radius· ) part 1~7 .
### 9-1. ( )
- part 7 CSS Tailwind config . .
### 9-2. ( // )
- part 5-5 props··padding·radius·border·shadow를 .
### 9-3.
- part 8 릿 릿(T1, T2, ...) HTML ( ) JSX/HTML로 1 .
### 9-4.
- part 3 hover/focus/transition (: \`.btn:hover { background: ...; transition: 0.2s ease; }\`).
### 9-5.
- part 6 / , , . .
### 9-6. ( )
- 9-1 ~ 9-5 \`[FE] / [BE] / [Asset]\` 태그를 붙여 티켓 형태로 나열. 모든 티켓은 "원본 사이트와 같게 만들기" 범위 안에 있어야 한다. 신규 기능 제안 금지.
## 🔍 (Buildability Gaps)
- ( ·CMS · · ) . , .
> ****: ** **. 9-1 ~ 9-6 part 1~8 .`;
const partGoal = part === 1
? '1/3단계: 원본 사이트의 시각·인터랙션·카피 톤을 4-렌즈로 분석한다.'
: part === 2
? '2/3단계: 원본의 IA, 페이지 템플릿, 디자인 토큰, 준비 리소스를 정리한다. 8단계 Page Template Map은 반드시 표 형식으로 작성한다.'
: '3/3단계: 원본 레퍼런스 사이트를 처음부터 다시 만들기 위한 개발 명세를 작성한다. **사용자 컨텍스트는 무시하고, 다른 서비스로 재해석하지 않는다.**';
return `당신은 시니어 UX/UI 분석가 겸 프론트엔드 아키텍트다.
${sharedRules}
[ ]
${partGoal}
[ (JSON)]
\`\`\`json
${JSON.stringify(slim, null, 2)}
\`\`\`
[ part 1·2 . part 3 .]
${userBlock}
[ ( )]
${partTemplate}`;
}
@@ -0,0 +1,88 @@
/**
* Datacollect Research(P-Reinforce v3.0)
* . Bridge의 /api/research/synthesize 릿 .
*/
export function buildWikifyPrompt(extracted: any, userContent: string): string {
const today = new Date().toISOString().slice(0, 10);
const topic = (userContent.trim() || extracted?.title || extracted?.url || '웹사이트 지식').trim();
const url = extracted?.url || '';
const idSlug = (topic.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9가-힣-]/g, '').slice(0, 80)) || 'web-wiki';
const headings = Array.isArray(extracted?.headings) ? extracted.headings.slice(0, 40) : [];
const body = String(extracted?.text || '').slice(0, 30000);
return `임무: 아래 웹사이트에서 추출한 본문을 근거로 P-Reinforce v3.0 규격에 맞춰 고밀도 지식 문서를 작성하시오.
: '${topic}'
[ ]
1. [ ] . .
2. Markdown 릿 Frontmatter . .
3. , "본문에서 확인되지 않음" .
4. . · [[ ]] . \`[[\` 로 열고 \`]]\` 로 닫으시오 (닫는 대괄호를 빠뜨리지 마시오).
5. JSON Schema·API · · , '📖 세부 내용' ·· ** ** . [ | | / | ·]. \`required\` 배열에 있는 항목만 '필수'로 표기하고, 그 목록을 임의로 바꾸거나 추가하지 마시오. \`additionalProperties\`·\`const\`·\`enum\` 같은 제약과 중첩 객체 구조도 원문 그대로 반영하시오. 최상위 필드를 하나라도 빠뜨리지 마시오.
[ ]
- URL: ${url}
- 제목: ${extracted?.title || '(없음)'}
- 설명: ${extracted?.description || '(없음)'}
- 헤딩: ${headings.join(' / ') || '(없음)'}
[ ]
\`\`\`
${body}
\`\`\`
[ 릿 - ]
---
id: ${idSlug}
title: "${topic}"
category: "10_Wiki/Topics"
status: "draft"
verification_status: "conceptual"
canonical_id: ""
aliases: []
duplicate_of: ""
source_trust_level: "B"
confidence_score: 0.8
created_at: ${today}
updated_at: ${today}
review_reason: ""
merge_history: []
tags: ["web", "wikify"]
raw_sources: ["${url}"]
applied_in: []
github_commit: ""
---
# [[${topic}]]
## 🎯 (One-line insight)
( / )
## 🧠 (Core concepts)
( 3-5 /)
## 🧩 (Extracted patterns)
( , , )
## 📖 (Details)
( . . ··API 5 .)
## (Contradictions & updates)
( . "본문에서 확인되지 않음".)
## 🛠 (Applied in summary)
( ···· . "본문에서 확인되지 않음".)
##
- **:** draft
- ** :** conceptual
- ** :** B (Primary Source )
- ** :** (New discovery)
## 🔗 (Related document links)
( 3-7 [[]] , . .)
## 📝 (Change history)
- ${today}: Astra /wikify ${url} .`;
}
@@ -0,0 +1,331 @@
/**
* `/youtube` slash command LLM + .
* - formatHms / fullScriptFromSegments / bucketSegments segment list
* - YoutubeAnalysisMode info/benchmark/both enum (slashRouter )
* - buildInfoExtractionPrompt * ()*
* - build4LensPrompt * * (///CTR) 4-
*
* 코드: slashRouter.ts 320 inline . (a)
* segment helper , (b) ,
* (c) prompt .
*/
export function formatHms(totalSec: number): string {
if (!isFinite(totalSec) || totalSec <= 0) return '00:00';
const s = Math.floor(totalSec);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
return h > 0
? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
: `${m}:${String(sec).padStart(2, '0')}`;
}
/**
* 30 `[mm:ss] 문장…` full script로 .
* YouTube segment가 .
*/
export function fullScriptFromSegments(segments: any[] | undefined): string {
if (!segments || segments.length === 0) return '(자막 없음 — 자동 자막이 없는 영상일 수 있습니다.)';
const buckets = new Map<number, string[]>();
for (const seg of segments) {
const b = Math.floor((seg.start || 0) / 30);
const arr = buckets.get(b) || [];
arr.push(String(seg.text || '').trim());
buckets.set(b, arr);
}
return Array.from(buckets.entries())
.sort((a, b) => a[0] - b[0])
.map(([b, texts]) => `**[${formatHms(b * 30)}]** ${texts.join(' ').replace(/\s+/g, ' ').trim()}`)
.join('\n\n');
}
/**
* timestamped segments "타임라인 뼈대" .
* §2 LLM .
*/
export function bucketSegments(segments: any[] | undefined, bucketSec = 30): { time: string; text: string }[] {
if (!segments || segments.length === 0) return [];
const buckets = new Map<number, string[]>();
for (const seg of segments) {
const bucket = Math.floor(seg.start / bucketSec);
const arr = buckets.get(bucket) || [];
arr.push(String(seg.text || '').trim());
buckets.set(bucket, arr);
}
return Array.from(buckets.entries())
.sort((a, b) => a[0] - b[0])
.map(([bucket, texts]) => ({
time: formatHms(bucket * bucketSec),
text: texts.join(' ').replace(/\s+/g, ' ').trim().slice(0, 240),
}));
}
/** Astra `/youtube` 의 분석 모드. 사용자 입력 `mode:info|benchmark|both`. */
export type YoutubeAnalysisMode = 'info' | 'benchmark' | 'both';
/**
* (info) LLM *·* .
*
* 의도: build4LensPrompt "이 영상을 어떻게 베껴 만들지"
* ···· .
* * * ·· ,
* ·· .
*
* build4LensPrompt (, , ).
*/
export function buildInfoExtractionPrompt(video: any, userContent: string): string {
const meta = video.metadata || {};
const segments = video.segments || [];
// 자막 본문 — info 모드는 *전체* 본문을 보여줘야 사실 추출이 정확. 단,
// LLM 컨텍스트 한도 고려해 너무 길면 trim. 12000자 = 가벼운 강의 60분 분량 정도.
const fullText = segments.map((s: any) => String(s.text || '').trim()).join(' ').replace(/\s+/g, ' ');
const trimmed = fullText.length > 12000 ? fullText.slice(0, 12000) + ' …[자막 일부 잘림]' : fullText;
const slim = {
url: meta.webpage_url || `https://www.youtube.com/watch?v=${video.video_id}`,
title: meta.title || video.title,
channel: meta.channel,
durationSec: meta.duration,
durationHms: meta.duration_string,
uploadDate: meta.upload_date,
viewCount: meta.view_count,
likeCount: meta.like_count,
tags: (meta.tags || []).slice(0, 8),
categories: meta.categories,
chapters: meta.chapters,
descriptionPreview: (meta.description || '').slice(0, 600),
};
const today = new Date().toISOString().slice(0, 10);
const userBlock = userContent.trim()
? `\n\n[사용자 컨텍스트 — 사용자가 어떤 관점에서 이 영상을 활용하려는지]\n${userContent.trim()}`
: '';
return `당신은 영상 콘텐츠를 *지식 카드*로 변환하는 정보 큐레이터입니다. 사장님이
, * *
(···) .
[ ]
1. ** ** () * * . ·
· \`## 🧩 정리자 노트\` 섹션에만. 두 줄 섞지 말 것.
2. ** ** "본문에 명시되지 않음" "해당 사례 없음".
3. ** ** :
- \`[근거 명시]\` 구체 출처·수치·인용이 본문에 있음
- \`[화자 주장]\` 출처 없는 단정 (디노가 그렇게 말함)
- \`[가정]\` 조건부·"~인 것 같다" 표현
- \`[정리자 추론]\` 본문에 없지만 정리자가 추가 (이건 정리자 노트 섹션 전용)
4. ** ** · · \`(mm:ss)\` 무조건 붙임.
fail. "(시점 미상)" .
5. ** + ** ··"X 는 Y 같은 것"
\`## 💡 화자 한 줄 비유\` 에 보존. 영상의 결정적 요약이 거기
. "본문에 명시된 한 줄 비유 없음" .
** ** "Hugging Face = 자료실, Reddit = 공부방"
( )
. . ··
.
6. **· ** "A → B → C 순서로" ** "
" . .
7. . ·릿 .
[ ]
\`\`\`json
${JSON.stringify(slim, null, 2)}
\`\`\`
[ ]
${trimmed}${userBlock}
[ . 8 ]
# ${slim.title || video.title}
> ** URL**: ${slim.url} · ** **: ${today} · ****: ${slim.durationHms || (slim.durationSec ? formatHms(slim.durationSec) : '?')} · ****: ${slim.channel || '?'}
## 🎯 (TL;DR)
( . "무엇이 누구에게 왜 중요한가" .
. )
## 💡 (Anchor Metaphor)
* ·*
. . : "Hugging Face = , Reddit = ,
= " 같은 식. 없으면 " ".
## 📌 3~5
** ·. ( 🧩 ).
+ + (mm:ss).
- **[ ]** "주장 한 줄" (mm:ss)
- **[ ]** "주장 한 줄" (mm:ss)
-
## 📊 ··
* ···· *. .
:
| | / | ( ) | |
| --- | --- | --- | --- |
| | | / / | mm:ss |
"본문에 명시된 구체 수치·출처 없음" .
## 🧭 (Sectioned Summary)
chapters ( ) 30
* *. 1~2. .
- **[00:0002:30]** (mm:ssmm:ss)
- **[02:3005:00]** (mm:ssmm:ss)
-
## 🔗 (Citation Snippets)
* * . ·· .
3~5. . .
- "직접 인용 한 문장" ${slim.title || video.title}, ${slim.channel || '?'} (mm:ss)
-
## (Open Questions)
2~4.
.
- "본문에서 X 가 Y 라고 했지만 Z 데이터 출처는 명시 안 됨 — 원 데이터 찾아볼 것"
-
## 🧩 ( )
* * ···. 6
, "이건 화자가 말한 게 아니라 LLM 이 추론한 거"
. \`[정리자 추론]\` 라벨로 시작.
- **[ ]** "여러 채널을 동시 시청" ,
.
-
"정리자 추가 노트 없음 — 본문 그대로가 명확함" .`;
}
/**
* extract된 4-(///CTR) LLM .
* Datacollect (YoutubePanel) build4LensPrompt를 .
*/
export function build4LensPrompt(video: any, userContent: string): string {
const meta = video.metadata || {};
const segments = video.segments || [];
// 초반 30초 / 60초 텍스트 — §1 훅 분석용.
const first30s = segments.filter((s: any) => s.start < 30).map((s: any) => String(s.text || '').trim()).join(' ').slice(0, 600);
const first60s = segments.filter((s: any) => s.start < 60).map((s: any) => String(s.text || '').trim()).join(' ').slice(0, 1200);
// 타임라인 버킷 (30초 단위) — §2 구조 분석용.
const timelineBuckets = bucketSegments(segments, 30);
const timelinePreview = timelineBuckets.slice(0, 24).map(b => `[${b.time}] ${b.text}`).join('\n');
// 인게이지먼트 키워드 매치 — §2 보조.
const engagementHits = segments
.filter((s: any) => /구독|좋아요|알림|댓글|공유|subscribe|like|comment/i.test(String(s.text || '')))
.slice(0, 5)
.map((s: any) => ({ t: formatHms(s.start), text: String(s.text || '').trim().slice(0, 100) }));
const slim = {
url: meta.webpage_url || `https://www.youtube.com/watch?v=${video.video_id}`,
title: meta.title || video.title,
channel: meta.channel,
durationSec: meta.duration,
durationHms: meta.duration_string,
viewCount: meta.view_count,
likeCount: meta.like_count,
commentCount: meta.comment_count,
uploadDate: meta.upload_date,
thumbnail: meta.thumbnail,
tags: (meta.tags || []).slice(0, 12),
categories: meta.categories,
chapters: meta.chapters,
descriptionPreview: (meta.description || '').slice(0, 600),
opening30s: first30s,
opening60s: first60s,
engagementMoments: engagementHits,
segmentCount: segments.length,
timelinePreview,
};
const today = new Date().toISOString().slice(0, 10);
const userBlock = userContent.trim()
? userContent.trim()
: '(미입력 — 일반 콘텐츠 제작자 컨텍스트로 작성)';
return `당신은 유튜브 '대본(스크립트)' 분석 전문가이자 콘텐츠 작가입니다. 사장님이
** ** .
() ,
'유저 친화적 역기획서' .
[ ]
1. BGM·· · '영상 연출' .
() .
2. , '언어적 장치'
. :
#FOMO # # # # #Promise #
# # # #릿 #
3. , '쉬운 비유'
'말의 맛' .
4. . (text)·chapters· ( ). mm:ss.
[ ]
\`\`\`json
${JSON.stringify(slim, null, 2)}
\`\`\`
[ / ]
${userBlock}
[ . 5 ]
# ${slim.title || video.title}
> ** URL**: ${slim.url} · ** **: ${today} · ****: ${slim.durationHms || (slim.durationSec ? formatHms(slim.durationSec) : '?')} · ****: ${slim.channel || '?'}
## 🎬 (One-line Read)
( . : "
, ")
## 1. (Script Architecture)
1. '레퍼런스 실제 대사' 1 .
'스크립트 기능' 1~2 . % durationSec ,
chapters가 , timelinePreview로 .
| () | () | | |
| --- | --- | --- | --- |
| Hook (0:00~?, ?%) | # #Promise | "첫 대사…" | |
| (?~?, ?%) | | | |
| (?~?, ?%) | | | |
| ·CTA (?~?, ?%) | | | |
## 2. & (Tone & Manner)
- ** **: /, , 1 .
- ** **: ) / 1.
- ** **: ·릿
2~3 , .
- ** **: ·
\`용어 → "화자의 실제 표현"\` 형태로 2~3개. 사례가 없으면 "해당 사례 없음"이라 명시.
## 3. (Action Items)
3~4. , .
- [ ] (: 오프닝 15 '내가 누구인지' )
- [ ]
- [ ]
## 릿 (Fill-in-the-Blank)
·· , [ ]
. [ ] .
\`\`\`
[ Hook]
", [ ] ?
[ ] [] ."
[ + ]
[ ]
[ CTA]
\`\`\`
> · . · / .`;
}
@@ -0,0 +1,89 @@
/**
* `/meet` action items task
* stateless helpers. slashRouter inline .
*
* - addBusinessDays(base, n) · n
* - toYmd(d) Date 'YYYY-MM-DD'
* - extractMeetingDate(report, fallback) ( fallback)
* - resolveTaskDate(due, meetingDate, today) 'D+3' / 'EOW' due
* - parseActionItems(report) action items
*/
// ─── /meet 캘린더 등록 헬퍼 ───
/** 토·일을 제외하고 영업일 n일을 더한 날짜 (공휴일은 고려하지 않음). */
export function addBusinessDays(base: Date, n: number): Date {
const r = new Date(base);
let added = 0;
while (added < n) {
r.setDate(r.getDate() + 1);
const day = r.getDay();
if (day !== 0 && day !== 6) added++;
}
return r;
}
/** Date → 'YYYY-MM-DD' (로컬 기준). */
export function toYmd(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
/** 회의록 본문의 "**날짜**: 2026년 05월 08일"에서 회의 날짜 추출. 없으면 fallback. */
export function extractMeetingDate(report: string, fallback: Date): Date {
const m = report.match(/날짜\*{0,2}\s*[:]\s*\*{0,2}\s*(\d{4})\s*년\s*(\d{1,2})\s*월\s*(\d{1,2})\s*일/);
if (m) {
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
if (!isNaN(d.getTime())) return d;
}
return fallback;
}
/**
* '기한' . :
* - (YYYY-MM-DD / YYYY년 M월 D일)
* - "차주 / 다음 주 / 내주" +6
* - "즉시 / 당일 / 금일 / 바로 / 오늘" ()
* - / + 5, tentative=true ( "(미확정)")
*/
export function resolveTaskDate(due: string, meetingDate: Date, today: Date): { date: string; tentative: boolean } {
const t = (due || '').trim();
const iso = t.match(/(\d{4})-(\d{1,2})-(\d{1,2})/);
if (iso) {
return { date: `${iso[1]}-${iso[2].padStart(2, '0')}-${iso[3].padStart(2, '0')}`, tentative: false };
}
const kor = t.match(/(\d{4})\s*년\s*(\d{1,2})\s*월\s*(\d{1,2})\s*일/);
if (kor) {
return { date: toYmd(new Date(Number(kor[1]), Number(kor[2]) - 1, Number(kor[3]))), tentative: false };
}
if (/차주|다음\s*주|내주/.test(t)) {
const d = new Date(meetingDate);
d.setDate(d.getDate() + 6);
return { date: toYmd(d), tentative: false };
}
if (/즉시|당일|금일|바로|오늘/.test(t)) {
return { date: toYmd(today), tentative: false };
}
// 변환 불가 — 등록일 + 영업일 5일, "(미확정)" 꼬리표.
return { date: toYmd(addBusinessDays(today, 5)), tentative: true };
}
/** 회의록 본문의 "## 5. 액션 아이템" 마크다운 표에서 행을 파싱. */
export function parseActionItems(report: string): { owner: string; work: string; due: string }[] {
const rows: { owner: string; work: string; due: string }[] = [];
let inSection = false;
for (const line of report.split('\n')) {
if (/^#{1,6}\s*5\.\s*액션\s*아이템/.test(line)) { inSection = true; continue; }
if (!inSection) continue;
if (/^#{1,6}\s/.test(line)) break; // 다음 섹션 시작 → 종료
if (!/^\s*\|/.test(line)) continue;
const cells = line.split('|').slice(1, -1).map((c) => c.trim());
if (cells.length < 3) continue;
if (/^:?-+:?$/.test(cells[0])) continue; // 표 구분선
if (cells[0] === '담당' || cells[1] === '작업 내용') continue; // 헤더
rows.push({ owner: cells[0], work: cells[1], due: cells[2] });
}
return rows;
}
File diff suppressed because it is too large Load Diff
+35 -4
View File
@@ -1,6 +1,15 @@
import { ProjectProfile } from './types';
export function buildProjectChronicleGuardContext(project: ProjectProfile | null): string {
export interface BuildGuardOptions {
/** true 면 4-section 보일러플레이트 (요청 요약 / / / )
* visible-heading **. follow-up / / turn . */
suppressTemplate?: boolean;
}
export function buildProjectChronicleGuardContext(
project: ProjectProfile | null,
options: BuildGuardOptions = {},
): string {
const hasUsableProject = !!project?.recordRoot?.trim();
const projectLines = project ? [
`Project selection status: selected`,
@@ -14,9 +23,23 @@ export function buildProjectChronicleGuardContext(project: ProjectProfile | null
'No active record project is selected. Before writing records, ask the user to select or create one.'
];
return [
...projectLines,
const templateLines = options.suppressTemplate ? [
// 짧은 follow-up / 정정 / 확인 turn — 보일러플레이트 헤더 강제 안 함.
'This turn is a short follow-up / correction / acknowledgement to the previous answer.',
'',
'Do NOT emit `## 요청 요약`, `## 사용자 의도 추론`, `## 프로젝트 기록 대상 확인`, `## 핵심 확인 질문`, or `## 간단 요약` headings — they belong on first-turn idea/feature requests, not on follow-ups.',
'',
'CRITICAL — DO NOT MINIMIZE TO ONE ECHO LINE EITHER.',
'사용자가 추가한 정보는 *직전 결론의 의미를 바꾼다* — 그것을 어떻게 바꾸는지 명시적으로 풀어야 한다. 사용자의 말을 그대로 한 문장으로 다시 말하는 것 (echo/parrot) 은 가장 나쁜 응답이다.',
'',
'Required structure (3-5 plain sentences, no `##` headings, no bullet lists):',
' Sentence 1: 새 정보가 직전 결론의 어떤 부분을 약화/강화/뒤집는지.',
' Sentence 2: 그 결과 결론을 어떻게 수정/유지하는지 ("결론 수정: …" 또는 "결론 유지 — 왜냐하면 …").',
' Sentence 3-5: 그 수정/유지의 *근거* 또는 다음에 확인할 구체적 한 가지.',
'',
'Response length sanity check: 응답이 사용자 메시지보다 짧으면 거의 확실히 잘못된 응답이다.',
'',
] : [
'This guard is active for project ideas, feature requests, architecture proposals, implementation planning, and design decisions.',
'',
'Required response order for new ideas or feature requests:',
@@ -34,6 +57,12 @@ export function buildProjectChronicleGuardContext(project: ProjectProfile | null
'12. Put Vector DB, relational DB, knowledge graph, semantic search, and complex automation only under "Later expansion" unless the user explicitly asks for them now.',
'13. End with "Candidate records for this discussion" and list planning, discussions, decisions, development, bugs, or retrospectives paths as candidates.',
'',
];
return [
...projectLines,
'',
...templateLines,
'Decision policy:',
'- Do not mark a decision as accepted until the user confirms it.',
'- Before confirmation, call decisions "candidates" or "pending".',
@@ -62,7 +91,9 @@ export function buildProjectChronicleGuardContext(project: ProjectProfile | null
'- If the user is using the tool to organize their thinking, reflect the shape of their uncertainty and turn it into 1-2 concrete choices.',
'- Keep the top conclusion calm and short so the user can understand the answer before reading the long version.',
'- Prefer short paragraphs with blank lines between numbered sections. Avoid starting most lines with `*` or `-` bullets.',
'- Use visible markdown headings such as `## 간단 요약`, `## 요청 요약`, `## 상세 답변`, `## 사용자 의도 추론`, and `## 핵심 확인 질문` for major sections.',
...(options.suppressTemplate
? ['- 헤더 (`##`) 사용 금지 — 이 turn 은 짧은 follow-up 이라 자연 문장으로만 답해라.']
: ['- Use visible markdown headings such as `## 간단 요약`, `## 요청 요약`, `## 상세 답변`, `## 사용자 의도 추론`, and `## 핵심 확인 질문` for major sections.']),
'- Avoid grand phrases like advanced cognitive architecture, compounding knowledge, perfect graph, or ultimate knowledge distiller.',
'- When the user wants low dependency, keep the first proposal to Markdown, JSON, local files, and explicit user save actions.',
'- Do not jump directly to large architectures. Narrow direction before expanding.',
@@ -85,6 +85,7 @@ interface SettingsState {
chatTemperature: number;
chunkedSwitchTokens: number;
chunkedMaxSections: number;
polishPersonaOverride: string;
};
datacollect: {
bridgeUrl: string;
@@ -593,6 +594,10 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
if (typeof msg.chunkedMaxSections === 'number' && Number.isFinite(msg.chunkedMaxSections)) {
await this._safeConfigUpdate('chunkedMaxSections', Math.max(1, Math.min(10, Math.floor(msg.chunkedMaxSections))));
}
if (typeof msg.polishPersonaOverride === 'string') {
// 빈 문자열도 유효한 값 (default persona 로 되돌리기). trim 으로 공백만 입력 무력화.
await this._safeConfigUpdate('polishPersonaOverride', msg.polishPersonaOverride.trim());
}
}
// ────────────── Datacollect (slash 명령) ──────────────
@@ -667,6 +672,7 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
chatTemperature: cfg.get<number>('chatTemperature', 0.3) ?? 0.3,
chunkedSwitchTokens: cfg.get<number>('chunkedSwitchTokens', 50000) ?? 50000,
chunkedMaxSections: cfg.get<number>('chunkedMaxSections', 3) ?? 3,
polishPersonaOverride: cfg.get<string>('polishPersonaOverride', '') ?? '',
},
datacollect: {
bridgeUrl: cfg.get<string>('datacollectBridgeUrl', '') || '',
+237
View File
@@ -0,0 +1,237 @@
import * as vscode from 'vscode';
import { AIService } from '../../core/services';
import { logError, logInfo } from '../../utils';
import { TELEGRAM_TOKEN_SECRET_KEY } from '../../extension/telegramCommands';
import { TelegramHttpClient } from '../../integrations/telegram/telegramClient';
import type { DiscoveredCandidate } from './stockDiscovery';
/**
* Discover LLM * Top 5* + / .
*
* (slashStocks cmdDiscover chain):
* 1) buildAnalysisPrompt(candidates) 20 LLM
* 2) AIService.chat(...) Astra
* 3) parseTopFive(output) LLM 5 ()
* 4) renderForChat / renderForTelegram
* 5) sendToTelegram(...) chatId ( silent)
*
* (gemma 4B ) parseTopFive **
* 5 , raw text fallback.
*/
export interface TopFiveItem {
rank: number;
name: string;
symbol: string;
/** LLM 이 한 줄로 정리한 매력 포인트. */
pitch: string;
/** 원본 후보 (선택적으로 ROE/영업이익률 등 인용용). */
candidate?: DiscoveredCandidate;
}
export interface TopFiveResult {
items: TopFiveItem[];
/** LLM 의 종합 코멘트 (1-2 문장). 없을 수 있음. */
summary?: string;
/** LLM 응답 원문 (디버그 / fallback 출력용). */
raw: string;
}
const SYSTEM_PROMPT = [
'당신은 한국 주식 발굴 결과를 검토하는 가치 투자 분석가다.',
'제공된 후보 종목들의 *재무 지표* 와 *통과 키워드* 를 보고 가장 매력적인 5개를 골라라.',
'',
'**평가 기준 (우선순위 순):**',
' 1. 통과 키워드 수 — 많을수록 우수 (3개 통과 < 5개 통과 < 6개 통과)',
' 2. ROE 절대 수치 — 15% 이상 강하게 가산',
' 3. 영업이익률 — 20% 이상 가산 (가격 결정력 + 마진 안정)',
' 4. 유보율 — 1,000% 이상 통과, 3,000% 이상 안정성 가산',
' 5. PBR — 낮을수록 가산, 단 1.0 미만은 *value trap* 가능성 cross-check',
'',
'**출력 형식 (반드시 이대로):**',
'🎯 매력도 Top 5',
'',
'1. <종목명> (<6자리 심볼>) — <한 줄 매력 포인트 30자 이내>',
' 근거: ROE x%, 영업이익률 y%, 유보율 z%, <통과 N키워드 인용>',
'2. ...',
'...',
'5. ...',
'',
'종합: <1-2 문장 — 이번 발굴 batch 의 공통 특징 또는 주의점>',
'',
'*다른 텍스트 절대 추가 금지.* 출력 첫 줄은 정확히 "🎯 매력도 Top 5" 이어야 한다.',
].join('\n');
function buildAnalysisPrompt(candidates: DiscoveredCandidate[]): string {
const lines: string[] = [
`발굴 후보 ${candidates.length}개. 매력도 Top 5 골라라.`,
'',
];
for (const c of candidates) {
const f = c.fundamentals;
lines.push(
`· ${c.name} (${c.symbol}) [${c.market} · ${c.marketCapEok.toLocaleString()}억]`,
` ROE ${f.roe?.toFixed(1) ?? '-'}% · 영업이익률 ${f.operatingMargin?.toFixed(1) ?? '-'}% · 유보율 ${f.retentionRatio?.toLocaleString() ?? '-'}% · 부채비율 ${f.debtRatio?.toFixed(1) ?? '-'}% · PER ${f.per?.toFixed(1) ?? '-'} · PBR ${f.pbr?.toFixed(1) ?? '-'}`,
` 통과 (${c.passedKeywords.length}): ${c.passedKeywords.join(', ')}`,
'',
);
}
return lines.join('\n');
}
/**
* LLM 5 . :
* - "1. <이름> (<6자리>) — <문구>" / "1) <이름> ..." / "1: ..."
* - 6 cross-check
*/
function parseTopFive(raw: string, candidates: DiscoveredCandidate[]): TopFiveResult {
const items: TopFiveItem[] = [];
const lines = raw.split('\n');
const symbolMap = new Map(candidates.map(c => [c.symbol, c]));
const itemRe = /^\s*([1-5])[\.\)\:]\s+(.+?)\s*[\(\[](\d{6})[\)\]]\s*[—\-:]\s*(.+)$/;
for (const line of lines) {
const m = line.match(itemRe);
if (!m) continue;
const rank = parseInt(m[1], 10);
if (items.find(i => i.rank === rank)) continue;
items.push({
rank,
name: m[2].trim(),
symbol: m[3],
pitch: m[4].trim(),
candidate: symbolMap.get(m[3]),
});
}
items.sort((a, b) => a.rank - b.rank);
// 종합 코멘트 추출 — "종합:" 또는 "총평:" 라인.
const summaryMatch = raw.match(/(?:|)\s*[:]\s*(.+?)(?:\n\n|$)/s);
const summary = summaryMatch ? summaryMatch[1].replace(/\s+/g, ' ').trim() : undefined;
return { items, summary, raw };
}
export async function analyzeTopCandidates(
candidates: DiscoveredCandidate[],
onProgress?: (msg: string) => void,
): Promise<TopFiveResult> {
if (candidates.length === 0) {
return { items: [], raw: '' };
}
onProgress?.('🤖 LLM 분석 시작 — 후보 ' + candidates.length + '개 평가 중...');
const ai = new AIService();
try {
const result = await ai.chat({
system: SYSTEM_PROMPT,
user: buildAnalysisPrompt(candidates),
timeoutMs: 90_000,
});
if (result.empty || !result.content.trim()) {
logError('Discovery analyzer: LLM 빈 응답.');
return { items: [], raw: '' };
}
const parsed = parseTopFive(result.content, candidates);
logInfo('Discovery analyzer: parsed Top 5.', {
parsedCount: parsed.items.length,
model: result.model,
});
return parsed;
} catch (e: any) {
logError('Discovery analyzer: LLM 호출 실패.', { error: e?.message ?? String(e) });
return { items: [], raw: '' };
}
}
/** 채팅 webview 용 — markdown 친화적 멀티라인. */
export function renderTopFiveForChat(result: TopFiveResult): string {
if (result.items.length === 0) {
return result.raw
? `\n🤖 **LLM 분석 결과** (형식 파싱 실패 — 원문 표시)\n\n${result.raw}\n`
: '\n⚠️ LLM 분석 실패 (빈 응답 또는 timeout).\n';
}
const lines: string[] = ['\n🎯 **Astra 매력도 Top 5**\n'];
for (const it of result.items) {
lines.push(`${it.rank}. **${it.name}** (${it.symbol}) — ${it.pitch}`);
if (it.candidate) {
const f = it.candidate.fundamentals;
lines.push(` 근거: ROE ${f.roe?.toFixed(1) ?? '-'}%, 영업이익률 ${f.operatingMargin?.toFixed(1) ?? '-'}%, 유보율 ${f.retentionRatio?.toLocaleString() ?? '-'}%, 통과 ${it.candidate.passedKeywords.length}개 (${it.candidate.passedKeywords.join(', ')})`);
}
}
if (result.summary) {
lines.push('', `💬 ${result.summary}`);
}
return lines.join('\n') + '\n';
}
/** 텔레그램용 — Markdown V1, 짧고 깔끔. 4096자 제한 안에서. */
function pickChatIdForReport(): number | null {
const cfg = vscode.workspace.getConfiguration('g1nation');
const allowed = cfg.get<number[]>('telegram.allowedChatIds', []) || [];
if (allowed.length > 0 && Number.isFinite(allowed[0]) && allowed[0] !== 0) {
return allowed[0];
}
const fallback = cfg.get<number>('stocks.telegramChatId', 0);
return fallback && Number.isFinite(fallback) ? fallback : null;
}
function formatKstNow(): string {
const fmt = new Intl.DateTimeFormat('ko-KR', {
timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false,
});
const parts = fmt.formatToParts(new Date());
const get = (t: string) => parts.find(p => p.type === t)?.value || '';
return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')} KST`;
}
export function renderTopFiveForTelegram(
result: TopFiveResult,
rangeLabel: string,
): string {
if (result.items.length === 0) {
return `🎯 *Astra 발굴 Top 5* (${rangeLabel})\n${formatKstNow()}\n\n⚠️ LLM 분석 실패 — 채팅 창에서 raw 결과 확인.`;
}
const lines: string[] = [
`🎯 *Astra 발굴 Top 5* (${rangeLabel})`,
formatKstNow(),
'',
];
for (const it of result.items) {
lines.push(`${it.rank}\\. *${it.name}* (\`${it.symbol}\`)`);
lines.push(` ${it.pitch}`);
if (it.candidate) {
const f = it.candidate.fundamentals;
lines.push(` ROE ${f.roe?.toFixed(1) ?? '-'}% · OM ${f.operatingMargin?.toFixed(1) ?? '-'}% · 유보 ${f.retentionRatio?.toLocaleString() ?? '-'}%`);
}
lines.push('');
}
if (result.summary) {
lines.push(`💬 ${result.summary}`);
}
const joined = lines.join('\n');
return joined.length > 3800 ? joined.slice(0, 3800) + '\n…(잘림)' : joined;
}
export async function sendTopFiveToTelegram(
context: vscode.ExtensionContext,
text: string,
): Promise<{ ok: boolean; reason?: string }> {
const chatId = pickChatIdForReport();
if (chatId === null) {
return { ok: false, reason: '텔레그램 chatId 미설정 (allowedChatIds 또는 stocks.telegramChatId)' };
}
const token = (await context.secrets.get(TELEGRAM_TOKEN_SECRET_KEY)) || '';
if (!token.trim()) {
return { ok: false, reason: '텔레그램 봇 토큰 없음' };
}
const client = new TelegramHttpClient({ getToken: () => token });
try {
await client.sendMessage({ chatId, text, parseMode: 'Markdown' });
logInfo('Top 5 텔레그램 발송 완료.', { chatId, chars: text.length });
return { ok: true };
} catch (e: any) {
logError('Top 5 텔레그램 발송 실패.', { chatId, error: e?.message ?? String(e) });
return { ok: false, reason: e?.message ?? String(e) };
}
}
+2
View File
@@ -0,0 +1,2 @@
export { startStocksWatcher, runOnceNow } from './stocksWatcher';
export { handleStocksCommand } from './slashStocks';
+127
View File
@@ -0,0 +1,127 @@
import { AIService } from '../../core/services';
import { logError, logInfo } from '../../utils';
import { readStocksStore, updateStock } from './stocksStore';
import type { Stock } from './types';
/**
* `/stocks judge <심볼>` LLM
* "3/4 필터" 4-criteria (ROE / / / )
* stocks.json `"3/4 필터"` .
*
* LLM trade-off **
* "자동 평가" prefix .
*
* (LLM ):
* : "충족 (ROE, 성장성, 유동성)" "미충족"
* ~ : () webview
*
* `.includes("충족")` unchanged signalClassifier .
*/
export interface JudgeResult {
ok: boolean;
/** stocks.json 에 저장된 새 "3/4 필터" 텍스트. */
filterText?: string;
/** LLM 이 제시한 평가 근거 (사용자에게 보여줌). */
rationale?: string;
error?: string;
}
const SYSTEM_PROMPT = [
'당신은 한국 주식 가치 평가 보조 도구다. 사용자가 제공한 종목의 *재무 지표만* 보고',
'필터 평가를 내린다. 외부 정보 추측 금지 — 주어진 숫자에서만 판단.',
'',
'**사용 가능한 8개 평가 키워드** (사용자의 실제 분류 패턴):',
' - ROE: ROE(25E) ≥ 10% 이면 통과. 15% 이상은 우수 (통과 강하게).',
' - 성장성: 영업이익률(25E) ≥ 15% 또는 상장일이 3년 이내 신규성장주.',
' - 유동성: 유보율 ≥ 1,000% 이면 통과 (자기자본 충분).',
' - 수익성: 영업이익률(25E) ≥ 10% 이면 통과. 20% 이상이면 "수익성 개선" 표기 가능.',
' - 영업효율: 영업이익률(25E) ≥ 15% + ROE ≥ 8% 동시 만족 (효율성 시그널).',
' - 기술력: "최대 먹거리" 가 AI / 반도체 / 배터리 / 바이오 / 기술 영역이고 PBR ≥ 2 (시장이 기술 premium 인정).',
' - 안정성: 유보율 ≥ 3,000% + 시가총액 ≥ 5,000억 (대형주 안전).',
' - PBR: PBR ≤ 1.5 이면 통과 (저평가 시그널, 저평가우량주에서 주로 사용).',
'',
'**투자성향별 우선 적용 키워드:**',
' - 스윙/중기: ROE / 성장성 / 유동성 / 수익성 위주.',
' - 장기투자: 성장성 / 유동성 / 기술력 / 영업효율 위주.',
' - 저평가우량주: PBR / ROE 필수 + (성장성 또는 수익성 또는 안정성) 중 1개.',
'',
'**판정 규칙:**',
' - 위 8개 키워드 중 *3개 이상* 통과 → "충족 (통과한 항목들 콤마 구분)" 형식.',
' 예: "충족 (ROE, 성장성, 유동성)" / "충족 (PBR, ROE, 안정성)" / "충족 (성장성, 유동성, 수익성 개선)"',
' - 3개 미만 → "미충족 (사유: 핵심 약점 한 줄)"',
'',
'**키워드 선택 가이드:**',
' - 한 종목에서 통과한 키워드 *모두* 나열하지 말고 *그 종목 성격을 가장 잘 보여주는 3개* 만 선택.',
' - 투자성향별 우선 키워드를 먼저 포함시키고, 빠진 부분은 다른 키워드로 메움.',
'',
'**실제 패턴 예시** (학습용 — 사용자가 이미 분류한 종목들):',
' - 마녀공장 (ROE 15.6%, 영업이익률 18.0%, 유보율 5,800%, 스윙/중기) → "충족 (ROE, 성장성, 유동성)"',
' - 기가비스 (ROE 7.23%, 영업이익률 25.7%, 유보율 4,250%, 신규 상장 2년차, 스윙/중기) → "충족 (성장성, 유동성, 수익성 개선)" (ROE 가 10% 미만이라 빠짐, 대신 수익성 강함)',
' - 엔켐 (ROE 12.4%, 영업이익률 8.5%, 유보율 1,250%, 스윙/중기) → "충족 (성장성, 유동성)" (영업이익률이 10% 미만이라 수익성 빠짐)',
'',
'**출력 형식 (반드시 이대로):**',
' 1번째 줄: 위 형식의 판정 문구만 (다른 텍스트 절대 금지)',
' 2번째 줄: 빈 줄',
' 3번째 줄 이후: 평가 근거 2-3 문장 — 어떤 지표가 어떤 threshold 를 통과/미통과했는지 *구체 수치 인용*.',
].join('\n');
function buildUserPrompt(s: Stock): string {
const lines = [
`종목: ${s.} (${s.})`,
`상장일: ${s. ?? '미상'}`,
`투자성향: ${s. ?? '미분류'}`,
`유보율: ${s. ?? '-'}`,
`ROE(25E): ${s['ROE(25E)'] ?? '-'}`,
`영업이익률(25E): ${s['영업이익률(25E)'] ?? '-'}`,
`EPS(25E): ${s['EPS(25E)'] ?? '-'}`,
`PER(25E): ${s['PER(25E)'] ?? '-'}`,
`PBR: ${s.PBR ?? '-'}`,
`시가총액: ${s. ?? '-'}`,
`최대 먹거리: ${s['최대 먹거리'] ?? '-'}`,
`특이사항: ${s. ?? '-'}`,
'',
'위 데이터로 4-criteria 필터 판정.',
];
return lines.join('\n');
}
export async function judgeStock(symbol: string): Promise<JudgeResult> {
const store = readStocksStore();
const stock = store.find(s => s. === symbol);
if (!stock) {
return { ok: false, error: `심볼 ${symbol} 을 stocks.json 에서 못 찾음` };
}
const ai = new AIService();
try {
const result = await ai.chat({
system: SYSTEM_PROMPT,
user: buildUserPrompt(stock),
});
if (result.empty || !result.content.trim()) {
return { ok: false, error: 'LLM 이 빈 응답 반환' };
}
const lines = result.content.split('\n');
const firstLine = (lines[0] || '').trim();
const rationale = lines.slice(2).join('\n').trim() || undefined;
if (!firstLine) return { ok: false, error: 'LLM 응답 형식 오류 (첫 줄 비어있음)' };
// 텍스트 형식 검증 — "충족" 또는 "미충족" 으로 시작해야 signalClassifier 가 인식.
if (!/^(충족|미충족)/.test(firstLine)) {
return { ok: false, error: `LLM 응답 형식 오류 (충족/미충족 prefix 없음): ${firstLine.slice(0, 80)}` };
}
// "자동 평가" prefix 박아서 수동/자동 구분 가능하게.
const filterText = `[자동 평가] ${firstLine}`;
const wrote = updateStock(symbol, { '3/4 필터': filterText });
if (!wrote) return { ok: false, error: 'stocks.json 쓰기 실패' };
logInfo('Stocks LLM judge 완료.', { symbol, filterText, model: result.model });
return { ok: true, filterText, rationale };
} catch (e: any) {
logError('Stocks LLM judge 실패.', { symbol, error: e?.message ?? String(e) });
return { ok: false, error: e?.message ?? String(e) };
}
}
+161
View File
@@ -0,0 +1,161 @@
import { logError, logInfo } from '../../utils';
/**
* Naver Finance JSON API fetch.
*
* endpoint :
* - `/api/stock/<code>/integration` / PER / PBR / EPS /
* - `/api/stock/<code>/finance/annual` ROE / / /
* (rowList row.title , )
*
* JSON selector , label (rowList[i].title === 'ROE').
* / API Naver Finance .
*/
const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const INTEGRATION_URL = 'https://m.stock.naver.com/api/stock';
export interface Fundamentals {
symbol: string;
/** 연간 재무제표 (최근 *확정* 연도). */
roe?: number; // %
operatingMargin?: number; // % (영업이익률)
retentionRatio?: number; // % (유보율)
debtRatio?: number; // % (부채비율)
/** integration API 의 현재가 + 평가지표. */
per?: number;
pbr?: number;
eps?: number;
marketCapEok?: number; // 억 단위
currentPrice?: number;
/** 업종 hint — 사용 가능하면 채움 ("기술력" 키워드 매칭 용). */
sectorHint?: string;
}
interface NaverIntegrationResponse {
stockName?: string;
totalInfos?: Array<{ code: string; key: string; value: string }>;
/** 종목 분류 / 업종 정보가 들어있을 수 있음 (옵션). */
industryInfo?: { name?: string };
}
interface NaverFinanceAnnualResponse {
financeInfo?: {
trTitleList?: Array<{ isConsensus: 'Y' | 'N'; title: string; key: string }>;
rowList?: Array<{ title: string; columns: Record<string, { value: string }> }>;
};
}
/** "12,090" / "23.64배" / "5,800%" / "1,710조 365억" / "-" 텍스트에서 숫자 추출. */
function parseNumber(raw: string | undefined): number | undefined {
if (!raw) return undefined;
const cleaned = raw.replace(/,/g, '').replace(/배|%|원|억|조/g, '').trim();
if (!cleaned || cleaned === '-' || cleaned === 'N/A') return undefined;
const n = parseFloat(cleaned);
return Number.isFinite(n) ? n : undefined;
}
/** "1,710조 365억" / "2,787억" / "5조" → 억 단위 정수. */
function parseMarketCapText(text: string | undefined): number | undefined {
if (!text) return undefined;
const cleaned = text.replace(/원|\s/g, '');
const joMatch = cleaned.match(/([\d,]+)조/);
const eokMatch = cleaned.match(/(?:조)?([\d,]+)억/) || cleaned.match(/([\d,]+)$/);
const jo = joMatch ? parseInt(joMatch[1].replace(/,/g, ''), 10) : 0;
const eok = eokMatch ? parseInt(eokMatch[1].replace(/,/g, ''), 10) : 0;
const total = jo * 10000 + eok;
return total > 0 ? total : undefined;
}
/** trTitleList 에서 *최근 확정* (isConsensus = 'N') 컬럼 키 선택. */
function pickLatestConfirmedKey(titles?: Array<{ isConsensus: 'Y' | 'N'; key: string }>): string | null {
if (!titles || titles.length === 0) return null;
// 'N' 만 필터 → key 내림차순 → 첫 번째.
const confirmed = titles.filter(t => t.isConsensus === 'N').map(t => t.key).sort().reverse();
return confirmed[0] ?? null;
}
async function fetchIntegration(symbol: string, timeoutMs: number): Promise<NaverIntegrationResponse | null> {
try {
const res = await fetch(`${INTEGRATION_URL}/${symbol}/integration`, {
headers: { 'User-Agent': USER_AGENT, 'Accept': 'application/json' },
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) return null;
return await res.json() as NaverIntegrationResponse;
} catch (e: any) {
logError('Naver integration fetch 실패.', { symbol, error: e?.message ?? String(e) });
return null;
}
}
async function fetchFinanceAnnual(symbol: string, timeoutMs: number): Promise<NaverFinanceAnnualResponse | null> {
try {
const res = await fetch(`${INTEGRATION_URL}/${symbol}/finance/annual`, {
headers: { 'User-Agent': USER_AGENT, 'Accept': 'application/json' },
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) return null;
return await res.json() as NaverFinanceAnnualResponse;
} catch (e: any) {
logError('Naver finance annual fetch 실패.', { symbol, error: e?.message ?? String(e) });
return null;
}
}
export async function fetchFundamentals(symbol: string, timeoutMs = 10000): Promise<Fundamentals | null> {
const [integ, fin] = await Promise.all([
fetchIntegration(symbol, timeoutMs),
fetchFinanceAnnual(symbol, timeoutMs),
]);
if (!integ && !fin) return null;
const out: Fundamentals = { symbol };
// integration — totalInfos 의 code 로 추출 (key 한글 텍스트보다 안정적).
if (integ?.totalInfos) {
const map = new Map(integ.totalInfos.map(i => [i.code, i.value]));
out.per = parseNumber(map.get('per'));
out.pbr = parseNumber(map.get('pbr'));
out.eps = parseNumber(map.get('eps'));
out.currentPrice = parseNumber(map.get('closePrice') || map.get('lastClosePrice'));
out.marketCapEok = parseMarketCapText(map.get('marketValue'));
}
if (integ?.industryInfo?.name) out.sectorHint = integ.industryInfo.name;
// finance/annual — rowList 에서 최근 확정 연도 컬럼 값.
if (fin?.financeInfo?.rowList && fin.financeInfo.rowList.length > 0) {
const latestKey = pickLatestConfirmedKey(fin.financeInfo.trTitleList);
if (latestKey) {
const rowByTitle = new Map(fin.financeInfo.rowList.map(r => [r.title, r]));
const valueOf = (title: string): number | undefined => {
const row = rowByTitle.get(title);
if (!row) return undefined;
return parseNumber(row.columns[latestKey]?.value);
};
out.roe = valueOf('ROE');
out.operatingMargin = valueOf('영업이익률');
out.retentionRatio = valueOf('유보율');
out.debtRatio = valueOf('부채비율');
}
}
return out;
}
/** 일괄 fetch — throttle 300ms. JSON API 가 가벼우니 HTML 크롤 500ms 보다 빠르게. */
export async function fetchAllFundamentals(
symbols: string[],
onProgress?: (symbol: string, fund: Fundamentals | null, i: number, total: number) => void,
): Promise<Map<string, Fundamentals>> {
const out = new Map<string, Fundamentals>();
let i = 0;
for (const symbol of symbols) {
i++;
const fund = await fetchFundamentals(symbol);
if (fund) out.set(symbol, fund);
onProgress?.(symbol, fund, i, symbols.length);
await new Promise(r => setTimeout(r, 300));
}
logInfo(`Naver fundamentals 일괄 fetch: ${out.size}/${symbols.length} 성공.`);
return out;
}
+151
View File
@@ -0,0 +1,151 @@
import { logError, logInfo } from '../../utils';
/**
* Naver Finance * JSON API* fetch.
*
* - : `https://m.stock.naver.com/api/stocks/marketValue/KOSPI?page=N&pageSize=50`
* - : `https://m.stock.naver.com/api/stocks/marketValue/KOSDAQ?page=N&pageSize=50`
*
* :
* { stocks: [ { itemCode, stockName, marketValue, marketValueHangeul, closePrice, ... }, ... ] }
*
* `marketValueHangeul` "2,787억" / "1,710조 365억" .
* * * ( ) .
*
* Why JSON over HTML:
* - ( )
* - EUC-KR (JSON UTF-8)
* - cheerio
* - (HTML X, JSON )
*
* Caveat: ** Naver . ToS X. .
*/
const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const BASE_URL = 'https://m.stock.naver.com/api/stocks/marketValue';
export type Market = 'kospi' | 'kosdaq';
export interface ScreenerEntry {
/** 6자리 종목코드. */
symbol: string;
/** 종목명. */
name: string;
/** 시가총액 (억원 단위 정수). marketValueHangeul 텍스트에서 파싱. */
marketCapEok: number;
/** 종가 (옵션). */
closePrice?: number;
market: Market;
}
/**
* "1,710조 365억" / "2,787억" / "17조" * * .
* - : ×10,000
* - : ×1
*/
function parseMarketCapHangeul(text: string | undefined): number {
if (!text) return 0;
const cleaned = text.replace(/원|\s/g, '');
const joMatch = cleaned.match(/([\d,]+)조/);
const eokMatch = cleaned.match(/(?:조)?([\d,]+)억/) || cleaned.match(/([\d,]+)$/);
const jo = joMatch ? parseInt(joMatch[1].replace(/,/g, ''), 10) : 0;
const eok = eokMatch ? parseInt(eokMatch[1].replace(/,/g, ''), 10) : 0;
// "1,710조 365억" → jo=1710, eok=365 → 17,103,650 안 됨. 1,710조 365 = 17,103,650 억? 아니다.
// 1,710조 = 17,100,000 억. + 365억 = 17,100,365 억.
return jo * 10000 + eok;
}
interface NaverStockListItem {
itemCode: string;
stockName: string;
marketValue?: string; // 백만원 단위 (단위 모호, 사용 안 함)
marketValueHangeul?: string; // "2,787억원" — 사용
closePrice?: string; // "12,090"
}
interface NaverMarketValueResponse {
stocks: NaverStockListItem[];
totalCount?: number;
pageSize?: number;
page?: number;
}
async function fetchPage(market: Market, page: number): Promise<NaverStockListItem[]> {
const category = market === 'kospi' ? 'KOSPI' : 'KOSDAQ';
const url = `${BASE_URL}/${category}?page=${page}&pageSize=50`;
const res = await fetch(url, {
headers: { 'User-Agent': USER_AGENT, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000),
});
if (!res.ok) throw new Error(`Naver screener HTTP ${res.status} (${market} p${page})`);
const data = await res.json() as NaverMarketValueResponse;
if (!Array.isArray(data.stocks)) {
throw new Error(`Naver screener: stocks 필드 누락 (${market} p${page})`);
}
return data.stocks;
}
function toEntry(item: NaverStockListItem, market: Market): ScreenerEntry {
const marketCapEok = parseMarketCapHangeul(item.marketValueHangeul);
const closePriceNum = item.closePrice
? parseFloat(item.closePrice.replace(/,/g, ''))
: undefined;
return {
symbol: item.itemCode,
name: item.stockName,
marketCapEok,
closePrice: Number.isFinite(closePriceNum as number) ? closePriceNum : undefined,
market,
};
}
/**
* ( maxPages) fetch. () 1 .
* - throttle: 300ms (HTML 500ms JSON ).
* - Naver , * maxCap * .
*/
export async function screenMarket(opts: {
market: Market;
maxPages?: number;
minMarketCapEok?: number;
maxMarketCapEok?: number;
onProgress?: (page: number, totalSoFar: number) => void;
}): Promise<ScreenerEntry[]> {
const maxPages = opts.maxPages ?? 20;
const minCap = opts.minMarketCapEok ?? 0;
const maxCap = opts.maxMarketCapEok ?? Number.MAX_SAFE_INTEGER;
const collected: ScreenerEntry[] = [];
for (let page = 1; page <= maxPages; page++) {
try {
const items = await fetchPage(opts.market, page);
if (items.length === 0) {
logInfo(`Naver screener p${page} ${opts.market}: 0 rows — 종료.`);
break;
}
const entries = items.map(it => toEntry(it, opts.market));
// 시총 큰 순 → 모든 종목 시총이 maxCap 보다 작아진 페이지부터는 maxCap 위 종목 없음 (정렬 보장).
// minCap 보다 작아진 페이지는 그 뒤 모든 종목이 더 작음 → early exit.
let pageBelowMin = true;
for (const e of entries) {
if (e.marketCapEok > maxCap) continue;
if (e.marketCapEok < minCap) continue;
collected.push(e);
pageBelowMin = false;
}
// 이 페이지의 *모든* 종목이 minCap 미만이면 더 작은 종목들만 남음 → early exit.
if (entries.every(e => e.marketCapEok < minCap)) {
logInfo(`Naver screener ${opts.market}: p${page} 전부 minCap(${minCap}억) 미만 — early exit.`);
opts.onProgress?.(page, collected.length);
break;
}
opts.onProgress?.(page, collected.length);
await new Promise(r => setTimeout(r, 300));
} catch (e: any) {
logError(`Naver screener p${page} ${opts.market} 실패.`, { error: e?.message ?? String(e) });
// partial 결과라도 반환 — 한 페이지 실패해도 continue.
}
}
logInfo(`Naver screener ${opts.market}: ${collected.length}개 (${minCap}억 ~ ${maxCap}억).`);
return collected;
}
+99
View File
@@ -0,0 +1,99 @@
import * as vscode from 'vscode';
import { writeSheetRange, type SheetValues } from '../sheets';
import { logError, logInfo } from '../../utils';
import { classifyAll } from './signalClassifier';
import { readStocksStore } from './stocksStore';
import type { ClassifiedStock } from './types';
/**
* Google Sheets .
*
* invest_results/gs_update_api.js :
* - Sheet1!A1:O = /
* - Sheet2!A1:O =
* - Sheet3!A1:O =
* ( spreadsheet 1/2/3 spreadsheet .)
*
* spreadsheet ID `g1nation.stocks.spreadsheetId` . skip .
* OAuth token calendar (oauth.ts SCOPE spreadsheets ).
*/
const HEADER = ['종목명', '심볼', '상장일', '현재가', '적정주가', '매수권장가', '3/4 필터', '매수 신호', 'ROE', 'PBR', '영업이익률', '유보율', 'PER', 'EPS', '특이사항'];
function buildSheetRows(classified: ClassifiedStock[]): SheetValues {
const rows: SheetValues = [HEADER];
for (const s of classified) {
rows.push([
s.,
s.,
s. ?? '',
s. ?? 0,
s. ?? '',
s. ?? '',
s.filterPass ? 'Pass' : 'Fail',
s.signalText,
s['ROE(25E)'] ?? '',
s.PBR ?? '',
s['영업이익률(25E)'] ?? '',
s. ?? '',
s['PER(25E)'] ?? '',
s['EPS(25E)'] ?? '',
s. ?? '',
]);
}
return rows;
}
/**
* 3 . / caller webview .
*
* Sheet 1/2/3 ** range .
* spreadsheet :
* 'Sheet1' / 'Sheet2' / 'Sheet3' override .
*/
export async function syncToSheets(
context: vscode.ExtensionContext,
): Promise<{ ok: boolean; errors: string[]; updatedRanges: string[] }> {
const cfg = vscode.workspace.getConfiguration('g1nation');
const spreadsheetId = (cfg.get<string>('stocks.spreadsheetId') || '').trim();
if (!spreadsheetId) {
return {
ok: false,
errors: ['Settings 에 g1nation.stocks.spreadsheetId 가 설정되지 않았습니다.'],
updatedRanges: [],
};
}
const sheetSwing = (cfg.get<string>('stocks.sheetSwing') || 'Sheet1').trim();
const sheetLong = (cfg.get<string>('stocks.sheetLong') || 'Sheet2').trim();
const sheetUltra = (cfg.get<string>('stocks.sheetUltraLow') || 'Sheet3').trim();
const store = readStocksStore();
const groups = classifyAll(store);
const errors: string[] = [];
const updatedRanges: string[] = [];
const tasks: Array<{ tab: string; rows: SheetValues; label: string }> = [
{ tab: sheetSwing, rows: buildSheetRows(groups.swing), label: '스윙/중기' },
{ tab: sheetLong, rows: buildSheetRows(groups.long), label: '장기투자' },
{ tab: sheetUltra, rows: buildSheetRows(groups.ultraLow), label: '저평가우량주' },
];
for (const t of tasks) {
if (t.rows.length <= 1) continue;
const range = `${t.tab}!A1:O${t.rows.length}`;
try {
const r = await writeSheetRange(context, spreadsheetId, range, t.rows);
if (r.ok) {
updatedRanges.push(r.updatedRange);
logInfo(`Stocks sheets sync: ${t.label} OK.`, { range: r.updatedRange, cells: r.updatedCells });
} else {
errors.push(`${t.label}: ${r.error}`);
logError(`Stocks sheets sync: ${t.label} 실패.`, { error: r.error });
}
} catch (e: any) {
errors.push(`${t.label}: ${e?.message ?? String(e)}`);
}
}
return { ok: errors.length === 0, errors, updatedRanges };
}
+69
View File
@@ -0,0 +1,69 @@
import type { Stock, ClassifiedStock, Signal } from './types';
/**
* invest_results/gs_update_api.js .
*
* 1. filterPass = "3/4 필터" "충족" ( , )
* 2. isPriceInZone = > 0 && > 0 && <=
* 3. signal:
* - filterPass + priceInZone BUY_ZONE (sortScore 2, "🚨 매수사정권!")
* - filterPass + !priceInZone OVERVALUED (sortScore 1, "⚠️ 고평가! 가격 조정 감시 중")
* - !filterPass HOLD (sortScore 0, "관망")
*
* ( /Sheets ).
*/
const SIGNAL_TEXT: Record<Signal, string> = {
BUY_ZONE: '🚨 매수사정권! (바닥 안착 시 매수 개시)',
OVERVALUED: '⚠️ 고평가! 가격 조정 감시 중',
HOLD: '관망',
};
/** "23,760" 같은 콤마 포함 텍스트 → number. 빈 문자열 / 파싱 실패 시 0. */
function parsePrice(raw: string | number | undefined): number {
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : 0;
if (!raw) return 0;
const cleaned = String(raw).replace(/,/g, '').trim();
const n = parseFloat(cleaned);
return Number.isFinite(n) ? n : 0;
}
export function classifyStock(s: Stock): ClassifiedStock {
const curPrice = parsePrice(s.);
const recPrice = parsePrice(s.);
const isPriceInZone = curPrice > 0 && recPrice > 0 && curPrice <= recPrice;
const filterPass = (s['3/4 필터'] || '').toString().includes('충족');
let signal: Signal = 'HOLD';
let sortScore: 0 | 1 | 2 = 0;
if (filterPass) {
if (isPriceInZone) { signal = 'BUY_ZONE'; sortScore = 2; }
else { signal = 'OVERVALUED'; sortScore = 1; }
}
return {
...s,
signal,
sortScore,
filterPass,
signalText: SIGNAL_TEXT[signal],
};
}
/** 전체 분류 + 투자성향별 정렬. 텔레그램 / 시트 동기화 둘 다 이걸 호출. */
export function classifyAll(stocks: Stock[]): {
swing: ClassifiedStock[];
long: ClassifiedStock[];
ultraLow: ClassifiedStock[];
all: ClassifiedStock[];
} {
const classified = stocks.map(classifyStock);
const filterByProfile = (profile: Stock['투자성향']) =>
classified.filter(s => s. === profile).sort((a, b) => b.sortScore - a.sortScore);
return {
swing: filterByProfile('스윙/중기'),
long: filterByProfile('장기투자'),
ultraLow: filterByProfile('저평가우량주'),
all: classified.slice().sort((a, b) => b.sortScore - a.sortScore),
};
}
+279
View File
@@ -0,0 +1,279 @@
import * as vscode from 'vscode';
import { logInfo } from '../../utils';
import { readStocksStore, addStock, removeStock, getStocksFilePath } from './stocksStore';
import { fetchAllPrices } from './yahooClient';
import { classifyAll } from './signalClassifier';
import { writeStocksStore } from './stocksStore';
import { syncToSheets } from './sheetsSync';
import { judgeStock } from './llmJudge';
import { sendStocksReport, buildReportText } from './telegramReport';
import { runOnceNow } from './stocksWatcher';
import { discoverStocks } from './stockDiscovery';
import { analyzeTopCandidates, renderTopFiveForChat, renderTopFiveForTelegram, sendTopFiveToTelegram } from './discoveryAnalyzer';
import type { ClassifiedStock, Stock } from './types';
/**
* `/stocks <subcommand> [args]` slashRouter handler
* sub-routing.
*
* Subcommands:
* /stocks
* /stocks list +
* /stocks check (Yahoo)
* /stocks signal
* /stocks sync Google Sheets
* /stocks add <> <> []
* /stocks remove <>
* /stocks judge <> LLM 4-criteria
* /stocks discover [min] [max] Naver ( , default 1000-5000)
* /stocks report
* /stocks run watcher (+sync+)
* /stocks path stocks.json
*/
interface Webview { postMessage(msg: any): Thenable<boolean> | boolean; }
function chunk(view: Webview | undefined, value: string) {
view?.postMessage({ type: 'streamChunk', value });
}
function formatPrice(n: number | undefined): string {
if (typeof n !== 'number' || !Number.isFinite(n)) return '-';
return n.toLocaleString();
}
function renderListLine(s: ClassifiedStock): string {
const cur = formatPrice(s.);
const rec = s. ?? '-';
return ` · ${s.} (${s.}): ${cur} / 권장 ${rec}${s.signalText}`;
}
async function cmdList(view: Webview | undefined): Promise<void> {
const store = readStocksStore();
if (store.length === 0) {
chunk(view, `\n종목 없음. \`/stocks add <심볼> <이름>\` 으로 추가하세요.\n경로: ${getStocksFilePath() ?? '(워크스페이스 없음)'}\n`);
return;
}
const g = classifyAll(store);
const lines: string[] = ['\n📋 **종목 목록 (분류별)**\n'];
if (g.swing.length) {
lines.push(`\n**스윙/중기** (${g.swing.length}개)`);
g.swing.forEach(s => lines.push(renderListLine(s)));
}
if (g.long.length) {
lines.push(`\n**장기투자** (${g.long.length}개)`);
g.long.forEach(s => lines.push(renderListLine(s)));
}
if (g.ultraLow.length) {
lines.push(`\n**저평가우량주** (${g.ultraLow.length}개)`);
g.ultraLow.forEach(s => lines.push(renderListLine(s)));
}
chunk(view, lines.join('\n') + '\n');
}
async function cmdCheck(view: Webview | undefined): Promise<void> {
const store = readStocksStore();
if (store.length === 0) { chunk(view, '\n종목 없음.\n'); return; }
chunk(view, `\n🔄 ${store.length}개 종목 현재가 갱신 중 (Yahoo, 1초/종목)...\n`);
const symbols = store.map(s => s.).filter(Boolean);
const prices = await fetchAllPrices(symbols, (sym, p) => {
const name = store.find(s => s. === sym)?. ?? sym;
chunk(view, ` · ${name}: ${p !== null ? p.toLocaleString() + '원' : '조회 실패'}\n`);
});
for (const s of store) {
const p = prices.get(s.);
if (typeof p === 'number') s. = p;
}
writeStocksStore(store);
const updated = [...prices.values()].filter(p => p !== null).length;
chunk(view, `\n✅ ${updated}/${store.length}개 종목 갱신 완료.\n`);
}
async function cmdSignal(view: Webview | undefined): Promise<void> {
const store = readStocksStore();
const g = classifyAll(store);
const buyZone = g.all.filter(s => s.signal === 'BUY_ZONE');
if (buyZone.length === 0) {
chunk(view, '\n🚨 매수사정권 종목 없음.\n');
return;
}
chunk(view, `\n🚨 **매수사정권 ${buyZone.length}개**\n\n`);
for (const s of buyZone) {
chunk(view, renderListLine(s) + '\n');
}
}
async function cmdSync(view: Webview | undefined, context: vscode.ExtensionContext): Promise<void> {
chunk(view, '\n📊 Google Sheets 동기화 중...\n');
const r = await syncToSheets(context);
if (r.ok) {
chunk(view, `\n✅ 동기화 완료: ${r.updatedRanges.length}개 시트.\n${r.updatedRanges.map(x => ` · ${x}`).join('\n')}\n`);
} else {
chunk(view, `\n❌ 동기화 실패:\n${r.errors.map(e => ` · ${e}`).join('\n')}\n`);
}
}
async function cmdAdd(arg: string, view: Webview | undefined): Promise<void> {
const parts = arg.split(/\s+/);
if (parts.length < 2) {
chunk(view, '\n사용법: `/stocks add <심볼> <이름> [투자성향]`\n 투자성향: 스윙/중기 | 장기투자 | 저평가우량주 (기본: 스윙/중기)\n');
return;
}
const [symbol, name, profileRaw] = parts;
const profile = (profileRaw as Stock['투자성향']) || '스윙/중기';
const r = addStock({ 이름: name, 심볼: symbol, 투자성향: profile });
chunk(view, r.ok ? `\n✅ 추가: ${name} (${symbol}, ${profile})\n` : `\n❌ ${r.reason}\n`);
}
async function cmdRemove(arg: string, view: Webview | undefined): Promise<void> {
if (!arg.trim()) { chunk(view, '\n사용법: `/stocks remove <심볼>`\n'); return; }
const r = removeStock(arg.trim());
chunk(view, r.ok ? `\n✅ 제거: ${arg.trim()}\n` : `\n❌ ${r.reason}\n`);
}
async function cmdJudge(arg: string, view: Webview | undefined): Promise<void> {
if (!arg.trim()) { chunk(view, '\n사용법: `/stocks judge <심볼>`\n'); return; }
const symbol = arg.trim();
chunk(view, `\n🤖 LLM 4-criteria 평가 중: ${symbol}...\n`);
const r = await judgeStock(symbol);
if (!r.ok) {
chunk(view, `\n❌ 평가 실패: ${r.error}\n`);
return;
}
chunk(view, `\n✅ 평가 완료: ${r.filterText}\n`);
if (r.rationale) chunk(view, `\n근거:\n${r.rationale}\n`);
}
async function cmdReport(view: Webview | undefined, context: vscode.ExtensionContext): Promise<void> {
chunk(view, '\n📨 텔레그램 보고서 발송 중...\n');
const r = await sendStocksReport(context);
chunk(view, r.ok ? '\n✅ 발송 완료.\n' : `\n❌ 발송 실패: ${r.reason}\n`);
chunk(view, `\n*Preview:*\n${buildReportText()}\n`);
}
async function cmdRun(view: Webview | undefined, context: vscode.ExtensionContext): Promise<void> {
chunk(view, '\n⚡ Watcher 1회 즉시 실행 (현재가 + Sheets + 텔레그램)...\n');
await runOnceNow(context);
chunk(view, '\n✅ 완료.\n');
}
async function cmdDiscover(rest: string, view: Webview | undefined, context?: vscode.ExtensionContext): Promise<void> {
// 인자 파싱 — `min max` (둘 다 억 단위) / 안 주면 default.
const parts = rest.split(/\s+/).filter(Boolean);
const minCap = parts[0] ? parseInt(parts[0], 10) : 1000;
const maxCap = parts[1] ? parseInt(parts[1], 10) : 5000;
if (Number.isNaN(minCap) || Number.isNaN(maxCap) || minCap >= maxCap) {
chunk(view, '\n사용법: `/stocks discover [min] [max]` (억 단위, 예: `/stocks discover 1000 5000`)\n');
return;
}
chunk(view, `\n🔍 **Naver 발굴 시작** (시총 ${minCap.toLocaleString()}억 ~ ${maxCap.toLocaleString()}억)\n`);
const candidates = await discoverStocks({
minMarketCapEok: minCap,
maxMarketCapEok: maxCap,
onProgress: (msg) => chunk(view, msg + '\n'),
});
if (candidates.length === 0) {
chunk(view, '\n결과 없음.\n');
return;
}
chunk(view, `\n📋 **발굴 후보 ${candidates.length}개** (통과 키워드 수 내림차순)\n\n`);
for (const c of candidates) {
const price = c.fundamentals.currentPrice;
const priceStr = typeof price === 'number' ? price.toLocaleString() + '원' : '-';
chunk(view, ` · ${c.name} (${c.symbol}) [${c.market} · ${c.marketCapEok.toLocaleString()}억]\n`);
chunk(view, ` 현재가 ${priceStr} · ROE ${c.fundamentals.roe?.toFixed(1) ?? '-'}% · 영업이익률 ${c.fundamentals.operatingMargin?.toFixed(1) ?? '-'}% · 유보율 ${c.fundamentals.retentionRatio?.toLocaleString() ?? '-'}%\n`);
chunk(view, ` 통과 (${c.passedKeywords.length}): ${c.passedKeywords.join(', ')}\n\n`);
}
// ── LLM 매력도 분석 + 텔레그램 전송 (자동 chain) ──
// 사용자 의도: 발굴 목록이 나오면 *항상* 분석 + 텔레그램. 별도 명령 trigger 불필요.
// 실패해도 (LLM timeout / 텔레그램 미설정) 위 발굴 목록은 화면에 그대로 남아 있음.
chunk(view, '\n');
const topFive = await analyzeTopCandidates(candidates, (msg) => chunk(view, msg + '\n'));
chunk(view, renderTopFiveForChat(topFive));
if (context) {
const rangeLabel = `시총 ${minCap.toLocaleString()}-${maxCap.toLocaleString()}`;
const tgText = renderTopFiveForTelegram(topFive, rangeLabel);
const tgResult = await sendTopFiveToTelegram(context, tgText);
chunk(view, tgResult.ok
? '\n📨 텔레그램 발송 완료.\n'
: `\n⚠️ 텔레그램 발송 skip: ${tgResult.reason}\n`);
} else {
chunk(view, '\n⚠️ 텔레그램 발송 skip: ExtensionContext 없음.\n');
}
chunk(view, '\n💡 종목을 stocks.json 에 추가하려면 `/stocks add <심볼> <이름>` 사용.\n');
}
function cmdPath(view: Webview | undefined): void {
const p = getStocksFilePath();
chunk(view, p ? `\n📂 stocks.json: \`${p}\`\n` : '\n⚠️ 워크스페이스 폴더 없음 — stocks 모듈 사용 불가.\n');
}
function cmdHelp(view: Webview | undefined): void {
chunk(view, [
'\n📈 **Stocks 명령**',
'',
' `/stocks list` — 종목 + 신호',
' `/stocks check` — 현재가 갱신',
' `/stocks signal` — 매수사정권 종목만',
' `/stocks sync` — Google Sheets 동기화',
' `/stocks add <심볼> <이름>` — 종목 추가',
' `/stocks remove <심볼>` — 종목 제거',
' `/stocks judge <심볼>` — LLM 4-criteria 평가',
' `/stocks discover [min] [max]` — Naver 크롤 발굴 (시총 억 단위, default 1000-5000)',
' `/stocks report` — 텔레그램 보고서 즉시 발송',
' `/stocks run` — Watcher 1회 즉시 실행',
' `/stocks path` — stocks.json 경로 표시',
'',
'자동 실행: VS Code 시작 시 활성화. KST 09:00 / 15:00 매일 자동.',
'',
].join('\n'));
}
/** slashRouter 가 `/stocks` 로 들어오는 모든 입력을 이 함수 한 곳으로 위임. */
export async function handleStocksCommand(
arg: string,
view: Webview | undefined,
context?: vscode.ExtensionContext,
): Promise<boolean> {
const parts = arg.trim().split(/\s+/);
const sub = (parts[0] || '').toLowerCase();
const rest = parts.slice(1).join(' ').trim();
logInfo(`Stocks slash: sub=${sub} rest="${rest.slice(0, 40)}"`);
try {
switch (sub) {
case '': cmdHelp(view); return true;
case 'list': await cmdList(view); return true;
case 'check': await cmdCheck(view); return true;
case 'signal': await cmdSignal(view); return true;
case 'sync':
if (!context) { chunk(view, '\n❌ ExtensionContext 없음 (sync 불가).\n'); return true; }
await cmdSync(view, context); return true;
case 'add': await cmdAdd(rest, view); return true;
case 'remove': case 'rm': await cmdRemove(rest, view); return true;
case 'judge': await cmdJudge(rest, view); return true;
case 'discover': await cmdDiscover(rest, view, context); return true;
case 'report':
if (!context) { chunk(view, '\n❌ ExtensionContext 없음.\n'); return true; }
await cmdReport(view, context); return true;
case 'run':
if (!context) { chunk(view, '\n❌ ExtensionContext 없음.\n'); return true; }
await cmdRun(view, context); return true;
case 'path': cmdPath(view); return true;
default:
chunk(view, `\n❌ 알 수 없는 sub-command: \`${sub}\`. \`/stocks\` 로 도움말 보기.\n`);
return true;
}
} catch (e: any) {
chunk(view, `\n❌ 에러: ${e?.message ?? String(e)}\n`);
return true;
}
}
+159
View File
@@ -0,0 +1,159 @@
import { logInfo } from '../../utils';
import { screenMarket, type Market, type ScreenerEntry } from './naverScreener';
import { fetchAllFundamentals, type Fundamentals } from './naverFundamentals';
import type { Stock } from './types';
/**
* `/stocks discover` Naver .
*
* :
* 1. ( + ) + ()
* 2. 1 (: 1,000 ~ 5,000)
* 3. ( 50-200) main
* 4. 8 (llmJudge.ts ) 3
* 5. ScreenedCandidate[] confirm stocks.json
*
* llmJudge * * discover judge * *
* mental model . ,
* judge LLM () discover ( ) .
* thresholds module (now: TODO).
*/
export interface DiscoverOptions {
/** 시가총액 하한 (억). default 1000 (1천억). */
minMarketCapEok?: number;
/** 시가총액 상한 (억). default 5000 (5천억). */
maxMarketCapEok?: number;
/** 시장 — default ['kospi', 'kosdaq']. */
markets?: Market[];
/** 시장당 크롤 페이지 수 — default 10 (페이지당 50개 = 500개/시장). */
maxPagesPerMarket?: number;
/** 최종 후보 최대 N개 — sortScore 내림차순 cut. default 20. */
limit?: number;
/** 진행률 콜백 (UI 가 사용). */
onProgress?: (msg: string) => void;
}
export interface DiscoveredCandidate {
symbol: string;
name: string;
market: Market;
marketCapEok: number;
/** 통과한 키워드들 (선별 후 최대 3개). */
passedKeywords: string[];
/** Stock 으로 변환된 형태 — stocks.json 에 그대로 추가 가능. */
asStock: Stock;
fundamentals: Fundamentals;
}
/** 8 키워드 각각의 통과 여부 판정 — llmJudge SYSTEM_PROMPT 의 임계값과 동일. */
function evaluateKeywords(f: Fundamentals): string[] {
const passed: string[] = [];
const roe = f.roe ?? 0;
const om = f.operatingMargin ?? 0;
const retention = f.retentionRatio ?? 0;
const pbr = f.pbr ?? Number.MAX_SAFE_INTEGER;
const mktCap = f.marketCapEok ?? 0;
const sector = (f.sectorHint || '').toLowerCase();
if (roe >= 10) passed.push('ROE');
if (om >= 15) passed.push('성장성');
if (retention >= 1000) passed.push('유동성');
if (om >= 10) {
// 10% 이상이면 "수익성", 20% 이상이면 "수익성 개선" 표기.
passed.push(om >= 20 ? '수익성 개선' : '수익성');
}
if (om >= 15 && roe >= 8) passed.push('영업효율');
const techKeywords = ['ai', '반도체', '배터리', '바이오', '기술', 'semicon', 'battery', 'bio'];
if (techKeywords.some(k => sector.includes(k)) && pbr >= 2) passed.push('기술력');
if (retention >= 3000 && mktCap >= 5000) passed.push('안정성');
if (pbr > 0 && pbr <= 1.5) passed.push('PBR');
return passed;
}
/** Fundamentals → Stock (stocks.json 호환 형식) 변환. */
function fundamentalsToStock(entry: ScreenerEntry, f: Fundamentals, filterText: string): Stock {
return {
이름: entry.name,
심볼: entry.symbol,
'유보율': f.retentionRatio !== undefined ? `${Math.round(f.retentionRatio).toLocaleString()}%` : undefined,
'ROE(25E)': f.roe !== undefined ? `${f.roe.toFixed(2)}%` : undefined,
'영업이익률(25E)': f.operatingMargin !== undefined ? `${f.operatingMargin.toFixed(1)}%` : undefined,
'PER(25E)': f.per !== undefined ? f.per.toFixed(1) : undefined,
PBR: f.pbr !== undefined ? f.pbr.toFixed(1) : undefined,
'시가총액': f.marketCapEok !== undefined ? `${Math.round(f.marketCapEok).toLocaleString()}` : undefined,
'3/4 필터': filterText,
현재가: f.currentPrice,
// 시가총액 기준 자동 분류 — 사용자가 나중에 수동으로 바꿀 수 있음.
투자성향: entry.marketCapEok < 3000 ? '저평가우량주' :
entry.marketCapEok < 10000 ? '스윙/중기' : '장기투자',
};
}
export async function discoverStocks(opts: DiscoverOptions = {}): Promise<DiscoveredCandidate[]> {
const minCap = opts.minMarketCapEok ?? 1000;
const maxCap = opts.maxMarketCapEok ?? 5000;
const markets = opts.markets ?? ['kospi', 'kosdaq'];
const maxPages = opts.maxPagesPerMarket ?? 10;
const limit = opts.limit ?? 20;
const progress = opts.onProgress ?? (() => {});
progress(`📡 Naver 시가총액 페이지 크롤 시작 — 시장 ${markets.join('+')}, 시총 ${minCap}-${maxCap}`);
// (1)+(2) 시가총액 페이지 → 1차 필터링.
const allEntries: ScreenerEntry[] = [];
for (const market of markets) {
progress(` · ${market} 스캔 중...`);
const entries = await screenMarket({
market,
maxPages,
minMarketCapEok: minCap,
maxMarketCapEok: maxCap,
onProgress: (page, total) => progress(` p${page} → 누적 ${total}`),
});
allEntries.push(...entries);
}
if (allEntries.length === 0) {
progress('⚠️ 시가총액 범위 내 후보 0개. 범위 넓혀서 다시 시도해보세요.');
return [];
}
progress(`\n✅ 1차 후보 ${allEntries.length}개 — 펀더멘털 크롤 시작 (~${Math.ceil(allEntries.length * 0.5)}초 소요).`);
// (3) 개별 펀더멘털 크롤.
const symbols = allEntries.map(e => e.symbol);
const fundsMap = await fetchAllFundamentals(symbols, (sym, _f, i, total) => {
if (i % 10 === 0 || i === total) progress(` 펀더멘털 ${i}/${total} 처리 중...`);
});
// (4) 8 키워드 평가.
const candidates: DiscoveredCandidate[] = [];
for (const entry of allEntries) {
const f = fundsMap.get(entry.symbol);
if (!f) continue;
const passed = evaluateKeywords(f);
if (passed.length < 3) continue;
// 상위 3개만 골라서 텍스트화 (judge 와 동일 패턴).
const top3 = passed.slice(0, 3);
const filterText = `[발굴 자동] 충족 (${top3.join(', ')})`;
candidates.push({
symbol: entry.symbol,
name: entry.name,
market: entry.market,
marketCapEok: entry.marketCapEok,
passedKeywords: passed,
asStock: fundamentalsToStock(entry, f, filterText),
fundamentals: f,
});
}
// sortScore — 통과 키워드 수 내림차순.
candidates.sort((a, b) => b.passedKeywords.length - a.passedKeywords.length);
const limited = candidates.slice(0, limit);
logInfo(`Stocks discovery: ${allEntries.length} 1차 후보 → ${candidates.length} 매수후보 → ${limited.length} 표시.`);
progress(`\n🎯 ${limited.length}개 후보 발굴 완료 (전체 1차후보 ${allEntries.length}개 중 ${candidates.length}개가 3 키워드 이상 통과).`);
return limited;
}
+96
View File
@@ -0,0 +1,96 @@
import * as fs from 'fs';
import * as path from 'path';
import * as vscode from 'vscode';
import { logError, logInfo } from '../../utils';
import type { Stock, StocksStore } from './types';
/**
* `.astra/stocks.json` source of truth .
*
* (q1=A): .
* (= VS Code ) store
* watcher / slash silent skip.
*
* Atomic write: tmp rename read SIGKILL partial JSON
* .
*/
const STORE_REL_PATH = '.astra/stocks.json';
export function getStocksFilePath(): string | null {
const folders = vscode.workspace.workspaceFolders;
if (!folders || folders.length === 0) return null;
return path.join(folders[0].uri.fsPath, STORE_REL_PATH);
}
/** 파일 없으면 빈 배열 반환. 파일 파싱 실패해도 빈 배열 + 에러 로그. */
export function readStocksStore(): StocksStore {
const filePath = getStocksFilePath();
if (!filePath || !fs.existsSync(filePath)) return [];
try {
const raw = fs.readFileSync(filePath, 'utf-8');
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
logError('stocks.json 가 배열이 아닙니다 — 빈 store 반환.', { filePath });
return [];
}
return parsed as StocksStore;
} catch (e: any) {
logError('stocks.json 읽기 실패.', { filePath, error: e?.message ?? String(e) });
return [];
}
}
/** Atomic write — tmp + rename. 워크스페이스 없으면 false 반환 (caller 가 안내). */
export function writeStocksStore(store: StocksStore): boolean {
const filePath = getStocksFilePath();
if (!filePath) {
logError('워크스페이스 폴더가 없어 stocks.json 쓰기 불가.');
return false;
}
try {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
fs.writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf-8');
fs.renameSync(tmp, filePath);
return true;
} catch (e: any) {
logError('stocks.json 쓰기 실패.', { filePath, error: e?.message ?? String(e) });
return false;
}
}
/** 한 종목 추가. 같은 심볼이 이미 있으면 false (caller 가 안내). */
export function addStock(stock: Stock): { ok: boolean; reason?: string } {
const store = readStocksStore();
if (store.some(s => s. === stock.)) {
return { ok: false, reason: `심볼 ${stock.} 이미 존재` };
}
store.push(stock);
const wrote = writeStocksStore(store);
if (!wrote) return { ok: false, reason: '쓰기 실패 (워크스페이스 없음 또는 권한)' };
logInfo('Stocks: 종목 추가.', { symbol: stock., name: stock.이름 });
return { ok: true };
}
/** 한 종목 제거. 못 찾으면 false. */
export function removeStock(symbol: string): { ok: boolean; reason?: string } {
const store = readStocksStore();
const idx = store.findIndex(s => s. === symbol);
if (idx < 0) return { ok: false, reason: `심볼 ${symbol} 못 찾음` };
const removed = store.splice(idx, 1)[0];
const wrote = writeStocksStore(store);
if (!wrote) return { ok: false, reason: '쓰기 실패' };
logInfo('Stocks: 종목 제거.', { symbol, name: removed.이름 });
return { ok: true };
}
/** 한 종목의 필드 patch — 현재가 갱신 / 필터 업데이트 등. */
export function updateStock(symbol: string, patch: Partial<Stock>): boolean {
const store = readStocksStore();
const idx = store.findIndex(s => s. === symbol);
if (idx < 0) return false;
store[idx] = { ...store[idx], ...patch };
return writeStocksStore(store);
}
+163
View File
@@ -0,0 +1,163 @@
import * as vscode from 'vscode';
import { logError, logInfo } from '../../utils';
import { readStocksStore, writeStocksStore } from './stocksStore';
import { fetchAllPrices } from './yahooClient';
import { sendStocksReport } from './telegramReport';
import { syncToSheets } from './sheetsSync';
/**
* VS Code KST 09:00 / 15:00 :
* 1. Yahoo stocks.json
* 2. () Google Sheets g1nation.stocks.spreadsheetId
* 3.
*
* :
* - setTimeout chain firing setTimeout.
* - VS Code disposable clear.
* - Asia/Seoul macOS timezone .
* - run_kodari_sync.command sleep-loop setTimeout .
*
* SCHEDULE .
*/
const SCHEDULE_HOURS_KST = [9, 15]; // 09:00, 15:00 KST
let _timer: NodeJS.Timeout | undefined;
let _disposed = false;
/** Asia/Seoul 기준 *지금* 의 hour/minute. */
function nowInKst(): { date: Date; hour: number; minute: number; ymd: string } {
const now = new Date();
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Seoul',
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false,
}).formatToParts(now);
const get = (t: string) => Number(parts.find(p => p.type === t)?.value || '0');
return {
date: now,
hour: get('hour'),
minute: get('minute'),
ymd: `${get('year')}-${parts.find(p => p.type === 'month')?.value}-${parts.find(p => p.type === 'day')?.value}`,
};
}
/**
* firing milliseconds .
* SCHEDULE_HOURS_KST * * , .
*
* 변환: 사용자 OS KST KST hour/minute
* ms ( × 60000).
*/
function msUntilNextRun(): number {
const kst = nowInKst();
const todayMinutes = kst.hour * 60 + kst.minute;
let bestTodayMinutes: number | null = null;
for (const h of SCHEDULE_HOURS_KST) {
const targetMinutes = h * 60;
if (targetMinutes > todayMinutes) {
bestTodayMinutes = targetMinutes;
break;
}
}
if (bestTodayMinutes !== null) {
return (bestTodayMinutes - todayMinutes) * 60_000;
}
// 오늘 더 없음 → 내일 첫 시각.
const tomorrowFirstMinutes = SCHEDULE_HOURS_KST[0] * 60;
const remainingTodayMinutes = 24 * 60 - todayMinutes;
return (remainingTodayMinutes + tomorrowFirstMinutes) * 60_000;
}
/**
* fire + () Sheets sync + .
* try/catch .
*/
async function fireOnce(context: vscode.ExtensionContext): Promise<void> {
const kst = nowInKst();
logInfo('Stocks watcher fire 시작.', { kst: `${kst.hour}:${kst.minute}` });
// (1) Yahoo 가격 갱신
try {
const store = readStocksStore();
const symbols = store.map(s => s.).filter(Boolean);
if (symbols.length === 0) {
logInfo('Stocks watcher: 종목 없음 — skip.');
} else {
const prices = await fetchAllPrices(symbols);
for (const s of store) {
const p = prices.get(s.);
if (typeof p === 'number') s. = p;
}
writeStocksStore(store);
}
} catch (e: any) {
logError('Stocks watcher: 가격 갱신 실패.', { error: e?.message ?? String(e) });
}
// (2) Sheets sync — 선택 (spreadsheetId 설정 시에만)
try {
const cfg = vscode.workspace.getConfiguration('g1nation');
if ((cfg.get<string>('stocks.spreadsheetId') || '').trim()) {
const r = await syncToSheets(context);
if (!r.ok) logError('Stocks watcher: Sheets 동기화 실패.', { errors: r.errors });
}
} catch (e: any) {
logError('Stocks watcher: Sheets 호출 실패.', { error: e?.message ?? String(e) });
}
// (3) Telegram 보고서
try {
const r = await sendStocksReport(context);
if (!r.ok) logInfo(`Stocks watcher: 보고서 skip — ${r.reason}`);
} catch (e: any) {
logError('Stocks watcher: 보고서 호출 실패.', { error: e?.message ?? String(e) });
}
}
function scheduleNext(context: vscode.ExtensionContext): void {
if (_disposed) return;
const ms = msUntilNextRun();
const hours = Math.floor(ms / 3600_000);
const minutes = Math.floor((ms % 3600_000) / 60_000);
logInfo(`Stocks watcher: 다음 firing 까지 ${hours}h ${minutes}m.`);
_timer = setTimeout(async () => {
try {
await fireOnce(context);
} catch (e: any) {
logError('Stocks watcher: fireOnce 예외.', { error: e?.message ?? String(e) });
}
scheduleNext(context);
}, ms);
}
/**
* VS Code (extension.ts activate ).
* `g1nation.stocks.watcherEnabled` false skip.
*/
export function startStocksWatcher(context: vscode.ExtensionContext): vscode.Disposable {
const cfg = vscode.workspace.getConfiguration('g1nation');
const enabled = cfg.get<boolean>('stocks.watcherEnabled', true);
if (!enabled) {
logInfo('Stocks watcher: 비활성 (g1nation.stocks.watcherEnabled=false).');
return { dispose: () => {} };
}
_disposed = false;
scheduleNext(context);
logInfo('Stocks watcher: 시작됨.', { schedule: SCHEDULE_HOURS_KST });
return {
dispose: () => {
_disposed = true;
if (_timer) { clearTimeout(_timer); _timer = undefined; }
logInfo('Stocks watcher: dispose.');
},
};
}
/** 명령으로 즉시 한 번 트리거 (`/stocks watch run` 같은 미래 명령 또는 디버깅). */
export async function runOnceNow(context: vscode.ExtensionContext): Promise<void> {
await fireOnce(context);
}
+117
View File
@@ -0,0 +1,117 @@
import * as vscode from 'vscode';
import { TelegramHttpClient } from '../../integrations/telegram/telegramClient';
import { TELEGRAM_TOKEN_SECRET_KEY } from '../../extension/telegramCommands';
import { logError, logInfo } from '../../utils';
import { readStocksStore } from './stocksStore';
import { classifyAll } from './signalClassifier';
import type { ClassifiedStock } from './types';
/**
* 09:00 / 15:00 KST Telegram .
*
* (q2=A): chatId `g1nation.telegram.allowedChatIds[0]` .
* fallback `g1nation.stocks.telegramChatId` . skip + .
* telegramCommands SecretStorage .
*/
function pickChatId(): number | null {
const cfg = vscode.workspace.getConfiguration('g1nation');
const allowed = cfg.get<number[]>('telegram.allowedChatIds', []) || [];
if (allowed.length > 0) return allowed[0];
const dedicated = cfg.get<number>('stocks.telegramChatId', 0);
if (dedicated && dedicated !== 0) return dedicated;
return null;
}
/** 한 카테고리 (스윙/장기/저평가) 의 종목 리스트를 텔레그램 Markdown 으로 렌더. */
function renderGroup(label: string, stocks: ClassifiedStock[]): string[] {
const buyZone = stocks.filter(s => s.signal === 'BUY_ZONE');
const overvalued = stocks.filter(s => s.signal === 'OVERVALUED');
const hold = stocks.filter(s => s.signal === 'HOLD');
const lines: string[] = [`*${label}* (총 ${stocks.length}개)`];
if (buyZone.length > 0) {
lines.push(`🚨 매수사정권 (${buyZone.length})`);
for (const s of buyZone) {
const cur = s. ? s..toLocaleString() : '?';
lines.push(` · ${s.} (${s.}): ${cur} / 권장 ${s. ?? '-'}`);
}
}
if (overvalued.length > 0) {
lines.push(`⚠️ 가격 조정 감시 (${overvalued.length})`);
for (const s of overvalued) {
const cur = s. ? s..toLocaleString() : '?';
lines.push(` · ${s.} (${s.}): ${cur} / 권장 ${s. ?? '-'}`);
}
}
if (hold.length > 0 && buyZone.length === 0 && overvalued.length === 0) {
// 모두 관망일 때만 그 카운트만 표시 — 종목 일일이 안 나열 (보고서 길이 절약).
lines.push(`📊 관망 ${hold.length}`);
} else if (hold.length > 0) {
lines.push(`📊 관망 ${hold.length}건 (생략)`);
}
return lines;
}
/** 보고서 텍스트 생성 (전송과 분리 — 테스트·로그용). */
export function buildReportText(now: Date = new Date()): string {
const store = readStocksStore();
if (store.length === 0) {
return '⚠️ stocks.json 에 종목 없음 — `/stocks add <심볼> <이름>` 으로 추가하세요.';
}
const g = classifyAll(store);
const kstStr = formatKstTimestamp(now);
const out: string[] = [`🦅 *Kodari 정기 보고서* _${kstStr}_`, ''];
out.push(...renderGroup('스윙/중기', g.swing), '');
out.push(...renderGroup('장기투자', g.long), '');
out.push(...renderGroup('저평가우량주', g.ultraLow));
return out.join('\n');
}
/** Date → "2026-05-25 09:00 KST" 형식. */
function formatKstTimestamp(d: Date): string {
const fmt = new Intl.DateTimeFormat('ko-KR', {
timeZone: 'Asia/Seoul',
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false,
});
const parts = fmt.formatToParts(d);
const get = (t: string) => parts.find(p => p.type === t)?.value || '';
return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')} KST`;
}
/**
* . chatId silent skip (warn ).
*
* / caller (watcher / slash ) .
*/
export async function sendStocksReport(
context: vscode.ExtensionContext,
): Promise<{ ok: boolean; reason?: string }> {
const chatId = pickChatId();
if (chatId === null) {
const reason = '텔레그램 chatId 미설정 (g1nation.telegram.allowedChatIds 또는 g1nation.stocks.telegramChatId).';
logInfo(`Stocks report skip: ${reason}`);
return { ok: false, reason };
}
const token = (await context.secrets.get(TELEGRAM_TOKEN_SECRET_KEY)) || '';
if (!token.trim()) {
const reason = '텔레그램 봇 토큰 없음 — `Astra: Set Telegram Bot Token` 으로 등록.';
logInfo(`Stocks report skip: ${reason}`);
return { ok: false, reason };
}
const text = buildReportText();
const client = new TelegramHttpClient({ getToken: () => token });
try {
await client.sendMessage({ chatId, text, parseMode: 'Markdown' });
logInfo('Stocks report 발송 완료.', { chatId, chars: text.length });
return { ok: true };
} catch (e: any) {
logError('Stocks report 발송 실패.', { chatId, error: e?.message ?? String(e) });
return { ok: false, reason: e?.message ?? String(e) };
}
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Stocks .
*
* invest_results/target_stocks.json , ConnectAI
* `<workspace>/.astra/stocks.json` .
*
* + JSON friction .
*/
/** target_stocks.json 의 한 종목 항목. */
export interface Stock {
이름: string;
심볼: string;
/** ISO date — 보통 'YYYY-MM-DD'. */
상장일?: string;
유보율?: string;
'ROE(25E)'?: string;
'영업이익률(25E)'?: string;
'EPS(25E)'?: string;
'PER(25E)'?: string;
PBR?: string;
시가총액?: string;
/** 사람이 계산한 적정주가 (텍스트). */
적정주가?: string;
/** 매수 추천 임계가. signalClassifier 가 현재가와 비교. */
매수권장가?: string;
설립일?: string;
/** 핵심 사업 한 줄. */
'최대 먹거리'?: string;
특이사항?: string;
/** "충족 (ROE, 성장성, 유동성)" 같은 텍스트. signalClassifier 는 `.includes("충족")` 로만 매칭. */
'3/4 필터'?: string;
/** Yahoo Finance 최근 fetch 한 현재가 (정수). 0 또는 누락이면 미수집. */
현재가?: number;
/** 분류 시트 — '스윙/중기' / '장기투자' / '저평가우량주' 중 하나. */
?: '스윙/중기' | '장기투자' | '저평가우량주';
}
export type StocksStore = Stock[];
/** 신호 분류 결과 — UI / 텔레그램 / 시트 동기화 모두 이 모양을 공유. */
export type Signal = 'BUY_ZONE' | 'OVERVALUED' | 'HOLD';
export interface ClassifiedStock extends Stock {
/** 정량 + 정성 필터 결합 결과. */
signal: Signal;
/** 텔레그램·시트 정렬용. 2 = 매수사정권, 1 = 가격 조정 감시, 0 = 관망. */
sortScore: 0 | 1 | 2;
/** `.includes("충족")` 결과 — 정성 필터 통과 여부. */
filterPass: boolean;
/** 사용자에게 표시할 한글 신호 문구. */
signalText: string;
}
+60
View File
@@ -0,0 +1,60 @@
import { logError, logInfo } from '../../utils';
/**
* Yahoo Finance public chart endpoint fetch. invest_results/quick_check.js
* symbol suffix `.KQ` () , `.KS` () .
*
* Yahoo `<6자리>.KQ` `<6자리>.KS` . US .
* symbol `.` .
*
* Returns null suffix skip .
*/
export async function fetchYahooPrice(symbol: string, timeoutMs = 8000): Promise<number | null> {
if (!symbol) return null;
const candidates: string[] = symbol.includes('.')
? [symbol]
: [`${symbol}.KQ`, `${symbol}.KS`];
for (const yahooSymbol of candidates) {
try {
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${yahooSymbol}`;
const res = await fetch(url, {
headers: { 'User-Agent': 'Mozilla/5.0' },
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) continue;
const data: any = await res.json();
const price = data?.chart?.result?.[0]?.meta?.regularMarketPrice;
if (typeof price === 'number' && Number.isFinite(price)) {
return price;
}
} catch (e: any) {
// suffix 후보가 더 남았으면 계속 시도, 마지막이면 null fallthrough.
if (yahooSymbol === candidates[candidates.length - 1]) {
logError('Yahoo Finance 현재가 조회 실패.', { symbol, yahooSymbol, error: e?.message ?? String(e) });
}
}
}
return null;
}
/**
* fetchYahooPrice 1 throttle (Yahoo rate limit).
* partial 허용: 실패해도 , Map .
*
* caller store patch.
*/
export async function fetchAllPrices(
symbols: string[],
onProgress?: (symbol: string, price: number | null) => void,
): Promise<Map<string, number | null>> {
const out = new Map<string, number | null>();
for (const symbol of symbols) {
const price = await fetchYahooPrice(symbol);
out.set(symbol, price);
onProgress?.(symbol, price);
await new Promise(r => setTimeout(r, 1000));
}
logInfo('Yahoo 일괄 갱신 완료.', { total: symbols.length, success: [...out.values()].filter(p => p !== null).length });
return out;
}
+143
View File
@@ -0,0 +1,143 @@
import * as fs from 'fs';
import * as path from 'path';
import * as vscode from 'vscode';
import type { CompanyState } from '../../features/company';
/**
* Telegram 5.
* stateless + Telegram .
*
* - buildTelegramSystemPrompt(hasContext) (4 /)
* - looksLikeWorkOrder(text)
* - buildTelegramCompanyContext(state, ctx) [COMPANY CONTEXT]
* - latestCompanySessionDir(ctx) ( )
* - chunkTelegramMessage(text, max) 4096
*/
/**
* Build the Telegram-specific system prompt.
*
* Why this matters: small local models (gemma e2b/e4b) drift badly when
* called as a single user message with no role grounding. The reported
* symptom ("path 입력 → 시 못 써드려요" ) is exactly that
* drift the model invents an interpretation because it has no anchor.
*
* The prompt does four things:
* 1. Names the role (Astra Telegram assistant) so the model has a
* consistent persona across messages.
* 2. States the language rule (mirror the user's language).
* 3. Tells the model how to treat brain context (evidence when relevant,
* ignore otherwise never refuse the question because context
* doesn't match).
* 4. Specifies behavior for ambiguous inputs (paths, single words,
* fragments) ask a clarifying question instead of guessing.
*/
export function buildTelegramSystemPrompt(hasContext: boolean): string {
const base = [
'You are Astra, a Telegram assistant connected to the user\'s personal Second Brain knowledge base.',
'Reply in the user\'s language (mirror Korean ↔ English exactly as the user wrote).',
'Be concise but complete. Telegram messages should feel like a knowledgeable friend, not a formal report.',
'',
'Behavior rules:',
'- Never refuse a question by claiming you can only do certain things. If you can answer, just answer.',
'- If the user\'s message is ambiguous (a single word, a file path, a fragment with no question), ask one short clarifying question instead of guessing what they meant.',
'- Do NOT invent that the user asked for poetry, songs, code, or any content type they did not request.',
];
if (hasContext) {
base.push(
'',
'You will receive a [SECOND BRAIN CONTEXT] block before the user\'s message.',
'- Use it as evidence only when it directly answers the question. Cite the file path (relative form, e.g. `10_Wiki/Topics/Foo.md`) inline when you do.',
'- If the context is unrelated to the question, ignore it silently. Do NOT mention that the context exists, do NOT explain why it doesn\'t apply, do NOT refuse the question because of it.',
);
}
return base.join('\n');
}
/**
* Cheap heuristic: does the message look like a *work order* the user
* wants the company to execute? Triggers company-turn routing.
*
* Conservative matches only we'd rather miss a borderline case
* (user retries with clearer wording) than mis-route a question
* into a company turn (which spends LLM calls + writes to disk).
*
* Positive signals:
* Explicit dispatch prefix: "CEO한테", "회사한테", "팀한테"
* Korean imperative verbs at sentence end: 만들어///
* //////
* English imperatives: "make X", "build X", "create X", "implement"
*
* Negative signals (override treat as question, not order):
* Ends with "?" pure question
* Contains "알려줘 / 어디 / 뭐야 / what / where" informational
*/
export function looksLikeWorkOrder(text: string): boolean {
const t = (text || '').trim();
if (!t) return false;
if (/(CEO|회사|팀)\s*(한테|에게|보고|에)/i.test(t)) return true;
if (/[?]$/.test(t)) return false;
if (/(어디(에|에서|야)|뭐야|얼마|언제|왜|^(누구|어떻게|뭐))/i.test(t)) return false;
if (/(만들어|짜줘|작성해|구현해|돌려줘|실행해|분석해|정리해|보고해|해줘|짜봐|만들어줘)/i.test(t)) return true;
if (/^\s*(make|build|create|implement|run|analyze|generate|write|fix|add|remove)\b/i.test(t)) return true;
return false;
}
/** Find the newest `<workspace>/.astra/company/sessions/<ts>/` directory, or '' if none. */
export function latestCompanySessionDir(ctx: vscode.ExtensionContext): string {
try {
const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
const baseDir = ws
? path.join(ws, '.astra', 'company', 'sessions')
: path.join(ctx.globalStorageUri.fsPath, 'company', 'sessions');
if (!fs.existsSync(baseDir)) return '';
const dirs = fs.readdirSync(baseDir)
.filter((n) => fs.statSync(path.join(baseDir, n)).isDirectory())
.sort()
.reverse();
return dirs[0] ? path.join(baseDir, dirs[0]) : '';
} catch { return ''; }
}
/**
* Build a `[COMPANY CONTEXT]` block describing the workspace, the
* current company state, and the most recent session directory. Lets
* the bot answer questions like "어디에 저장했어?" by reading its own
* mirror history *plus* the resolved absolute path on disk.
*
* Returns '' when company mode is off, so the prompt stays minimal
* for users who only use the Telegram bot for RAG-chat.
*/
export function buildTelegramCompanyContext(state: CompanyState, ctx: vscode.ExtensionContext): string {
if (!state.enabled) return '';
const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
const lines: string[] = [`[COMPANY CONTEXT]`];
lines.push(`회사명: ${state.companyName || '1인 기업'}`);
if (ws) lines.push(`작업 폴더 (워크스페이스 루트): ${ws}`);
const latestSession = latestCompanySessionDir(ctx);
if (latestSession) {
lines.push(`최근 작업 세션 폴더: ${latestSession}`);
lines.push(`(이 안에 _brief.md, _report.md, 각 에이전트별 산출물이 저장됨)`);
}
lines.push('');
lines.push('당신의 역할: 이 회사의 비서(Secretary). 사용자(사장님)의 질문에 답할 때 위 경로 정보를 *그대로* 활용하세요.');
lines.push('"실제 파일 시스템에 접근할 수 없다" 같은 답변은 잘못된 것입니다 — 위 경로가 실제 시스템 경로입니다.');
return lines.join('\n');
}
/** Telegram has a 4096-char per-message limit. Split on paragraph/sentence boundaries to keep replies readable. */
export function chunkTelegramMessage(text: string, max = 4000): string[] {
if (text.length <= max) return [text];
const out: string[] = [];
let remaining = text;
while (remaining.length > max) {
let cut = remaining.lastIndexOf('\n\n', max);
if (cut < max * 0.5) cut = remaining.lastIndexOf('\n', max);
if (cut < max * 0.5) cut = remaining.lastIndexOf('. ', max);
if (cut < max * 0.5) cut = max;
out.push(remaining.slice(0, cut).trim());
remaining = remaining.slice(cut).trim();
}
if (remaining) out.push(remaining);
return out;
}
+176
View File
@@ -0,0 +1,176 @@
import * as vscode from 'vscode';
import { TelegramBot } from './telegramBot';
import type { TelegramHttpClient } from './telegramClient';
import { AIService } from '../../core/services';
import { getActiveBrainProfile, logInfo, logError } from '../../utils';
import { resolveScopeForAgent } from '../../skills/agentKnowledgeMap';
import { retrieveScoped, buildContextBlock } from '../../skills/scopedBrainRetriever';
import type { SidebarChatProvider } from '../../sidebarProvider';
import {
buildTelegramSystemPrompt,
looksLikeWorkOrder,
buildTelegramCompanyContext,
chunkTelegramMessage,
} from './promptBuilders';
export interface TelegramSetupDeps {
telegramClient: TelegramHttpClient;
/** activate() 안 `let provider` 의 *호출 시점* 값. 등록 시점엔 undefined 일 수 있음. */
getProvider: () => SidebarChatProvider | undefined;
}
/**
* Telegram bot + .
*
* :
* 1) allowlist (`telegram.allowedChatIds`) silent drop
* 2) + work-order CEO ( ack )
* 3) Per-chat agent + scoped RAG
* 4) (company-aware) + +
* 5) AI empty/error fallback
* 6) reply + 4096
*
* "조용히 실패하지 말 것" "응답 없음"
* .
* .
*/
export function createTelegramBot(
context: vscode.ExtensionContext,
deps: TelegramSetupDeps,
): TelegramBot {
const { telegramClient, getProvider } = deps;
const telegramAi = new AIService();
return new TelegramBot({
client: telegramClient,
handle: async (text, chatId) => {
const cfg = vscode.workspace.getConfiguration('g1nation');
const allowed = cfg.get<number[]>('telegram.allowedChatIds', []) || [];
if (allowed.length > 0 && !allowed.includes(chatId)) {
logInfo('Telegram message from unallowed chat ignored.', { chatId });
return null;
}
// 진입점 trace — silent failure 진단용. "응답 없음" 신고 시 이 로그가
// 없으면 메시지가 여기까지도 못 옴 (allowlist 또는 polling drop).
logInfo('Telegram message received.', {
chatId,
chars: text.length,
preview: text.length > 80 ? text.slice(0, 80) + '…' : text,
});
// ── 1인 기업 모드 라우팅 ────────────────────────────────────────
// 회사 모드 ON + 메시지가 work *order* 처럼 보이면 (만들어줘/해줘 또는
// "CEO한테 …" 접두) RAG-chat 대신 dispatcher 로. dispatcher 가 끝에
// Telegram mirror 를 쏘므로 사용자는 제대로 된 보고서를 받음.
const { readCompanyState } = await import('../../features/company');
const companyState = readCompanyState(context);
if (companyState.enabled && looksLikeWorkOrder(text)) {
const { appendTelegramMessage } = await import('./conversationHistory');
appendTelegramMessage({ chatId, role: 'user', text, kind: 'user' });
logInfo('Telegram: routing to company turn.', { chatId, preview: text.slice(0, 60) });
// Fire-and-forget — dispatcher 의 secretary mirror 가 최종 보고 전송.
// 즉시 ack 를 반환해 사용자는 봇이 명령을 받았다는 신호를 봄.
void (async () => {
try {
await getProvider()!._runCompanyTurn(text);
} catch (e: any) {
logError('Telegram → company turn failed.', { error: e?.message ?? String(e) });
}
})();
const ack = '🧭 CEO에게 전달했어요. 작업 끝나면 보고드릴게요.';
appendTelegramMessage({ chatId, role: 'assistant', text: ack, kind: 'reply' });
return ack;
}
// Per-chat agent override → global default → mapping default.
const perChatAgents = cfg.get<Record<string, string>>('telegram.agentByChatId', {}) || {};
const perChatAgent = perChatAgents[String(chatId)];
const defaultAgent = cfg.get<string>('telegram.defaultAgent', '') || '';
const agentName = (perChatAgent || defaultAgent || '').trim();
const brain = getActiveBrainProfile();
const brainRoot = brain?.localBrainPath || '';
const scope = resolveScopeForAgent(agentName, brainRoot);
// RAG retrieval — agent 매치 없어도 전체 brain 검색해 봇이 계속 유용하게.
// buildContextBlock 가 '' 반환하면 섹션 자체를 빼버림 (cleaner prompt).
let contextBlock = '';
if (brainRoot) {
try {
const result = retrieveScoped(text, brainRoot, scope.folders, {
maxResults: cfg.get<number>('telegram.contextChunks', 6) ?? 6,
});
contextBlock = buildContextBlock(result);
logInfo('Telegram RAG retrieval done.', {
chatId,
agent: scope.agent?.name ?? '(none)',
scopedFolders: scope.folders.length,
candidates: result.candidateCount,
chunks: result.chunks.length,
});
} catch (e: any) {
logError('Telegram RAG retrieval failed; falling back to plain prompt.', {
chatId, error: e?.message ?? String(e),
});
}
}
// Company-aware 시스템 프롬프트 — 회사 모드 ON 일 때 봇은 그 회사의 *비서*.
// 모델에게 그렇게 말해줘야 "어디에 저장했어?" 같은 질문에 "파일 시스템 접근
// 못 함" 으로 회피하지 않고 실제 경로를 그대로 답함.
const companyContextBlock = buildTelegramCompanyContext(companyState, context);
const systemPrompt = buildTelegramSystemPrompt(!!contextBlock)
+ (companyContextBlock ? `\n\n${companyContextBlock}` : '');
// Per-chat 대화 히스토리 — 없으면 매 inbound 가 fresh turn 이라
// "방금 한 말" 을 봇이 즉시 잊음. AI service 의 {system, user} 표면이
// messages 배열을 안 받아서 user 메시지에 inline 으로 박음.
const { appendTelegramMessage, getRecentMessages, formatHistoryForPrompt } =
await import('./conversationHistory');
const history = getRecentMessages(chatId, 10);
const historyBlock = formatHistoryForPrompt(history);
const pieces: string[] = [];
if (contextBlock) pieces.push(`[SECOND BRAIN CONTEXT]\n${contextBlock}`);
if (historyBlock) pieces.push(historyBlock);
pieces.push(`[USER MESSAGE]\n${text}`);
const userMessage = pieces.join('\n\n');
// AI 호출 *전에* user 메시지 persist — 실패해도 다음 inbound 가 발화를 봄.
appendTelegramMessage({ chatId, role: 'user', text, kind: 'user' });
try {
const result = await telegramAi.chat({ system: systemPrompt, user: userMessage });
logInfo('Telegram AI reply generated.', {
chatId, engine: result.engine, model: result.model,
empty: result.empty, chars: result.content.length,
});
if (result.empty) {
// silent 가 아니라 사용자에게 도달. 모델 재시작/질문 단순화 안내.
return [
'⚠️ AI 모델이 빈 응답을 반환했습니다.',
'',
'다음을 시도해보세요:',
'• LM Studio에서 모델이 실제로 로드되어 있는지 확인',
'• 더 짧고 구체적인 질문으로 다시 보내기',
'• `Astra: Test Telegram Connection` 으로 연결 상태 확인',
].join('\n');
}
// assistant reply persist → 다음 inbound 가 우리가 한 말을 봄.
appendTelegramMessage({ chatId, role: 'assistant', text: result.content, kind: 'reply' });
// 4096자 hard limit 분할. 1청크면 그대로, 여러 청크는 "(이어서 i/n)"
// 힌트와 함께 join — bot framework 가 한 메시지로 보내지만, drop 없이
// 모든 청크를 시도하는 게 silent loss 보다 나음.
const chunks = chunkTelegramMessage(result.content);
if (chunks.length === 1) return chunks[0];
return chunks.map((c, i) => i === 0 ? c : `(이어서 ${i + 1}/${chunks.length})\n\n${c}`).join('\n\n---\n\n').slice(0, 4000);
} catch (e: any) {
// 하드 실패에서도 ALWAYS 응답 — silent failure 가 두 번째로 보고된 통증.
logError('Telegram handler threw.', { chatId, error: e?.message ?? String(e) });
return `⚠️ Astra 처리 중 오류가 발생했습니다.\n${e?.message ?? e}\n\nLM Studio가 실행 중인지, 모델이 로드되어 있는지 확인해주세요.`;
}
},
});
}
@@ -0,0 +1,56 @@
/**
* Astra Mode Architecture Context Builder.
*
* 의도: 사용자가 *Astra mode * (Guard vs Multi-Agent
* ) , *
* * .
*
* builder *stateless* instance state . agent.ts
* private extract god file +
* .
*/
/**
* prompt "Astra 의 Guard vs MA 모드 분리 결정" .
*
* 3 ** true
* .
*/
export function isAstraModeArchitectureQuestion(prompt: string): boolean {
if (!prompt) return false;
const mentionsGuard = /\bguard\b|가드|Guard|Chronicle Guard|Project Chronicle/i.test(prompt);
const mentionsMultiAgent = /\bMA\b|multi[-\s]?agent|멀티\s*에이전트|다중\s*에이전트|Planner|Researcher|Writer/i.test(prompt);
const asksDecision = /(분리|통합|모드|사용|좋을까|맞을까|구조|설계|아키텍처|의견|판단|어때|어떤\s*거?\s*같|separate|combine|mode|architecture|design|opinion)/i.test(prompt);
return asksDecision && mentionsGuard && mentionsMultiAgent;
}
/**
* prepend .
* ( ).
*/
export function buildAstraModeArchitectureContext(prompt: string): string {
if (!isAstraModeArchitectureQuestion(prompt)) {
return '';
}
return [
'[ASTRA MODE ARCHITECTURE DECISION CONTEXT]',
'The user is asking about Astra itself, specifically whether Guard mode and MA/Multi-Agent mode should remain separate.',
'',
'Confirmed implementation facts from the current codebase:',
'- Guard is currently exposed as a sidebar toggle, but it defaults to enabled in the webview UI.',
'- Guard context is built by buildProjectChronicleGuardContext(activeProject) and passed into AgentExecutor as designerContext.',
'- In the normal single-agent path, designerContext is injected into the system prompt as [PROJECT CHRONICLE GUARD].',
'- In the Multi-Agent path, designerContext is appended as Project Chronicle Guard context for the workflow manager.',
'- Multi-Agent is an internal execution strategy. The legacy g1nation.multiAgentEnabled setting can still force it for complex prompts, but Astra may also select it automatically for report/research/strategy style tasks.',
'- Current guardrail: Multi-Agent is not used for local project path preflight or Astra mode-design questions, because those need richer context assembly first.',
'',
'Product decision guidance:',
'- Do not treat Guard and MA as two equal user-facing modes.',
'- Guard should be an always-on policy/context layer: project target, evidence discipline, record hygiene, tone, and decision logging.',
'- MA should be an optional execution strategy chosen automatically for genuinely complex tasks.',
'- Recommended UX: hide or de-emphasize the Guard toggle, show it as Auto/On by default, and let Astra route between single-agent and MA internally.',
'- Recommended answer: give a clear verdict that separating them as peer modes is not ideal; separate them internally by responsibility instead.',
'- Mention the concrete risk that MA can currently bypass richer context assembly, so unifying the context preparation before routing is the next engineering step.',
].join('\n');
}
@@ -0,0 +1,52 @@
import type { ChatMessage } from '../../agent';
/**
* v2.2.69 sliding-window .
*
* LLM heuristic :
* - prompt
* - assistant (conclusion-first R1)
* . "이전에 무슨 얘기를 했는지"
* .
*
* 8
* .
*
* Stateless agent.ts private . instance state
* . + god-file .
*/
export function buildDroppedHistorySummary(dropped: ChatMessage[]): string {
if (dropped.length === 0) return '';
const lines: string[] = [];
let userTurnIdx = 0;
for (const msg of dropped) {
if (msg.internal) continue;
const content = typeof msg.content === 'string' ? msg.content : '';
if (!content.trim()) continue;
if (msg.role === 'user') {
userTurnIdx++;
lines.push(`U${userTurnIdx}: ${firstSentence(content)}`);
} else if (msg.role === 'assistant') {
lines.push(`A${userTurnIdx}: ${firstSentence(content)}`);
}
}
const MAX_LINES = 8;
if (lines.length > MAX_LINES) {
const tail = lines.slice(-MAX_LINES);
const head = lines.slice(0, lines.length - MAX_LINES);
return `[이전 대화 요약 — 총 ${dropped.length}개 메시지가 컨텍스트 한계로 생략됨]\n(더 오래된 ${head.length}개 턴 생략됨)\n${tail.join('\n')}`;
}
return `[이전 대화 요약 — 총 ${dropped.length}개 메시지가 컨텍스트 한계로 생략됨]\n${lines.join('\n')}`;
}
function firstSentence(s: string): string {
const cleaned = String(s || '')
.replace(/^\s{0,3}#{1,6}\s+/gm, '')
.replace(/\*\*/g, '')
.replace(/`{3}[\s\S]*?`{3}/g, '[code]')
.replace(/\s+/g, ' ')
.trim();
const m = cleaned.match(/^[^.!?。\n]{1,140}[.!?。]?/);
const out = (m ? m[0] : cleaned.slice(0, 140)).trim();
return out;
}
+56
View File
@@ -0,0 +1,56 @@
import type { ChatMessage } from '../../agent';
/**
* LLM + variant .
*
* - normalizeMessages content stringify (Ollama Vision images
* ). API plain object array .
* - buildEngineMessageVariants LM Studio system role
* fallback variant
* (native-system flattened-system-fallback). Ollama native 1.
*
* stateless agent.ts private .
*/
export function normalizeMessages(messages: ChatMessage[]) {
return messages.map((message) => {
const normalizedContent = typeof message.content === 'string'
? message.content
: JSON.stringify(message.content);
const result: any = {
role: message.role,
content: normalizedContent,
};
// Ollama Vision: images 필드 보존
if ((message as any).images) {
result.images = (message as any).images;
}
return result;
});
}
export function buildEngineMessageVariants(messages: ChatMessage[], engine: 'lmstudio' | 'ollama') {
const normalized = normalizeMessages(messages);
if (engine !== 'lmstudio') {
return [{ name: 'native', messages: normalized }];
}
// LM Studio system-role bug 우회: system 메시지를 "[System Instruction - do not
// answer this message]\n…" 로 감싸 user role 로 변환. 호출자는 native 가 실패하면
// 이 flattened variant 로 재시도한다.
const flattened = normalized.map((message) => {
if (message.role === 'system') {
return {
role: 'user' as const,
content: `[System Instruction - do not answer this message]\n${message.content}`,
};
}
return message;
});
return [
{ name: 'native-system', messages: normalized },
{ name: 'flattened-system-fallback', messages: flattened },
];
}
@@ -0,0 +1,77 @@
import type { ChatMessage } from '../../agent';
/**
* history . stage:
* 1) sanitizeHistoryAssistantContent */*
* (Second Brain Trace, candidate records ) .
* .
* 2) buildRequestHistory assistant .
*
* stateless agent.ts private . sanitize
* god-file .
*/
export function buildRequestHistory(history: ChatMessage[]): ChatMessage[] {
return history.map((message) => {
if (message.role !== 'assistant' || typeof message.content !== 'string') {
return message;
}
return {
...message,
content: sanitizeHistoryAssistantContent(message.content),
};
});
}
/**
* In-memory chat history :
* 1) `recentFullMessages` * tool-result* (read_file /
* list_files / list_brain / read_brain) `oldToolResultCap`
* truncate .
* 2) `maxRetained` drop.
* system ( / framing ).
*
* cap stateless agent.ts private class
* static .
*
* 주의: history *in-place mutate* (splice + content ).
* . reference
* .
*/
export function capChatHistory(
history: ChatMessage[],
opts: { maxRetained: number; recentFullMessages: number; oldToolResultCap: number },
): void {
if (history.length === 0) return;
const recentStart = Math.max(0, history.length - opts.recentFullMessages);
for (let i = 0; i < recentStart; i++) {
const msg = history[i];
if (msg.role !== 'system' || !msg.internal || typeof msg.content !== 'string') continue;
if (!/^\[Result of (read_file|list_files|list_brain|read_brain)\b/.test(msg.content)) continue;
if (msg.content.length <= opts.oldToolResultCap) continue;
msg.content = msg.content.slice(0, opts.oldToolResultCap)
+ '\n…[이전 도구 결과는 컨텍스트 절약을 위해 축약되었습니다]';
}
if (history.length > opts.maxRetained) {
const first = history[0];
const preserveFirst = first.role === 'system';
const overflow = history.length - opts.maxRetained;
if (preserveFirst) {
history.splice(1, overflow);
} else {
history.splice(0, overflow);
}
}
}
export function sanitizeHistoryAssistantContent(content: string): string {
return content
.replace(/<details>\s*<summary>2nd Brain Trace:[\s\S]*?<\/details>/gi, '')
.replace(/## Second Brain Debug JSON[\s\S]*?(?=\n## |\n# |$)/gi, '')
.replace(/## Candidate records for this discussion[\s\S]*?(?=\n## |\n# |$)/gi, '')
.replace(/## 후보 기록[\s\S]*?(?=\n## |\n# |$)/gi, '')
.replace(/## 프로젝트 기록 검토[\s\S]*?(?=\n## |\n# |$)/gi, '')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
@@ -0,0 +1,59 @@
import { isThinkingPartnerRequest } from './promptDetection';
import { extractEvidenceFilesFromProjectKnowledge, extractPriorityPreviewFiles } from './projectEvidence';
import { buildThinkingPartnerResponseContract } from './thinkingPartnerContract';
/**
* "자비스 프로젝트 브리프" thinking-partner
* * * * *
* prompt prepend .
*
* 우선순위: 직전 localPathContext ( path access ) > project knowledge
* record. "성급한 단정 금지" + thinking-partner contract
* .
*
* Stateless agent.ts private . 의존: thinking-partner
* detection + evidence file ( ) + response contract.
*/
export function buildJarvisProjectBriefContext(
prompt: string,
localPathContext: string,
recentProjectKnowledgeContext: string,
): string {
if (!isThinkingPartnerRequest(prompt)) {
return '';
}
const sourceContext = localPathContext && localPathContext.includes('Access: succeeded')
? localPathContext
: recentProjectKnowledgeContext;
if (!sourceContext) {
return [
'[JARVIS PROJECT BRIEF]',
'No concrete local project brief is available yet.',
'Use the conversation and Second Brain cautiously. If the user asks about a project architecture, ask for or inspect the project path before making strong claims.',
'',
buildThinkingPartnerResponseContract(),
].join('\n');
}
const projectPath = sourceContext.match(/Path:\s*(.+)/)?.[1]?.trim()
|| sourceContext.match(/Repository:\s*`([^`]+)`/)?.[1]?.trim()
|| sourceContext.match(/project evidence:\s*([^\s]+)/i)?.[1]?.trim()
|| 'current project';
const evidenceFiles = sourceContext.includes('Priority file previews:')
? extractPriorityPreviewFiles(sourceContext).slice(0, 10)
: extractEvidenceFilesFromProjectKnowledge(sourceContext).slice(0, 10);
const treeMatch = sourceContext.match(/Scanned tree:\n([\s\S]*?)(?:\nPriority file previews:|$)/);
const treePreview = treeMatch?.[1]?.trim().split('\n').slice(0, 30).join('\n') || '';
return [
'[JARVIS PROJECT BRIEF]',
`Project evidence target: ${projectPath}`,
evidenceFiles.length
? `Evidence files available:\n${evidenceFiles.map((file) => `- ${file}`).join('\n')}`
: 'Evidence files available: not enough concrete file markers were found.',
treePreview ? `Visible structure preview:\n${treePreview}` : '',
'',
buildThinkingPartnerResponseContract(),
].filter(Boolean).join('\n');
}
+19
View File
@@ -0,0 +1,19 @@
import type { ChatMessage } from '../../agent';
/**
* v2.2.69 chatHistory user
* bridge "이전 맥락" .
*
* . 120 cap (bridge ).
*
* agent.ts private `this.chatHistory`
* stateful , history arg stateless .
* history .
*/
export function buildLastTopicLine(history: ChatMessage[]): string {
const recent = history.filter(m => !m.internal && (m.role === 'user' || m.role === 'assistant'));
if (recent.length === 0) return '';
const lastUser = [...recent].reverse().find(m => m.role === 'user');
if (!lastUser || typeof lastUser.content !== 'string') return '';
return lastUser.content.replace(/\s+/g, ' ').trim().slice(0, 120);
}
@@ -0,0 +1,29 @@
import { getConfig } from '../../config';
import type { LmStudioSampling } from '../../lmstudio/streamer';
/**
* LM Studio config sampling .
* SDK streamer `respond()` (topPSampling/topKSampling/) REST body
* (top_p/top_k/) , path
* . Ollama `options` .
*
* 코드: agent.ts private 5 + 7 . stateless config
* extract + config shape .
*/
/** SDK / REST 양쪽이 공통으로 쓰는 sampling block. */
export function lmStudioSamplingFromConfig(): LmStudioSampling {
const c = getConfig();
return {
topP: c.lmStudioTopP,
topK: c.lmStudioTopK,
minP: c.lmStudioMinP,
repeatPenalty: c.lmStudioRepeatPenalty,
};
}
/** SDK `respond()` 전용 extras — 현재는 speculative decoding 의 draft model 뿐. */
export function lmStudioRespondExtrasFromConfig(): { draftModel?: string } {
const c = getConfig();
return c.lmStudioDraftModel ? { draftModel: c.lmStudioDraftModel } : {};
}
@@ -0,0 +1,233 @@
import { isThinkingPartnerRequest } from './promptDetection';
/**
* * * + *tone-shaping* . agent.ts
* 6 private + 2 static regex .
*
* :
* containsLocalFilePath
* classifyLocalProjectIntent {isProjectKnowledge..,
* isProjectReview..,
* buildAstraStanceContext}
* shouldPreflightLocalProjectPath
*
* buildLocalProjectIntentGuidance , intent
*
* stateless god-file + +
* .
*
* lastIndex pollution :
* /g/ flag RegExp `.test()` . /g/
* lastIndex .test() false
* .
*/
export type LocalProjectIntent =
| 'review-evaluation'
| 'knowledge-creation'
| 'implementation'
| 'documentation'
| 'thinking'
| 'general';
// POSIX: /Volumes/, /Users/, /home/, /opt/, ... or ~/ — backtick 제외 (markdown code spans).
// Source string 도 export — 호출자가 /g/ flag 가 필요한 matchAll 패턴에 새 RegExp 인스턴스를
// 만들 수 있어야 함 (lastIndex pollution 방지 위해 인스턴스 공유 안 함).
export const POSIX_ABS_PATH_SRC = "(?:\\/(?:Volumes|Users|home|opt|srv|mnt|data|workspace)\\/|~\\/)[^\\s`\"'<>|*?]+";
// Windows: drive letter (C:\ or C:/) or UNC (\\server\share). Backslash 가 separator 로 허용됨.
export const WIN_ABS_PATH_SRC = "(?:[A-Za-z]:[\\\\/]|\\\\\\\\[^\\s\\\\/]+\\\\[^\\s\\\\/]+)[^\\s`\"'<>|*?]*";
export const ABS_PATH_RE = new RegExp(POSIX_ABS_PATH_SRC, 'i');
export const WIN_ABS_PATH_RE = new RegExp(WIN_ABS_PATH_SRC, 'i');
/**
* Prompt / . (POSIX + Windows
* + UNC) (`src/...`, `lib/...` + ) .
*/
export function containsLocalFilePath(prompt: string): boolean {
if (ABS_PATH_RE.test(prompt) || WIN_ABS_PATH_RE.test(prompt)) {
return true;
}
if (/(?:^|[\s,])(?:src|lib|components|pages|app|tests|test|utils|core|features|hooks|services|config|public|assets|docs|scripts)[\\/]/i.test(prompt)
&& /\.[a-z]{1,6}(?:[\s,;)\]]|$)/i.test(prompt)) {
return true;
}
return false;
}
/**
* prompt + * * .
* preflight ( scan + ) .
* scan skip.
*/
export function shouldPreflightLocalProjectPath(prompt: string): boolean {
const hasActionKeyword = /(검토|리뷰|분석|확인|봐줘|읽어|열어|파일|내용|코드|고쳐|개선|디버그|지식|문서화|문서|정리|기록|위키|저장|만들|생성|설계|아키텍처|구조|방향|의견|생각|판단|어떤\s*거?\s*같|어때|순서대로|보면|knowledge|document|documentation|wiki|summari[sz]e|review|analy[sz]e|inspect|debug|fix|improve|architecture|design|structure|opinion|think|judge|read|open|file|content|code)/i.test(prompt);
const hasLocalPath = containsLocalFilePath(prompt);
return hasActionKeyword && hasLocalPath;
}
/**
* Intent preflight tone / prompt
* . 'general' .
*
* 우선순위: review > implementation > knowledge-creation > documentation
* > thinking > general. * *
* .
*/
export function classifyLocalProjectIntent(prompt: string): LocalProjectIntent {
if (!containsLocalFilePath(prompt)) {
return 'general';
}
const normalized = prompt.replace(/\s+/g, ' ').trim();
const asksReview = /(코드\s*리뷰|코드리뷰|리뷰|검토|평가|봐줘|장점|단점|약점|강점|확장성|문제점|리스크|개선점|의견|판단|괜찮|어때|어떤\s*거?\s*같|review|evaluate|assessment|strength|weakness|pros?\s*and\s*cons?|extensibility|scalability|risk|issue)/i.test(normalized);
if (asksReview) {
return 'review-evaluation';
}
const asksImplementation = /(고쳐|수정|개선해|구현|추가|삭제|리팩토링|디버그|fix|implement|add|remove|refactor|debug)/i.test(normalized);
if (asksImplementation) {
return 'implementation';
}
const explicitKnowledgeCreation = /((?:이|그|현재|해당)?\s*(?:프로젝트|프로그램|코드베이스).{0,20}(?:대한|기반|관련).{0,20}지식.{0,12}(?:만들|생성|정리|문서화|기록|저장))|(지식.{0,12}(?:만들|생성|정리|문서화|기록|저장).{0,20}(?:프로젝트|프로그램|코드베이스))|(project\s+knowledge.{0,20}(?:create|generate|record|document|overview))|((?:create|generate|record|document).{0,20}project\s+knowledge)/i.test(normalized);
if (explicitKnowledgeCreation) {
return 'knowledge-creation';
}
const asksDocumentation = /(문서화(?:해|해줘|를)|문서(?:로)?\s*(?:정리|작성|만들)|README|가이드|wiki|documentation|document\s+this|write\s+docs)/i.test(normalized);
if (asksDocumentation) {
return 'documentation';
}
const asksThinking = /(설계|아키텍처|구조|방향|생각|의견|판단|어떤\s*거?\s*같|어때|architecture|design|structure|direction|opinion|think|judge)/i.test(normalized);
if (asksThinking) {
return 'thinking';
}
return 'general';
}
export function isProjectKnowledgeCreationRequest(prompt: string): boolean {
return classifyLocalProjectIntent(prompt) === 'knowledge-creation';
}
export function isProjectReviewEvaluationRequest(prompt: string): boolean {
return classifyLocalProjectIntent(prompt) === 'review-evaluation';
}
/**
* Intent contract .
* `review-evaluation` strict ( template
* ), intent contract.
*/
export function buildLocalProjectIntentGuidance(intent: LocalProjectIntent): string {
switch (intent) {
case 'review-evaluation':
return [
'Intent operating contract — Code Review:',
'The user wants a real review, not a meta-plan of how to review.',
'OUTPUT FORMAT: PLAIN TEXT only. Section labels are bare words on their own line (no "#", "##", "**", "__", "> "). Bullets use "- ". Long answers MUST start with a "핵심 요약" block (2~4 bullets) before any detail.',
'Required sections in this exact order, in Korean (each label appears as a plain line, NOT a markdown heading):',
' 1) 한 줄 판단 — one sentence: would you rely on this today, and under what constraint?',
' 2) 잘된 점 — 2~4 concrete strengths. Each MUST cite a specific file path (and a function or section if you can name one) and explain WHY it works, not just that it exists.',
' 3) 부족한 점 — 2~4 concrete weaknesses or risks. Same rule: cite a specific file/area, name the actual problem (race condition, missing retry, coupling, etc.), and say what breaks because of it.',
' 4) 사용자 관점 개선 — 2~4 changes phrased from the END USER\'s perspective ("when X happens, the user currently sees Y; they should see Z"). Tie each to a code location that needs to change.',
' 5) 다음 한 수 — exactly one next action, small enough to do this week.',
'',
'Hard rules — these are the things that made past reviews feel like a template:',
'- Do NOT write meta-sentences like "확인해야 합니다", "다음 리뷰에서는 ~를 보면 됩니다", "~로 보입니다", "~인지 확인하는 것이 핵심입니다". Either you observed it or you read the file with <read_file> right now.',
'- Do NOT list the file structure tree back to the user — they already see it. Reference files only when making a specific claim.',
'- Do NOT use the words "blind spot", "파이프라인 안정화", "골격은 있습니다" — these are tells of the old canned response.',
'- If a file preview is insufficient to support a claim, USE <read_file path="..."> immediately to read it before writing the section. Do not hedge with "preview만으로는 판단할 수 없습니다".',
'- Strengths and weaknesses must be SPECIFIC to this project. A sentence that would still be true if you swapped the project name is not allowed.',
'- Skip every section that has nothing concrete to say. Better to write 잘된 점 with 2 strong items than 4 weak ones.',
].join('\n');
case 'knowledge-creation':
return [
'Intent operating contract:',
'- Create a reusable project knowledge note from inspected evidence.',
'- Do not ask for scope if the path is accessible; choose a small MVP overview by default.',
'- Separate confirmed structure from inferred purpose and next deep-dive targets.',
].join('\n');
case 'implementation':
return [
'Intent operating contract:',
'- Treat this as a change request, not advice.',
'- Inspect the relevant files, make the smallest safe implementation, and verify it.',
'- Preserve unrelated user changes.',
].join('\n');
case 'documentation':
return [
'Intent operating contract:',
'- Produce or update documentation from inspected evidence.',
'- Separate user-facing usage docs from internal architecture notes.',
'- Avoid claiming behavior that is not visible in code or existing docs.',
].join('\n');
case 'thinking':
return [
'Intent operating contract:',
'- Act as a thinking partner.',
'- Give a direct opinion, then split confirmed facts, inferences, risks, decision forks, and one next move.',
'- Avoid generic encouragement.',
].join('\n');
default:
return [
'Intent operating contract:',
'- Use the inspected local files as grounding.',
'- If the user request is ambiguous, answer the most likely project-oriented task and state the assumption.',
].join('\n');
}
}
/**
* "Astra Stance Layer" template-style * *
* · prepend. intent review/thinking
* opinionated, light persona.
*/
export function buildAstraStanceContext(prompt: string, localPathContext: string): string {
const intent = localPathContext ? classifyLocalProjectIntent(prompt) : 'general';
const wantsThinkingPartner = isThinkingPartnerRequest(prompt) || intent === 'review-evaluation' || intent === 'thinking';
const lines = [
'[ASTRA STANCE LAYER]',
'Use this to make the response feel like Astra thinking with the user, not a template being filled.',
'',
'Voice:',
'- Warm, direct, and grounded. Do not over-explain the framework.',
'- Prefer sentences that sound like a senior collaborator: "나는 여기서 X를 먼저 볼 것 같아요" / "이건 좋아요, 그런데 위험은 Y예요."',
'- Avoid sterile balance like "장단점이 있습니다" unless you immediately make a call.',
'',
'Judgment habits:',
'- State the real bet you think the user is making.',
'- Name one thing to keep, one thing to cut, and one thing to verify next when relevant.',
'- Use the users own goal as the yardstick, not generic best practice.',
'- If there are many possible improvements, choose the one that compounds the project fastest.',
'',
wantsThinkingPartner
? 'For this request, be especially opinionated. Give a clear personal verdict before structure.'
: 'For this request, keep the persona light but still make concrete choices.',
intent !== 'general' ? `Local project intent for tone: ${intent}` : '',
];
if (intent === 'review-evaluation') {
lines.push(
'',
'Review stance:',
'- Do not merely list strengths and weaknesses. Say whether you would rely on this project today and under what constraint.',
'- Prefer the product-owner question: "What has to become boring and reliable before this deserves expansion?"',
'- If evidence is shallow, say which file would change your opinion most.',
);
}
if (intent === 'thinking') {
lines.push(
'',
'Thinking stance:',
'- Do not solve every branch. Reduce the users uncertainty to the next decision.',
'- A useful answer may say: "I would not expand yet" or "This deserves a spike, not a feature."',
);
}
return lines.filter(Boolean).join('\n');
}
+328
View File
@@ -0,0 +1,328 @@
import * as fs from 'fs';
import * as path from 'path';
import { summarizeText } from '../../utils';
import { EXCLUDED_DIRS } from '../../config';
import { validatePath } from '../../security';
import {
POSIX_ABS_PATH_SRC,
WIN_ABS_PATH_SRC,
shouldPreflightLocalProjectPath,
classifyLocalProjectIntent,
buildLocalProjectIntentGuidance,
} from './localProjectIntent';
/**
* "로컬 프로젝트 경로 preflight" prompt path
* scan / system prompt prepend.
*
* :
* 1) extractLocalProjectPaths prompt ( + )
* 2) inspectLocalProjectPath fs tree + priority preview
* - listProjectTree depth/limit
* - findPriorityProjectFiles package.json/README/src
* 3) buildLocalProjectPathContext + intent guidance + critical directives
* 4) enforceLocalPathReviewAnswer "코드를 제공해주세요"
* "스스로 read_file 하겠다"
*
* Why one module: 6개 import .
* stateless agent.ts private .
*
* Notes:
* - extractLocalProjectPaths /g/ flag RegExp
* (lastIndex pollution ). source string import.
* - inspectLocalProjectPath / 8000, 2000 preview.
* prompt token + .
*/
/** 사용자 prompt 에서 로컬 경로 후보들을 추출 (절대 경로 + 흔한 상대 경로). */
export function extractLocalProjectPaths(prompt: string, rootPath?: string): string[] {
const results: string[] = [];
const stripTrailingPunct = (s: string) => s.replace(/[),.;\]]+$/g, '');
// 1a. POSIX 절대 경로
const absMatches = prompt.match(new RegExp(POSIX_ABS_PATH_SRC, 'gi')) || [];
for (const m of absMatches) {
results.push(stripTrailingPunct(m));
}
// 1b. Windows 절대 경로
const winMatches = prompt.match(new RegExp(WIN_ABS_PATH_SRC, 'gi')) || [];
for (const m of winMatches) {
results.push(stripTrailingPunct(m));
}
// 2. 상대 경로 감지: src/lib/engine.ts, components\App.tsx 등
const relMatches = prompt.match(/(?:^|[\s,])(?:(?:src|lib|components|pages|app|tests|test|utils|core|features|hooks|services|config|public|assets|docs|scripts)[\\/][^\s`"'<>]+\.[a-z]{1,6})/gi) || [];
for (const m of relMatches) {
const cleaned = m.trim().replace(/^,\s*/, '').replace(/[),.;\]]+$/g, '');
if (rootPath) {
const absPath = path.resolve(rootPath, cleaned);
if (fs.existsSync(absPath)) {
results.push(absPath);
} else {
// 프로젝트 루트 하위 sub-project 들에서도 검색
const subProjects = ['ConnectAI', 'Datacollector_MAC', 'Agent', 'skybound'];
let found = false;
for (const sub of subProjects) {
const subPath = path.resolve(rootPath, sub, cleaned);
if (fs.existsSync(subPath)) {
results.push(subPath);
found = true;
break;
}
}
if (!found) {
results.push(absPath); // fallback: 원래 경로 그대로
}
}
} else {
results.push(cleaned);
}
}
return Array.from(new Set(results));
}
/**
* Depth/limit . EXCLUDED_DIRS hidden file . recursive
* lines.length cap .
*/
export function listProjectTree(root: string, current: string, depth: number, maxDepth: number, limit: number): string {
if (limit <= 0 || depth > maxDepth) {
return '';
}
let entries: fs.Dirent[] = [];
try {
entries = fs.readdirSync(current, { withFileTypes: true })
.filter((entry) => !entry.name.startsWith('.') && !EXCLUDED_DIRS.has(entry.name))
.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
} catch {
return '';
}
const lines: string[] = [];
for (const entry of entries) {
if (lines.length >= limit) break;
const fullPath = path.join(current, entry.name);
const relative = path.relative(root, fullPath);
lines.push(`${' '.repeat(depth)}${relative}${entry.isDirectory() ? '/' : ''}`);
if (entry.isDirectory() && depth < maxDepth) {
const child = listProjectTree(root, fullPath, depth + 1, maxDepth, limit - lines.length);
if (child) {
lines.push(child);
}
}
}
return lines.join('\n');
}
/**
* Priority (package.json, README, tsconfig, src/**, docs/**, config )
* . visit source area dfs src/
* / , config .
*/
export function findPriorityProjectFiles(root: string): string[] {
const exactNames = new Set([
'package.json',
'README.md',
'readme.md',
'tsconfig.json',
'vite.config.ts',
'vite.config.js',
'next.config.js',
'next.config.mjs',
'webpack.config.js',
]);
const results: string[] = [];
const visit = (dir: string, depth: number, inSourceArea: boolean) => {
if (depth > 6 || results.length >= 24) return;
let entries: fs.Dirent[] = [];
try {
entries = fs.readdirSync(dir, { withFileTypes: true })
.filter((entry) => !entry.name.startsWith('.') && !EXCLUDED_DIRS.has(entry.name));
} catch {
return;
}
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const nextInSourceArea = inSourceArea || /^(src|app|pages|components|docs|lib|server|backend|frontend|config|features|core|hooks|systems|store|model|utils|ui|api)$/i.test(entry.name);
if (nextInSourceArea) {
visit(fullPath, depth + 1, nextInSourceArea);
}
continue;
}
const relative = path.relative(root, fullPath);
const isSourceCode = /\.(ts|tsx|js|jsx)$/i.test(entry.name);
if (
exactNames.has(entry.name)
|| (inSourceArea && isSourceCode)
|| /(^|[\\/])(src|app|pages|components|docs|lib|server|backend|frontend|features|core)[\\/].+\.(ts|tsx|js|jsx|md|json)$/i.test(relative)
|| /\.(config|rc)\.(js|ts|json)$/i.test(entry.name)
) {
results.push(fullPath);
}
}
};
visit(root, 0, false);
return Array.from(new Set(results)).sort((a, b) => {
const rank = (file: string) => {
const relative = path.relative(root, file);
if (path.basename(file) === 'package.json') return 0;
if (/readme\.md$/i.test(file)) return 1;
if (/^src[\\/]App\.tsx$/i.test(relative)) return 2;
if (/^src[\\/]main\.tsx$/i.test(relative)) return 3;
if (/^src[\\/]features[\\/]game[\\/]hooks[\\/]useGameEngine\.ts$/i.test(relative)) return 4;
if (/^src[\\/]features[\\/]game[\\/]systems[\\/]/i.test(relative)) return 5;
if (/^src[\\/]features[\\/]game[\\/]ui[\\/]/i.test(relative)) return 6;
if (/^src[\\/]/i.test(relative)) return 7;
if (/^docs[\\/]|\.md$/i.test(relative)) return 8;
return 9;
};
return rank(a) - rank(b) || a.localeCompare(b);
});
}
/**
* inspect tree + priority preview, preview.
* / 8000, 2000 cap (token ).
*/
export function inspectLocalProjectPath(targetPath: string, rootPath: string): string {
try {
const absPath = validatePath(rootPath, targetPath);
if (!fs.existsSync(absPath)) {
return [
`Path: ${targetPath}`,
'Access: failed',
'Reason: path does not exist in the current environment.',
].join('\n');
}
const stat = fs.statSync(absPath);
if (!stat.isDirectory()) {
const content = fs.readFileSync(absPath, 'utf8');
const fileName = path.basename(absPath);
const ext = path.extname(absPath).toLowerCase();
const isCodeOrDoc = ['.ts', '.js', '.tsx', '.jsx', '.py', '.java', '.go', '.rs', '.md', '.json', '.yaml', '.yml', '.toml', '.css', '.html', '.sql', '.sh', '.zsh', '.env', '.xml', '.swift', '.kt'].includes(ext);
const previewLimit = isCodeOrDoc ? 8000 : 2000;
return [
`Path: ${targetPath}`,
'Access: succeeded',
`Type: file (${fileName})`,
`Size: ${content.length} characters`,
`Full content (${content.length <= previewLimit ? 'complete' : `first ${previewLimit} chars`}):\n\`\`\`${ext.slice(1)}\n${summarizeText(content, previewLimit)}\n\`\`\``,
].join('\n');
}
const tree = listProjectTree(absPath, absPath, 0, 4, 140);
const priorityFiles = findPriorityProjectFiles(absPath).slice(0, 12);
const previews = priorityFiles.map((file) => {
try {
const content = fs.readFileSync(file, 'utf8');
return [
`File: ${path.relative(absPath, file)}`,
summarizeText(content, 2200),
].join('\n');
} catch (error: any) {
return `File: ${path.relative(absPath, file)}\nRead failed: ${error.message}`;
}
}).join('\n\n');
return [
`Path: ${targetPath}`,
'Access: succeeded',
'Type: directory',
`Scanned tree:\n${tree || '(no visible files found)'}`,
priorityFiles.length > 0
? `Priority file previews:\n${previews}`
: 'Priority file previews: no package, README, docs, src, or config files found in the first scan.',
].join('\n');
} catch (error: any) {
return [
`Path: ${targetPath}`,
'Access: failed',
`Reason: ${error.message}`,
].join('\n');
}
}
/**
* Preflight system prompt prepend . preflight
* skip . 5 inspect (token cap).
*/
export function buildLocalProjectPathContext(prompt: string, rootPath: string): string {
if (!shouldPreflightLocalProjectPath(prompt)) {
return '';
}
const candidates = extractLocalProjectPaths(prompt, rootPath);
if (candidates.length === 0) {
return '';
}
const intent = classifyLocalProjectIntent(prompt);
const sections: string[] = [
'[LOCAL PROJECT PATH PREFLIGHT]',
`Local project intent: ${intent}`,
buildLocalProjectIntentGuidance(intent),
'[CRITICAL DIRECTIVE] The file structure and snippets below are an INITIAL scan from the local filesystem.',
'If you need to see the full content of any file or explore other directories to perform the analysis, you MUST use the <read_file path="..."> or <list_files path="..."> action tags immediately in your response.',
'DO NOT ask the user to provide, upload, paste, or share the file contents. DO NOT ask for permission to read them. Just use the action tags to read them yourself.',
'DO NOT say "파일 내용을 보여주세요", "코드를 공유해 주세요", or "파일을 제공해 주세요".',
'Proceed IMMEDIATELY with analysis or with using action tags to gather more context. Do not ask for confirmation like "진행할까요?" or "분석을 시작할까요?". Just do it.',
'If multiple files are mentioned, analyze them sequentially in the order the user specified without pausing for confirmation between each.',
'The user provided a local project path for review, analysis, documentation, or knowledge creation. Use this inspected context, and if needed use <read_file> to dig deeper before answering.',
'If access failed, explain the concrete failure.',
'If access succeeded and priority file previews are present, do not say that code was not provided.',
'Treat the Local project intent line as the routing decision for this response.',
'If intent is review-evaluation, do not create a project knowledge note. Review the inspected project as the primary task: strengths, weaknesses, risks, and extensibility.',
'If intent is knowledge-creation, answer that the project can be summarized from the inspected local path and propose or execute a project knowledge note based on the previews.',
'If intent is thinking, act as a project thinking partner and give a clear verdict grounded in the inspected files.',
];
for (const candidate of candidates.slice(0, 5)) {
sections.push(inspectLocalProjectPath(candidate, rootPath));
}
return sections.join('\n');
}
/**
* "코드를 제공해주세요 / 업로드해주세요" ,
* "스스로 read_file 하겠다" . localPathContext
* access noop ( ).
*/
export function enforceLocalPathReviewAnswer(content: string, localPathContext: string): string {
if (!localPathContext.includes('Access: succeeded')) {
return content;
}
const asksForUpload = /(코드(?:를|가)?\s*업로드|파일(?:을|를)?\s*업로드|소스\s*코드(?:를)?\s*업로드|코드를 제공|파일을 제공|핵심 파일(?:이나|과|을|를)?.*제공|파일 목록(?:이나|과|을|를)?.*제공|구조(?:를|와|나)?.*제공|자료(?:를|가)?.*필요|folder path is not enough|upload (?:the )?(?:source )?code|please provide (?:the )?files|먼저 분석할까요|살펴볼까요)/i.test(content);
const deniesCodeAccess = /(실제 코드 내용이 없|코드 내용이 없|코드가 없|코드를 볼 수 없|소스 코드를 볼 수 없|실제 구현 자료가 없|실제 구현 근거 없이는|현재로서는.*자료가 없|기술적인 진단.*수 없습니다|코드를 읽어야만|파일 구조만으로는.*판단할 수 없|코드의 논리적 흐름.*판단할 수 없)/i.test(content);
if (!asksForUpload && !deniesCodeAccess) {
return content;
}
const header = [
'## 경로 확인 결과',
'',
'제공된 로컬 프로젝트 경로에는 접근할 수 있고, 코드 파일도 일부 확인되었습니다. 만약 추가적인 코드 확인이 필요하다면 <read_file> 이나 <list_files> 액션 태그를 즉시 사용하여 스스로 파일을 읽어보고 분석을 진행하겠습니다.',
'',
'이전 응답에서 "파일을 제공해주세요" 라거나 "먼저 분석할까요?" 라고 묻는 것은 잘못된 안내입니다. 액션 태그를 통해 스스로 필요한 코드를 열어보겠습니다.',
].join('\n');
return [
header,
'',
content
.replace(/.*(?:코드(?:를|가)?\s*업로드|파일(?:을|를)?\s*업로드|소스\s*코드(?:를)?\s*업로드|코드를 제공|파일을 제공).*$/gmi, '')
.replace(/.*(?:핵심 파일(?:이나|과|을|를)?.*제공|파일 목록(?:이나|과|을|를)?.*제공|구조(?:를|와|나)?.*제공|자료(?:를|가)?.*필요).*$/gmi, '')
.replace(/.*(?:실제 코드 내용이 없|코드 내용이 없|코드가 없|코드를 볼 수 없|소스 코드를 볼 수 없|실제 구현 자료가 없|실제 구현 근거 없이는|현재로서는.*자료가 없|코드를 읽어야만|파일 구조만으로는.*판단할 수 없).*$/gmi, '')
.replace(/.*(?:먼저 분석할까요|살펴볼까요).*$/gmi, '')
.trim(),
].filter(Boolean).join('\n\n');
}
+233
View File
@@ -0,0 +1,233 @@
import * as path from 'path';
import * as vscode from 'vscode';
import type { ChatMessage } from '../../agent';
import type { BrainProfile } from '../../config';
import { getConfig } from '../../config';
import type { MemoryManager } from '../../memory';
import type { RetrievalOrchestrator } from '../../retrieval';
import { buildLessonChecklistBlock } from '../../retrieval/lessonHelpers';
import { embedQuery, embedTexts } from '../../retrieval/embeddings';
import { backfillBrainEmbeddings } from '../../retrieval/brainIndex';
import { resolveScopeForAgent } from '../../skills/agentKnowledgeMap';
import {
resolveKnowledgeMix,
mapWeightToBrainFileLimit,
mapWeightToRetrievalRatio,
ResolvedKnowledgeMix,
} from '../../retrieval/knowledgeMix';
/**
* turn RAG / 5-layer memory .
*
* 코드: agent.ts 130 private `buildMemoryContext`. state 6
* (memoryManager, chatHistory, retrievalOrchestrator, context, currentTaskId,
* _turnCtx) god-file .
*
* 방식: 호출자(provider) deps struct * orchestration*
* . RetrievalOrchestrator / MemoryManager (
* * * ). :
* 1) `deps.turnCtx` mutation webview footer retrieval/lessons/knowledgeMix.
* 2) `backfillBrainEmbeddings` fire-and-forget turn score .
*
* 의도: agent.ts 130 RAG .
* Provider deps .
*/
/** TurnContext 의 retrieval 슬롯 모양. provider 의 `_turnCtx.retrieval` 와 일치해야 함. */
export interface TurnRetrievalSummary {
agentName: string | null;
scoped: boolean;
source: string;
configuredFolders: string[];
usedBrainFiles: string[];
usedMemoryLayers: string[];
lessonFiles: string[];
totalChunks: number;
selectedChunks: number;
}
/**
* Mutable turn-context sink `_turnCtx`
* . `reset` .
*/
export interface TurnContextSink {
retrieval: TurnRetrievalSummary | null;
lessons: string[];
knowledgeMix: ResolvedKnowledgeMix | null;
}
export interface MemoryContextDeps {
currentPrompt: string;
activeBrain: BrainProfile;
agentSkillFile?: string;
/** Visible + internal 합친 raw chat history. 함수 안에서 internal 필터링. */
chatHistory: ChatMessage[];
memoryManager: MemoryManager;
retrievalOrchestrator: RetrievalOrchestrator;
/** vscode ExtensionContext — chat_sessions globalState 읽기에 사용. */
context: vscode.ExtensionContext;
/** 현재 turn 의 session id — recentSessions 에서 자기 자신 제외. */
currentTaskId: string;
/** 함수가 채울 turn-context sink. 호출자는 호출 전에 비워둬야 한다. */
turnCtx: TurnContextSink;
}
/**
* chat_sessions medium-term compact helper.
* , history , /
* orchestrator .
*/
function compactRecentSessions(
rawSessions: any[],
activeSessionId: string | null,
limit: number,
): Array<{ id: string; title: string; firstUserMsg: string; lastAssistantExcerpt: string; summary?: string; timestamp: number }> {
if (!Array.isArray(rawSessions) || rawSessions.length === 0 || limit <= 0) return [];
const pool = rawSessions.length > limit + 5 ? limit + 5 : rawSessions.length;
const out: Array<{ id: string; title: string; firstUserMsg: string; lastAssistantExcerpt: string; summary?: string; timestamp: number }> = [];
for (let i = 0; i < rawSessions.length && out.length < pool; i++) {
const s = rawSessions[i];
if (!s || typeof s !== 'object') continue;
const id = String(s.id ?? '');
if (!id || id === activeSessionId) continue;
const history: any[] = Array.isArray(s.history) ? s.history : [];
if (history.length === 0) continue;
const firstUser = history.find((m) => m?.role === 'user');
const lastAssistant = [...history].reverse().find((m) => m?.role === 'assistant');
const firstUserMsg = String(firstUser?.content ?? '').replace(/\s+/g, ' ').trim().slice(0, 200);
const lastTxt = String(lastAssistant?.content ?? '').replace(/\s+/g, ' ').trim();
const lastAssistantExcerpt = lastTxt.length <= 200 ? lastTxt : lastTxt.slice(-200);
const summary = typeof s.summary === 'string' ? s.summary.trim().slice(0, 600) : undefined;
if (!firstUserMsg && !lastAssistantExcerpt && !summary) continue;
out.push({
id,
title: String(s.title ?? '').trim() || firstUserMsg.slice(0, 50),
firstUserMsg,
lastAssistantExcerpt,
summary,
timestamp: typeof s.timestamp === 'number' ? s.timestamp : 0,
});
}
return out;
}
export async function buildMemoryContext(deps: MemoryContextDeps): Promise<string> {
const config = getConfig();
if (!config.memoryEnabled) return '';
// Settings 가 turn 사이에 바뀔 수 있으니 매번 동기화.
deps.memoryManager.updateConfig({
enabled: config.memoryEnabled,
shortTermLimit: config.memoryShortTermMessages,
});
const visibleHistory = deps.chatHistory.filter((message) => !message.internal);
const workspaceFolders = vscode.workspace.workspaceFolders;
const workspacePath = workspaceFolders ? workspaceFolders[0].uri.fsPath : undefined;
// Agent ↔ knowledge map. 매핑 없으면 folders=[] → orchestrator 가 whole-brain 사용 (legacy).
const scope = resolveScopeForAgent(deps.agentSkillFile, deps.activeBrain.localBrainPath);
// Context 윈도우 비례 retrieval 예산. 32K → 8K, 230K → 57K, 80K cap (scoring 속도).
const scaledTotalBudget = Math.min(
80000,
Math.max(8000, Math.floor(config.contextLength * 0.25)),
);
// medium-term layer 용 옛 세션 후보. sidebar 가 직접 쓰는 key 를 read-through.
const rawSessions = deps.context.globalState.get<any[]>('chat_sessions', []) || [];
const recentSessions = compactRecentSessions(
rawSessions,
deps.currentTaskId,
Math.max(0, config.memoryMediumTermSessions ?? 0),
);
// Hybrid retrieval (옵션): embedding model 있으면 query embedding 가져와 cosine
// + TF-IDF blend. timeout 4초 — endpoint 가 느리면 그냥 pure TF-IDF 로 진행.
let queryEmbedding: number[] | undefined;
if (config.embeddingModel) {
const EMBED_QUERY_TIMEOUT_MS = 4000;
try {
queryEmbedding = await Promise.race([
embedQuery(deps.currentPrompt, { baseUrl: config.ollamaUrl, model: config.embeddingModel }),
new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), EMBED_QUERY_TIMEOUT_MS)),
]);
} catch {
queryEmbedding = undefined;
}
}
// Knowledge Mix 가중치 (per-agent → global → default). weight=50 이면 legacy 기본값과 동일.
const knowledgeMix = resolveKnowledgeMix(deps.agentSkillFile);
deps.turnCtx.knowledgeMix = knowledgeMix;
const mixedBrainFileLimit = mapWeightToBrainFileLimit(knowledgeMix.weight, config.memoryLongTermFiles);
const mixedRetrievalRatio = mapWeightToRetrievalRatio(knowledgeMix.weight);
// Unified RAG Pipeline 호출.
const result = deps.retrievalOrchestrator.retrieve(deps.currentPrompt, {
brain: deps.activeBrain,
memoryManager: deps.memoryManager,
workspacePath,
chatHistory: visibleHistory,
contextBudget: {
totalBudget: scaledTotalBudget,
retrievalRatio: mixedRetrievalRatio,
},
brainFileLimit: mixedBrainFileLimit,
scopeFolders: scope.folders,
recentSessions,
mediumTermLimit: config.memoryMediumTermSessions ?? 0,
queryEmbedding,
embeddingModel: config.embeddingModel || undefined,
embeddingBlendAlpha: config.embeddingBlendAlpha,
});
// Fire-and-forget background embedding. Vector 없는 파일만 embed 하므로
// steady-state turn 은 작업량 0 — 다음 turn 이 혜택.
if (config.embeddingModel) {
const scoredFilePaths = result.selectedChunks
.filter((c) => c.source === 'brain-memory' && c.metadata.filePath)
.map((c) => c.metadata.filePath!)
.filter((p, i, arr) => arr.indexOf(p) === i);
if (scoredFilePaths.length > 0) {
void backfillBrainEmbeddings(
deps.activeBrain.localBrainPath,
scoredFilePaths,
config.embeddingModel,
(texts) => embedTexts(texts, { baseUrl: config.ollamaUrl, model: config.embeddingModel }),
);
}
}
// webview "scope used" footer 가 읽는 turn-context summary. brain-trace 는
// 검색이 아니라 trace 표시용이라 usedMemoryLayers 에서 제외 (brain-memory 도 제외 —
// 별도 usedBrainFiles 로 표시).
const brainRoot = deps.activeBrain.localBrainPath;
const rel = (p?: string) => (p ? (path.relative(brainRoot, p) || p) : '');
const lessonChunks = result.lessonChunks || [];
deps.turnCtx.retrieval = {
agentName: scope.agent?.name ?? null,
scoped: scope.folders.length > 0,
source: String((scope as any).source ?? ''),
configuredFolders: scope.folders.map((abs) => rel(abs)),
usedBrainFiles: result.selectedChunks
.filter((c) => c.source === 'brain-memory' && c.metadata.filePath)
.map((c) => rel(c.metadata.filePath))
.filter((p, i, arr) => p && arr.indexOf(p) === i),
usedMemoryLayers: Array.from(new Set(
result.selectedChunks
.filter((c) => c.source !== 'brain-memory' && c.source !== 'brain-trace')
.map((c) => c.source as string),
)),
lessonFiles: lessonChunks.map((c) => rel(c.metadata.filePath)).filter((p, i, arr) => p && arr.indexOf(p) === i),
totalChunks: result.totalChunks,
selectedChunks: result.selectedChunks.length,
};
deps.turnCtx.lessons = lessonChunks.map((c) => c.content);
// Lessons 블록은 일반 RAG context 보다 앞 — context-overflow truncation 에서 먼저
// 살아남게.
const lessonBlock = buildLessonChecklistBlock(lessonChunks.map((c) => ({ title: c.title, content: c.content })));
const memoryBlock = deps.retrievalOrchestrator.buildContextString(result);
return [lessonBlock, memoryBlock].filter(Boolean).join('\n\n');
}
@@ -0,0 +1,20 @@
/**
* modelName , 404 / not-loaded
* fallback .
*
* LM Studio : `gemma3:4b` ":quant suffix" base name
* (`gemma3`) push. Ollama
* candidates 1.
*
* Stateless agent.ts private .
*/
export function buildModelCandidates(modelName: string, engine: 'lmstudio' | 'ollama'): string[] {
const candidates = [modelName];
if (engine === 'lmstudio') {
const baseModel = modelName.replace(/:\d+$/, '');
if (baseModel && baseModel !== modelName) {
candidates.push(baseModel);
}
}
return candidates;
}
@@ -0,0 +1,96 @@
import { getConfig } from '../../config';
import { estimateTokens, estimateModelParamsB } from '../contextManager';
import { isCasualConversationPrompt } from './promptDetection';
import { isAstraModeArchitectureQuestion } from './astraModeArchitecture';
import { shouldPreflightLocalProjectPath } from './localProjectIntent';
/**
* -LLM vs Multi-Agent (5 ) .
* (configEnabled), prompt ,
* multi-agent . / prompt LLM.
*
* Priority ( evaluated, ):
* 1) Astra-mode meta question / local-path preflight (false)
* 2) mode='off' legacy +
* 3) casual prompt / (<12자) 단일
* 4) mode='always' multi ( )
* 5) mode='auto' / context fraction / /
* multi. prompt tokens `chunkedSwitchTokens` **
* ( context chunked
* ).
*
* Stateless agent.ts private . 의존: config / token
* / detection ( stateless).
*/
export function shouldUseMultiAgentWorkflow(prompt: string, configEnabled: boolean): boolean {
if (!prompt || isAstraModeArchitectureQuestion(prompt)) {
return false;
}
if (shouldPreflightLocalProjectPath(prompt)) {
return false;
}
const cfg = getConfig();
const mode = cfg.workflowMultiAgentMode || 'auto';
// 'off' → 기존 키워드/길이 휴리스틱만 사용 (legacy multiAgentEnabled 토글 존중).
if (mode === 'off') {
const legacyComplex = prompt.length > 180 || /(보고서|심층|종합\s*분석|리서치|조사|전략\s*수립|기획안|제안서|roadmap|research|report|deep\s*analysis|strategy|proposal)/i.test(prompt);
if (!legacyComplex) return false;
return configEnabled || /(보고서|심층|종합\s*분석|리서치|조사|전략\s*수립|기획안|제안서|research|report|deep\s*analysis|strategy|proposal)/i.test(prompt);
}
// 인사·잡담은 5단계 파이프라인 낭비. 짧은 casual prompt 는 제외.
if (isCasualConversationPrompt(prompt)) {
return false;
}
if (prompt.trim().length < 12) {
return false;
}
// 'always' → 위 가드만 통과하면 무조건 발동.
if (mode === 'always') return true;
// 'auto' → 다음 중 하나라도 만족하면 발동:
// (1) 사용자가 multiAgentEnabled 를 명시적으로 켰다,
// (2) 작은 모델 (≤4B params) 이라 한 번에 처리하기 위험,
// (3) prompt 토큰이 효과적 context window 의 임계 이상을 차지한다,
// (4) "보고서/리뷰/심층 분석" 같은 명백한 복합 작업 키워드 매치,
// (5) prompt 길이 자체가 큼 (>240 chars).
if (configEnabled) return true;
const paramB = estimateModelParamsB(cfg.defaultModel);
if (paramB !== null && paramB <= 4) return true;
// ── 절대 임계값 게이트 (사용자 명시 요청) ────────────────────────────
// 입력 prompt 가 `chunkedSwitchTokens` 미만이면 *키워드·길이 트리거 모두 무시*
// 하고 단일 LLM 호출. 큰 컨텍스트 모델(131k 등)에서 "요약/리뷰" 같은 키워드만
// 써도 chunked 가 강제 발동해 답변이 느려지던 문제 해결.
//
// 이 게이트는 fraction 안전 체크보다 *먼저* 평가됨 — 사용자가 절대 임계값을
// 명시한 의도(50k 미만은 한 번에 처리)를 fraction 이 뒤집지 못하게. 작은
// 컨텍스트 모델 사용자는 config 에서 이 값을 모델 윈도우의 ~30% 로 낮춰야 함.
try {
const promptTokensForGate = estimateTokens(prompt);
if (promptTokensForGate < cfg.chunkedSwitchTokens) {
return false;
}
} catch { /* fall through — 안전 측 fraction/keyword 체크가 처리 */ }
try {
const effectiveCtx = cfg.smallModelContextCap > 0 && paramB !== null && paramB <= 4
? cfg.smallModelContextCap
: cfg.contextLength;
const promptTokens = estimateTokens(prompt);
const threshold = Math.floor(effectiveCtx * cfg.workflowAutoCtxFractionThreshold);
if (promptTokens >= threshold) return true;
} catch { /* 안전한 폴백: 키워드/길이 체크로 진행 */ }
if (/(보고서|심층|종합\s*분석|리서치|조사|전략\s*수립|기획안|제안서|코드\s*리뷰|리뷰|아키텍처|architecture|research|report|deep\s*analysis|strategy|proposal|review)/i.test(prompt)) {
return true;
}
if (prompt.length > 240) return true;
return false;
}
@@ -0,0 +1,99 @@
/**
* (post-stream sanitization). stateless pure transform.
*
* 1) sanitizeAssistantContent (rationale,
* reasoning channels, Harmony/GPT-OSS , [PROBLEM]/[GOAL]/[REASONING]
* ) * * .
* 2) cleanDegeneratedOutput long context degenerate
* ( , , paragraph stutter ) .
* ** "
* " .
* 3) isRestartedAnswer continuation round "이어붙이지" **
* detect. "continue from here" .
*
* Why one module: * *
* . stream loop .
*/
/**
* ** / . Harmony
* `final` (thought, analysis, commentary, reasoning)
* closing (`<channel|>`, `<|channel|>`, `<|end|>`,
* `<|return|>`) .
*/
export function sanitizeAssistantContent(text: string): string {
const stripped = text
.replace(/<rationale>[\s\S]*?<\/rationale>/gi, '')
.replace(/^\s*\[PROBLEM\][\s\S]*?\[GOAL\][\s\S]*?\[REASONING\][^\n]*(?:\n+|$)/i, '')
.replace(/^\s*\[PROBLEM\][\s\S]*?(?:\n\s*\n|$)/i, '')
.replace(/(?:<think(?:ing)?>|<analysis>)[\s\S]*?(?:<\/think(?:ing)?>|<\/analysis>)/gi, '')
.replace(/<\|?channel\|?>\s*(?:thought|analysis|commentary|reasoning)\b[\s\S]*?<\|?channel\|?>/gi, '')
.replace(/<\|?channel\|?>\s*(?:thought|analysis|commentary|reasoning)\b[\s\S]*?(?=<\|?channel\|?>\s*final\b)/gi, '')
.replace(/<\|?channel\|?>\s*final\b\s*(?:<\|?message\|?>)?/gi, '')
.replace(/<\|?(?:end|return|start|message)\|?>/gi, '')
.trim();
return cleanDegeneratedOutput(stripped);
}
/**
* Long context small degenerate blast radius :
* - (`_______…`, `~~~~~~`) 8+ . markdown rule 3.
* - (Note: ) narration .
* - leaked Chronicle / follow-up scaffolding .
* - paragraph ( stutter) .
*
* ** "노이즈 벽" "읽을 수 있는 텍스트" .
*/
export function cleanDegeneratedOutput(text: string): string {
let s = text;
s = s.replace(/([_=~.*])\1{7,}/g, '');
s = s.replace(/\(Note:[^)]*\)/gi, '');
s = s.replace(/\n?Candidate records for this discussion[^\n]*/gi, '');
s = s.replace(/\(\s*질문\s*의도\s*[:][^)]*\)/g, '');
s = s.replace(/\[\s*핵심\s*확인\s*질문\s*\]\s*/g, '');
const paras = s.split(/\n{2,}/);
const deduped: string[] = [];
for (const p of paras) {
const norm = p.trim().replace(/\s+/g, ' ');
const prevNorm = deduped.length ? deduped[deduped.length - 1].trim().replace(/\s+/g, ' ') : '';
if (norm && norm === prevNorm) { continue; }
deduped.push(p);
}
return deduped.join('\n\n').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
}
/**
* Continuation round "이어붙이기" * *
* detect leading prefix 12 + 40 restart .
* "continue from here" .
*/
/**
* `<rationale>…</rationale>`
* [PROBLEM] / [GOAL] / [REASONING] .
* undefined .
*
* `[PROBLEM]…[GOAL]…[REASONING]…` .
* / fallback (reasoning raw ) .
*/
export function parseRationale(text: string): { problem: string; goal: string; reasoning: string } | undefined {
const match = text.match(/<rationale>([\s\S]*?)<\/rationale>/);
if (!match) return undefined;
const raw = match[1];
const problem = raw.match(/\[PROBLEM\]([\s\S]*?)(?=\[|$)/)?.[1]?.trim() || '';
const goal = raw.match(/\[GOAL\]([\s\S]*?)(?=\[|$)/)?.[1]?.trim() || '';
const reasoning = raw.match(/\[REASONING\]([\s\S]*?)(?=\[|$)/)?.[1]?.trim() || raw.trim();
return { problem, goal, reasoning };
}
export function isRestartedAnswer(soFar: string, cont: string): boolean {
const norm = (x: string) => x.trim().replace(/\s+/g, ' ').toLowerCase();
const a = norm(soFar);
const b = norm(cont);
if (a.length < 40 || b.length < 40) { return false; }
let i = 0;
const max = Math.min(a.length, b.length, 80);
while (i < max && a[i] === b[i]) { i++; }
return i >= 12;
}
@@ -0,0 +1,75 @@
import type { ChatMessage } from '../../agent';
/**
* assistant * 1-2 * (= R1 )
* system prompt [PRIOR TURN CONCLUSION] .
*
* 필요한가: 짧은 history (1-2 ) **
* "X 는 Y 의 개선판이야"
* / ("ConnectAI 가 더 경험자 코드") **
* ,
* anchor revise . chat history
* "방금 무엇을 결론지었는지" .
*
* 규칙: 마지막 assistant :
* - ( "." / "다." / "요." / "음." )
* - 1-2 (period/question mark )
* - 240 cap
*
* Returns '' when assistant .
*/
export function buildPriorTurnConclusionContext(history: ChatMessage[]): string {
const visible = history.filter(m => !m.internal && (m.role === 'user' || m.role === 'assistant'));
const lastAssistant = [...visible].reverse().find(m => m.role === 'assistant');
if (!lastAssistant || typeof lastAssistant.content !== 'string') return '';
const conclusion = extractConclusion(lastAssistant.content);
if (!conclusion) return '';
return [
'[PRIOR TURN CONCLUSION]',
'직전 답변에서 내가 도달한 결론은 다음과 같다:',
`> ${conclusion}`,
'이번 턴의 사용자 메시지가 이 결론의 전제·근거·범위에 영향을 주는 정보라면, 그 결론을 *재평가* 해서 정정/보강된 결론으로 다시 말해라. 영향이 없다면 왜 없는지 한 줄로 짚고 자연스럽게 다음으로 넘어가라.',
].join('\n');
}
/**
* * 1-2 * . Plain-text
* R1 "첫 문장 = 결론" , verdict.
*
* / / skip. 240 cut.
*/
function extractConclusion(text: string): string {
// 마크다운 fence + 헤더 + 빈 줄 skip → 첫 의미 있는 줄.
const lines = text.split('\n');
let firstContent = '';
let inFence = false;
for (const raw of lines) {
const line = raw.trim();
if (!line) continue;
if (line.startsWith('```')) { inFence = !inFence; continue; }
if (inFence) continue;
if (/^#+\s/.test(line)) continue; // markdown heading
if (line.startsWith('---') || line.startsWith('===')) continue;
firstContent = line;
break;
}
if (!firstContent) return '';
// 첫 1-2 문장 — period / question mark / 한국어 종결 기준.
// 250자 안에서 첫 2개 문장 cut, 없으면 250자 cap.
const cleaned = firstContent.replace(/\s+/g, ' ').trim();
if (cleaned.length <= 240) return cleaned;
// 마지막 문장 종결 위치 찾기 (240자 이내).
const within = cleaned.slice(0, 240);
const lastTerminator = Math.max(
within.lastIndexOf('. '),
within.lastIndexOf('. '),
within.lastIndexOf('? '),
within.lastIndexOf('! '),
within.lastIndexOf('다. '),
within.lastIndexOf('요. '),
);
if (lastTerminator > 60) return within.slice(0, lastTerminator + 1);
return within + '…';
}
@@ -0,0 +1,39 @@
/**
* Project-knowledge / local-path * *
* pure parser . :
*
* - extractEvidenceFilesFromProjectKnowledge: `## Evidence Files` (project
* knowledge overview ). `## Confirmed Structure`
* backtick fallback.
* - extractPriorityPreviewFiles: `File:` (local path scan ).
* `Priority file previews:` `### file/path` fallback.
*
* stateless god-file . dedupe
* list .
*/
export function extractEvidenceFilesFromProjectKnowledge(recentProjectKnowledgeContext: string): string[] {
const evidenceBlock = recentProjectKnowledgeContext.match(/## Evidence Files\n([\s\S]*?)(?=\n## |\n# |$)/i)?.[1] || '';
const evidenceFiles = [...evidenceBlock.matchAll(/-\s+`([^`]+)`/g)].map((match) => match[1].trim());
if (evidenceFiles.length > 0) {
return Array.from(new Set(evidenceFiles));
}
const structureBlock = recentProjectKnowledgeContext.match(/## Confirmed Structure\n([\s\S]*?)(?=\n## |\n# |$)/i)?.[1] || '';
return Array.from(new Set([...structureBlock.matchAll(/`([^`]+)`/g)]
.map((match) => match[1].trim())
.filter((value) => /[\\/]/.test(value) || /\.[a-z0-9]+$/i.test(value))));
}
export function extractPriorityPreviewFiles(localPathContext: string): string[] {
const fileMarkerMatches = [...localPathContext.matchAll(/^File:\s*(.+)$/gmi)]
.map((match) => match[1].trim());
if (fileMarkerMatches.length > 0) {
return Array.from(new Set(fileMarkerMatches));
}
const previewBlock = localPathContext.match(/Priority file previews:\n([\s\S]*)/)?.[1] || '';
return Array.from(new Set([...previewBlock.matchAll(/^###\s+(.+)$/gmi)]
.map((match) => match[1].trim())
.filter((value) => /[\\/]/.test(value) || /\.[a-z0-9]+$/i.test(value))));
}
+163
View File
@@ -0,0 +1,163 @@
import * as fs from 'fs';
import * as path from 'path';
import { logError } from '../../utils';
import { extractPriorityPreviewFiles } from './projectEvidence';
/**
* "프로젝트 지식 (project knowledge overview)" + .
*
* :
* 1) buildProjectKnowledgeMarkdown localPathContext Markdown
* overview (pure).
* 2) writeProjectKnowledgeRecord `docs/records/<project>/development/`
* / .
* 3) buildProjectKnowledgeFallbackAnswer "추가 정보 필요"
* (markdown ).
*
* Why one module: helper (`getProjectDisplayName`, `extractPriority...`,
* Path ) , (knowledge turn)
* god-file + .
*
* Note: writeProjectKnowledgeRecord fs I/O transactionManager
* *append-only * ( 0).
* transactional pure-ish .
*/
/** `ConnectAI` 디렉토리는 사용자가 `Astra` 로 부르므로 표시명 alias. 그 외는 그대로. */
export function getProjectDisplayName(projectPath: string): string {
const projectName = path.basename(projectPath);
return /^connectai$/i.test(projectName) ? 'Astra' : projectName;
}
/** localPathContext 의 `Path:` 라인 + tree + priority files 를 표준 markdown 으로 직렬화. */
export function buildProjectKnowledgeMarkdown(localPathContext: string): string {
const pathMatch = localPathContext.match(/Path:\s*(.+)/);
const projectPath = pathMatch?.[1]?.trim() || 'Unknown project path';
const projectDisplayName = getProjectDisplayName(projectPath);
const treeMatch = localPathContext.match(/Scanned tree:\n([\s\S]*?)(?:\nPriority file previews:|$)/);
const treePreview = treeMatch?.[1]?.trim().split('\n').slice(0, 80).join('\n') || '';
const priorityFiles = extractPriorityPreviewFiles(localPathContext);
return [
`# ${projectDisplayName} Project Knowledge Overview`,
'',
`Date: ${new Date().toISOString()}`,
`Project: ${projectDisplayName}`,
`Repository: \`${projectPath}\``,
'',
'## Purpose',
`${projectDisplayName}는 VS Code 안에서 로컬 AI 에이전트, Second Brain, 프로젝트 기록, 에이전트 스킬을 연결하는 개발 보조 프로젝트다.`,
'',
'## Confirmed Structure',
'- `src/agent.ts`: 에이전트 실행, 로컬 경로 프리플라이트, Second Brain Trace, 액션 실행 흐름의 중심.',
'- `src/sidebarProvider.ts`: Webview UI, 브레인/모델/프로젝트 선택, 프롬프트 전달, 기록 UI를 담당.',
'- `src/features/secondBrainTrace.ts`: Second Brain 검색 결과와 근거 정책을 구성.',
'- `src/features/projectChronicle/`: 프로젝트 기록을 Markdown으로 관리하는 Chronicle 기능.',
'- `src/core/`: 큐, 이벤트, 트랜잭션, 오류 처리 등 실행 안정성 계층.',
'- `tests/`: Second Brain, 로컬 경로 프리플라이트, Chronicle, 보안/트랜잭션 회귀 테스트.',
'',
'## Evidence Files',
...(priorityFiles.length ? priorityFiles.map((file) => `- \`${file}\``) : ['- 확인된 우선 파일 없음']),
'',
'## Scanned Tree Excerpt',
'```text',
treePreview || '(no scanned tree captured)',
'```',
'',
'## Current Knowledge Gap',
'- 전체 아키텍처는 파일 구조와 일부 프리뷰 기준으로 파악 가능하지만, 세부 동작 지식은 `src/agent.ts`, `src/sidebarProvider.ts`, `secondBrainTrace.ts`, `projectChronicle` 순서로 심화 분석해 보강해야 한다.',
'',
'## Next Records',
'- `agent.ts` 실행 흐름 상세 분석',
'- Second Brain Trace 검색 및 근거 정책 분석',
'- Project Chronicle 기록 생성 흐름 분석',
].join('\n');
}
/**
* markdown `docs/records/<project>/development/<date>_<slug>_…md`
* . Access projectPath null. log
* null ( fallback path ).
*/
export function writeProjectKnowledgeRecord(
localPathContext: string,
): { filePath: string; relativePath: string } | null {
const pathMatch = localPathContext.match(/Path:\s*(.+)/);
const projectPath = pathMatch?.[1]?.trim();
if (!projectPath || !localPathContext.includes('Access: succeeded')) return null;
try {
const projectName = path.basename(projectPath);
const projectDisplayName = getProjectDisplayName(projectPath);
const today = new Date().toISOString().slice(0, 10);
const slug = projectDisplayName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'project';
const relativePath = path.join('docs', 'records', projectName, 'development', `${today}_${slug}_project_knowledge_overview.md`);
const filePath = path.join(projectPath, relativePath);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, buildProjectKnowledgeMarkdown(localPathContext), 'utf8');
return { filePath, relativePath };
} catch (error: any) {
logError('Failed to write project knowledge record.', { error: error?.message || String(error) });
return null;
}
}
/**
* "추가 정보 필요" .
* 릿 priorityFiles / treePreview / record
* .
*/
export function buildProjectKnowledgeFallbackAnswer(
localPathContext: string,
record?: { filePath: string; relativePath: string } | null,
): string {
const pathMatch = localPathContext.match(/Path:\s*(.+)/);
const projectPath = pathMatch?.[1]?.trim() || '제공된 로컬 프로젝트 경로';
const projectDisplayName = getProjectDisplayName(projectPath);
const treeMatch = localPathContext.match(/Scanned tree:\n([\s\S]*?)(?:\nPriority file previews:|$)/);
const treePreview = treeMatch?.[1]?.trim().split('\n').slice(0, 18).join('\n') || '';
const priorityMatches = extractPriorityPreviewFiles(localPathContext).slice(0, 10);
const priorityText = priorityMatches.length
? priorityMatches.map((file) => `- ${file}`).join('\n')
: '- package.json, src, docs, config 계열 파일을 우선 확인';
return [
'## 간단 요약',
'맞아요. 이 경우에는 추가 질문으로 멈출 필요 없이, 지금 확인된 로컬 프로젝트 구조를 기준으로 기본 프로젝트 지식을 바로 만들면 됩니다.',
'',
'## 기본 지식 생성 방향',
`대상 프로젝트는 \`${projectPath}\`입니다. 우선 MVP 지식은 “프로젝트 개요 + 주요 모듈 + 확인된 근거 파일 + 다음에 깊게 볼 영역” 형태로 만드는 것이 가장 안전합니다.`,
'',
'## 확인된 근거',
priorityText,
'',
treePreview ? `## 확인된 구조 일부\n\`\`\`text\n${treePreview}\n\`\`\`` : '',
'',
'## 바로 만들 지식 초안',
'```markdown',
`# ${projectDisplayName} Project Knowledge Overview`,
'',
'## Purpose',
`${projectDisplayName}는 VS Code 안에서 로컬 AI 에이전트, Second Brain, 프로젝트 기록, 에이전트 스킬을 연결하는 개발 보조 프로젝트다.`,
'',
'## Confirmed Structure',
'- `src/agent.ts`: 에이전트 실행, 로컬 경로 프리플라이트, Second Brain Trace, 액션 실행 흐름의 중심.',
'- `src/sidebarProvider.ts`: Webview UI, 브레인/모델/프로젝트 선택, 프롬프트 전달, 기록 UI를 담당.',
'- `src/features/secondBrainTrace.ts`: Second Brain 검색 결과와 근거 정책을 구성.',
'- `src/features/projectChronicle/`: 프로젝트 기록을 Markdown으로 관리하는 Chronicle 기능.',
'- `src/core/`: 큐, 이벤트, 트랜잭션, 오류 처리 등 실행 안정성 계층.',
'- `tests/`: Second Brain, 로컬 경로 프리플라이트, Chronicle, 보안/트랜잭션 회귀 테스트.',
'',
'## Current Knowledge Gap',
'- 전체 아키텍처는 파일 구조와 일부 프리뷰 기준으로 파악 가능하지만, 세부 동작 지식은 `src/agent.ts`, `src/sidebarProvider.ts`, `secondBrainTrace.ts`, `projectChronicle` 순서로 심화 분석해 보강해야 한다.',
'',
'## Recommended Next Record',
`- \`docs/records/${path.basename(projectPath)}/development/YYYY-MM-DD_${projectDisplayName.toLowerCase()}_project_knowledge_overview.md\``,
'```',
'',
'## 다음 액션',
record
? `프로젝트 지식 1번 문서를 생성했습니다: \`${record.filePath}\``
: '기본값으로는 위 초안을 프로젝트 지식 1번 문서로 저장하고, 그 다음 `agent.ts` 실행 흐름 지식을 별도 문서로 쪼개는 것이 좋습니다.',
].filter(Boolean).join('\n');
}
@@ -0,0 +1,85 @@
/**
* prompt * * detection helpers. stateless .
*
* agent.ts private , system prompt
* (`buildJarvisProjectBriefContext` ) god-file
* . (builders detection) .
*
* 주의: 패턴 false positive thinking-partner
*
* .
*/
/**
* Thinking partner request "어떻게 생각해?" "어떤 게 맞을까?"
* * / / / * .
* , "구조" true
* detection (`isCasualConversationPrompt`) .
*/
export function isThinkingPartnerRequest(prompt: string): boolean {
return /(어떤\s*거?\s*같|어때|어떻게\s*생각|의견|판단|방향|설계|아키텍처|구조|자비스|생각.*정리|갈림길|architecture|design|direction|opinion|think|judge)/i.test(prompt);
}
/**
* Standalone greetings / acknowledgements / fillers * *
* true. "안녕, 이 프로젝트 분석해줘"
* . "안녕" RAG
* stale context .
*/
const CASUAL_PHRASES = new Set<string>([
// greetings
'안녕', '안녕하세요', '안녕하십니까', '안뇽', '하이', '하잉', '헬로', '헬로우', 'hello', 'hi', 'hii', 'hey', 'yo', 'ㅎㅇ', 'ㅎㅇㅎㅇ', '굿모닝', 'good morning', 'morning', 'gm',
// farewells
'잘가', '잘가요', '안녕히', '안녕히가세요', '안녕히계세요', '바이', '바이바이', 'bye', 'byebye', 'bye bye', 'goodbye', 'good bye', '굿바이', '잘자', '잘자요', '굿나잇', 'good night', 'gn',
// acknowledgements / affirmations
'네', '넵', '넹', '예', '응', '웅', '음', '흠', '엄', '그래', '그렇구나', '그렇군', '그렇네', '오케이', '오케', 'ok', 'okay', 'okey', 'k', 'ㅇㅋ', 'ㅇㅇ', '알겠어', '알겠습니다', '알겠어요', '알았어', '알았다', '알았어요', 'yes', 'yeah', 'yep', 'yup', 'sure', '좋아', '좋아요', '좋네', '좋다', 'good', 'fine',
// negations (still small talk — needs no RAG; the prior turn is already in the chat history)
'아니', '아니요', '아니오', 'ㄴㄴ', 'no', 'nope', 'nah',
// thanks / praise
'고마워', '고마워요', '고맙습니다', '감사', '감사해요', '감사합니다', 'thanks', 'thank you', 'thx', 'ty', '굿', '굳', '굿잡', 'good job', '잘했어', '잘했네', '훌륭', '훌륭해', '대박', 'nice', 'cool', 'great', 'awesome', 'perfect', '완벽', '수고', '수고했어', '수고하셨습니다', '고생했어', '고생많았어',
// laughs / fillers
'lol', 'haha', 'hmm', 'hmmm', 'umm', 'uh',
]);
/**
* Second Brain RAG . false positive
* force=true OK.
*/
export function isExplicitSecondBrainRequest(prompt: string): boolean {
return /(second brain|2nd brain|제2뇌|브레인|brain|기억|기록|노트|문서|참고해서|사용해서|검색해서|근거|출처)/i.test(prompt);
}
/**
* Second Brain * * "내 제2뇌의 강점/약점은?" . brain +
* overview/ true ( ). inventory
* + fallback path .
*/
export function isSecondBrainInventoryRequest(prompt: string): boolean {
const normalized = prompt.toLowerCase();
const asksBrain = /(second brain|2nd brain|제2뇌|브레인|brain)/i.test(normalized);
const asksOverview = /(평가|분석|강점|약점|부족|무엇을 할 수|활용|전체|연결된|현재|inside|overview|inventory|strength|weakness)/i.test(normalized);
return asksBrain && asksOverview;
}
/**
* "데이터/파일/근거 없어서 평가 불가" detect. true
* (inventory ) fallback .
*/
export function isNoBrainDataRefusal(answer: string): boolean {
return /(분석할 만한 실제 데이터가 없어|분석할.*데이터가 없어|파일 목록.*제공|핵심 내용.*제공|자료를 준비|지식을 먼저 제공|cannot be evaluated|no data|no files)/i.test(answer);
}
export function isCasualConversationPrompt(prompt: string): boolean {
const normalized = (prompt || '')
.trim()
.replace(/\s+/g, ' ')
.replace(/[~!?.,,。!?·…\s]+$/g, '')
.toLowerCase();
if (!normalized) return false;
if (normalized.length > 40) return false;
if (CASUAL_PHRASES.has(normalized)) return true;
if (/^[ㅋㅎ]{2,}$/.test(normalized)) return true; // ㅋㅋ, ㅎㅎㅎ, ㅋㅎㅋㅎ
if (/^(?:ha){2,}h?$|^(?:he){2,}h?$/.test(normalized)) return true; // haha, hahaha, hehe
return false;
}
@@ -0,0 +1,191 @@
import * as fs from 'fs';
import * as path from 'path';
import { logError, summarizeText } from '../../utils';
import type { ChatMessage } from '../../agent';
import { extractEvidenceFilesFromProjectKnowledge, extractPriorityPreviewFiles } from './projectEvidence';
/**
* "이전 라운드에서 만든 project knowledge overview 문서" * *
* follow-up + .
*
* :
* 1) isProjectKnowledgeFollowupRequest prompt architecture/structure
* detect.
* 2) findRecentProjectKnowledgeRecord chat history record path
* fallback `docs/records/**` *_project_knowledge_overview.md.
* 3) buildRecentProjectKnowledgeContext record 5000 system prompt
* prepend.
* 4) ensureRecentProjectKnowledgeEvidence / ensureLocalProjectPathEvidence
* "## 근거" (
* ).
* 5) isBlockingProjectKnowledgeAnswer "방향 알려주세요" blocking
* detect ( fallback path ).
*
* stateless agent.ts private . findRecord
* `this.chatHistory` history arg .
*/
/**
* Follow-up detection architecture / structure / design
* true. record detection
* `rootPath` .
*/
export function isProjectKnowledgeFollowupRequest(prompt: string): boolean {
return /(아키텍처|구조|조사|분석|설계|흐름|모듈|역할|개선|architecture|structure|design|flow|module|investigate|analy[sz]e)/i.test(prompt);
}
/** "project evidence:" 라인에서 옛 record path 만 뽑아냄. 없으면 null. */
export function extractRecentProjectKnowledgeRecordPath(recentProjectKnowledgeContext: string): string | null {
return recentProjectKnowledgeContext.match(/project evidence:\s*(\/Volumes\/Data\/project\/Antigravity\/[^\s`"'<>]+\.md)/i)?.[1] || null;
}
/**
* Chat history record path ( ). history fallback
* `docs/records/**` scan mtime `*_project_knowledge_overview.md`
* . null.
*/
export function findRecentProjectKnowledgeRecord(history: ChatMessage[], rootPath: string): string | null {
const fromHistory = [...history]
.reverse()
.map((message) => typeof message.content === 'string'
? message.content.match(/\/Volumes\/Data\/project\/Antigravity\/[^\s`"'<>]+_project_knowledge_overview\.md/i)?.[0]
: undefined)
.find(Boolean);
if (fromHistory && fs.existsSync(fromHistory)) {
return fromHistory;
}
const recordsRoot = path.join(rootPath, 'docs', 'records');
if (!fs.existsSync(recordsRoot)) {
return null;
}
const candidates: string[] = [];
const visit = (dir: string, depth: number) => {
if (depth > 5) return;
let entries: fs.Dirent[] = [];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (!entry.name.startsWith('.')) visit(fullPath, depth + 1);
continue;
}
if (/_project_knowledge_overview\.md$/i.test(entry.name)) {
candidates.push(fullPath);
}
}
};
visit(recordsRoot, 0);
return candidates
.filter((file) => fs.existsSync(file))
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)[0] || null;
}
/**
* Follow-up record system prompt prepend.
* record . Content 5000 cap (token ).
*/
export function buildRecentProjectKnowledgeContext(
prompt: string,
rootPath: string,
history: ChatMessage[],
): string {
if (!rootPath || !isProjectKnowledgeFollowupRequest(prompt)) {
return '';
}
const recordPath = findRecentProjectKnowledgeRecord(history, rootPath);
if (!recordPath) {
return '';
}
try {
const content = fs.readFileSync(recordPath, 'utf8');
return [
'[RECENT LOCAL PROJECT KNOWLEDGE]',
'The current user request appears to continue a previous local project knowledge discussion.',
`Use this recently generated project knowledge record as project evidence: ${recordPath}`,
'When answering, explicitly say that the analysis is based on the recently generated project knowledge record and local project structure. Do not imply that Second Brain Trace was the only evidence.',
'If deeper architecture detail is needed, recommend reading the concrete source files next instead of asking for the project path again.',
'',
summarizeText(content, 5000),
].join('\n');
} catch (error: any) {
logError('Failed to load recent project knowledge record.', { recordPath, error: error?.message || String(error) });
return '';
}
}
/**
* record path "## 근거" .
* record path noop ( ).
*/
export function ensureRecentProjectKnowledgeEvidence(content: string, recentProjectKnowledgeContext: string): string {
const recordPath = extractRecentProjectKnowledgeRecordPath(recentProjectKnowledgeContext);
if (!recordPath || content.includes(recordPath)) {
return content;
}
const evidenceFiles = extractEvidenceFilesFromProjectKnowledge(recentProjectKnowledgeContext).slice(0, 8);
const evidenceSection = [
'## 근거',
`이번 답변은 최근 생성된 프로젝트 지식 기록과 로컬 프로젝트 구조를 기준으로 작성했습니다: \`${recordPath}\``,
evidenceFiles.length
? `확인된 근거 파일:\n${evidenceFiles.map((file) => `- \`${file}\``).join('\n')}`
: '',
].filter(Boolean).join('\n\n');
return [
content.trim(),
'',
evidenceSection,
].join('\n');
}
/**
* "## 근거" local path access , path
* evidence . recent record * inspect
* * .
*/
export function ensureLocalProjectPathEvidence(content: string, localPathContext: string): string {
if (!localPathContext.includes('Access: succeeded') || content.includes('## 근거')) {
return content;
}
const pathMatch = localPathContext.match(/Path:\s*(.+)/);
const projectPath = pathMatch?.[1]?.trim();
const evidenceFiles = extractPriorityPreviewFiles(localPathContext).slice(0, 10);
if (!projectPath && evidenceFiles.length === 0) {
return content;
}
const evidenceSection = [
'## 근거',
projectPath
? `이번 답변은 로컬 프로젝트 경로 \`${projectPath}\`에서 확인한 파일 구조와 코드 프리뷰를 기준으로 작성했습니다.`
: '이번 답변은 확인된 로컬 프로젝트 파일 구조와 코드 프리뷰를 기준으로 작성했습니다.',
evidenceFiles.length
? `확인된 근거 파일:\n${evidenceFiles.map((file) => `- \`${file}\``).join('\n')}`
: '',
].filter(Boolean).join('\n\n');
return [
content.trim(),
'',
evidenceSection,
].join('\n');
}
/**
* "방향 알려주세요 / 어떤 부분 먼저 / 구체적인 방향 필요" blocking
* detect. true fallback path .
*/
export function isBlockingProjectKnowledgeAnswer(content: string): boolean {
return /(블로킹 질문|어떤 기능 영역|어떤 부분.*먼저|어떤 기능이나 아키텍처|구체적인 방향|방향 설정이 필요|명확히 알려주시면|우선적으로 정리|최종 사용 목적|Question reason|별도의 파일 기록.*생성되지|파일 기록이 생성되지|더 깊이 있는 분석.*지정|해당 기능.*지정하여 요청)/i.test(content);
}
@@ -0,0 +1,104 @@
import * as path from 'path';
import type { BrainProfile } from '../../config';
import type { SecondBrainTrace } from '../../features/secondBrainTrace';
/**
* Second Brain ** ("내 제2뇌 분석해줘", "강점/약점은?") prompt
* "데이터가 없어서 평가 불가"
* prompt prepend inventory + fallback .
*
* stateless agent.ts private .
*
* Why fallback 필요한가: inventory "
* " (`isNoBrainDataRefusal`
* detection), fallback .
*/
/** 시스템 prompt 에 prepend 할 inventory 컨텍스트 블록. */
export function buildSecondBrainInventoryContext(activeBrain: BrainProfile, brainFiles: string[]): string {
const relativeFiles = brainFiles.map((file) => path.relative(activeBrain.localBrainPath, file));
const directoryCounts = new Map<string, number>();
for (const rel of relativeFiles) {
const topDir = rel.includes(path.sep) ? rel.split(path.sep)[0] : '(root)';
directoryCounts.set(topDir, (directoryCounts.get(topDir) || 0) + 1);
}
const topDirectories = [...directoryCounts.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, 12)
.map(([dir, count]) => `- ${dir}: ${count} markdown files`)
.join('\n');
const samples = relativeFiles
.slice(0, 40)
.map((file) => `- ${file}`)
.join('\n');
return [
'[SECOND BRAIN INVENTORY]',
'The user is asking about the currently selected Second Brain as a knowledge base. Use this inventory as direct evidence.',
`Selected brain name: ${activeBrain.name}`,
`Selected brain path: ${activeBrain.localBrainPath}`,
`Markdown file count: ${brainFiles.length}`,
brainFiles.length > 0
? 'Do not say the Second Brain has no data, no files, or cannot be evaluated because files were not provided.'
: 'No Markdown files were found in the selected Second Brain path.',
topDirectories ? `Top-level distribution:\n${topDirectories}` : 'Top-level distribution: none',
samples ? `Sample files:\n${samples}` : 'Sample files: none',
'For strengths and weaknesses, infer from the inventory and selected note excerpts. Mark broad conclusions as inference when they are not directly proven.',
].join('\n');
}
/**
* . ///
* 릿 , trace
* inventory .
*/
export function buildSecondBrainInventoryFallbackAnswer(
activeBrain: BrainProfile,
brainFiles: string[],
trace: SecondBrainTrace | null,
): string {
const relativeFiles = brainFiles.map((file) => path.relative(activeBrain.localBrainPath, file));
const directoryCounts = new Map<string, number>();
for (const rel of relativeFiles) {
const topDir = rel.includes(path.sep) ? rel.split(path.sep)[0] : '(root)';
directoryCounts.set(topDir, (directoryCounts.get(topDir) || 0) + 1);
}
const topDirectories = [...directoryCounts.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, 8);
const distribution = topDirectories
.map(([dir, count]) => `- ${dir}: ${count}`)
.join('\n');
const selectedDocs = trace?.retrievedDocuments
.filter((doc) => doc.selectedForAnswerContext)
.map((doc) => `- ${doc.path} (${doc.sourceType}, score ${doc.score})`)
.join('\n') || '';
return [
'## 간단 요약',
`현재 선택된 제2뇌는 비어 있지 않습니다. \`${activeBrain.localBrainPath}\` 아래에서 Markdown 파일 ${brainFiles.length}개를 확인했기 때문에, 강점과 약점을 평가할 수 있습니다.`,
'',
'## 강점',
'1. 지식량이 충분합니다. 수천 개 규모의 Markdown 노트가 있어 단일 프로젝트 메모장이 아니라 실제 지식 베이스로 볼 수 있습니다.',
'2. 상위 폴더 기준으로 주제가 나뉘어 있어 검색과 확장에 유리합니다.',
'3. AI, UX, 프로젝트 로그처럼 실행 지식과 참고 지식이 함께 있어 기획, 리서치, 의사결정 보조에 쓸 수 있습니다.',
'4. Trace가 실제 문서를 찾고 있으므로 연결 자체는 동작합니다.',
'',
'## 약점',
'1. 검색 결과에서 인덱스 문서와 일반 지식 문서가 상위에 올라옵니다. 제2뇌 전체 평가에는 도움이 되지만, 구체적 판단 근거로는 밀도가 낮습니다.',
'2. Project Evidence와 General Knowledge가 명확히 분리되지 않아 답변이 조심스러워집니다.',
'3. “강점/약점 평가” 같은 전체 분석 요청에는 단일 키워드 검색보다 폴더 분포, 대표 문서, 최근 문서, 프로젝트 로그를 함께 보는 전용 분석 흐름이 필요합니다.',
'4. 문서 수가 많아서 요약 인덱스, 태그, source type 메타데이터가 약하면 좋은 문서가 검색 순위에서 밀릴 수 있습니다.',
'',
'## 확인된 분포',
distribution || '- 상위 폴더 없음',
'',
selectedDocs ? '## 이번 검색에서 잡힌 문서\n' + selectedDocs : '',
'',
'## 활용 가능성',
'이 제2뇌는 프로젝트 회고, UX/비즈니스 판단, 기술 리서치, 제안서 초안, 의사결정 근거 정리, 고객 요구사항 검토에 쓸 수 있습니다. 다음 개선 포인트는 “인덱스 문서보다 실제 근거 문서를 우선 선택하는 검색 랭킹”과 “프로젝트 근거 문서에 명시적 메타데이터를 붙이는 것”입니다.',
].filter(Boolean).join('\n');
}
@@ -0,0 +1,78 @@
/**
* System prompt * * stateless :
*
* 1) stripAstraFormattingForAgentMode Agent Mode v3 Astra
* *//* .
* .
* 2) computeModeSignature agent/company/multi/brain 4
* . signature system prompt "이전 모드 X → 현재 Y"
* bridge .
*
* instance state pure transform agent.ts private
* .
*/
/**
* Agent Mode Astra :
* - Astra "specialized AI assistant"
* - [OUTPUT FORMAT] (## // )
* - [FOLLOW-UP QUESTION RULES]
* - [ENGINEERING STANCE] (Astra )
* - [NO EMOJIS - ABSOLUTE RULE] ( )
* : [CORE BEHAVIOR], [LOCAL PATH RULE], [STRICT GLOBAL RULES], [ACTION TAGS], [OPERATIONAL RULES]
*
* 4 2 ( ).
*/
export function stripAstraFormattingForAgentMode(prompt: string): string {
let stripped = prompt;
stripped = stripped.replace(
/You are Astra, a Jarvis-style local project operating assistant\.\s*\nIf the user asks your name, say you are Astra\.\s*\n/,
'You are a specialized AI assistant operating in Agent Mode.\n',
);
stripped = stripped.replace(
/\[OUTPUT FORMAT\][\s\S]*?(?=\[FOLLOW-UP QUESTION RULES\]|\[ENGINEERING STANCE\]|\[ACTION TAGS\])/,
'',
);
stripped = stripped.replace(
/\[FOLLOW-UP QUESTION RULES\][\s\S]*?(?=\[ENGINEERING STANCE\]|\[ACTION TAGS\])/,
'',
);
stripped = stripped.replace(
/\[ENGINEERING STANCE\][\s\S]*?(?=\[ACTION TAGS\])/,
'',
);
stripped = stripped.replace(
/1\. \[NO EMOJIS - ABSOLUTE RULE\][^\n]*\n/,
'',
);
stripped = stripped.replace(/\n{4,}/g, '\n\n');
return stripped;
}
/**
* v2.2.69 mode signature . signature
* system prompt "이전 모드: X / 현재 모드: Y" bridge
* . 4 (agent/company/multi/brain)
* cache key .
*/
export function computeModeSignature(opts: {
agentSkillName?: string;
companyMode?: boolean;
multiAgent?: boolean;
brainName?: string;
}): string {
const parts = [
`agent=${opts.agentSkillName || 'none'}`,
`company=${opts.companyMode ? 'on' : 'off'}`,
`multi=${opts.multiAgent ? 'on' : 'off'}`,
`brain=${opts.brainName || '?'}`,
];
return parts.join('|');
}
+39
View File
@@ -0,0 +1,39 @@
import type { ChatMessage } from '../../agent';
/**
* "얇은 follow-up" assistant
* / / / .
*
* true Project Chronicle Guard " / /
* / " **
* 4-section 릿 (R1/R3 ).
*
* false-positive false-negative (
* ). ** true:
* (1) assistant history ( follow-up )
* (2) ( 100, trim )
* (3) ** (?, "어떻게", "왜", "뭐", "어디", )
* (4) * * (/////// )
*/
export function isThinFollowUp(prompt: string | null, history: ChatMessage[]): boolean {
if (!prompt) return false;
const t = prompt.trim();
if (t.length === 0 || t.length > 100) return false;
// (1) 직전 assistant 메시지가 있어야 follow-up 임.
const visible = history.filter(m => !m.internal && (m.role === 'user' || m.role === 'assistant'));
const hasPriorAssistant = visible.some(m => m.role === 'assistant');
if (!hasPriorAssistant) return false;
// (3) 질문 신호.
if (/[?]/.test(t)) return false;
if (/(어떻게|어디|언제|왜|뭐|누구|얼마|몇)/.test(t)) return false;
if (/^\s*(what|how|why|where|when|who|which)\b/i.test(t)) return false;
// (4) 새 작업 요청 신호 (단순 명령어 / 분석 요청).
if (/(분석|평가|추천|비교|만들|짜줘|작성|구현|돌려|실행|생성|검토|리뷰|review|analyze|implement|create|build)/i.test(t)) {
return false;
}
return true;
}
@@ -0,0 +1,21 @@
/**
* Thinking partner response contract system prompt prepend 6
* . 100% stateless god-file agent.ts
* . / / contract
* .
*
* 6 "thinking partner" +
* + + .
* / paragraph .
*/
export function buildThinkingPartnerResponseContract(): string {
return [
'Thinking partner response contract:',
'1. Start with a direct verdict, not a generic compliment.',
'2. Separate confirmed facts from inferences.',
'3. Name the strongest part of the direction and the weakest/missing part.',
'4. Identify the real decision fork the user is facing.',
'5. Suggest one small next action that would make the project direction clearer.',
'6. If project evidence is thin, say what must be inspected next instead of pretending certainty.'
].join('\n');
}
+17 -6
View File
@@ -853,24 +853,35 @@ export class AgentEngine {
* Fast-path 휴리스틱: prompt "쪼갤 필요 없는 단순 케이스" .
* true false outline LLM .
*
* :
* - < 200
* - ( , , --- )
* - / (////////)
* (v2 ** ):
* - 400 chunked ( )
* - chunked
* - / ** 80 chunked
* - / *80 * fast-path (: "이 함수 분석해줘", "리뷰 요청")
* - < 400 fast-path
*
* v1 1 200 chunked "5줄 코드 리뷰해줘"
* 7 LLM . v2 * + *
* chunked.
*/
public static isObviouslySimple(prompt: string): boolean {
if (!prompt) return false;
const trimmed = prompt.trim();
if (trimmed.length === 0) return false;
if (trimmed.length >= 200) return false;
// 본문 첨부 신호: 코드 펜스 / 긴 빈줄 / 마크다운 구분선 / 인용 다수.
const hasAttachment = /```|\n\n\n|^---$|^> .*\n> /m.test(trimmed);
if (hasAttachment) return false;
// 매우 긴 입력은 키워드 무관하게 chunked.
if (trimmed.length >= 400) return false;
// 분석/구조화 키워드.
const heavyKeyword = /(분석|리서치|조사|보고서|심층|상세히|꼼꼼히|기획|설계|아키텍처|리뷰|review|analyz|research|deep\s*analysis|strategy|proposal|보고|요약해서\s*정리)/i;
if (heavyKeyword.test(trimmed)) return false;
const hasKeyword = heavyKeyword.test(trimmed);
// 키워드 있고 입력이 길면(≥80자) chunked. 짧으면 (예: "이거 분석해줘") fast-path.
if (hasKeyword && trimmed.length >= 80) return false;
return true;
}
+15
View File
@@ -1,9 +1,24 @@
import * as vscode from 'vscode';
import { MissionState } from './engine';
/**
* @deprecated v2.2.76+ * OFF*. unreachable.
*
* 5-stage P-Reinforce v3.0 frontmatter + Reliability
* Audit wrap . (" wiki
* ") `AgentEngine.runMission` * opt-in* (options.config.
* formatAsKnowledgeArtifact === true) .
*
* ():
* - "wiki 보관용 답변 모드"
* - Datacollect P-Reinforce v3.0
*
* * * .
*/
export class WikiFormatter {
/**
* P-Reinforce v3.0 .
* @deprecated docstring .
*/
public static format(content: string, state: MissionState): string {
const now = new Date().toISOString();
@@ -0,0 +1,90 @@
import * as fs from 'fs';
import { logInfo, logError } from '../../utils';
import { getOrCreateAgentEntry } from '../../skills/agentKnowledgeMap';
import { loadExternalSkills, formatSkillsAsPromptBlock } from '../../skills/externalSkillLoader';
/**
* Chat turn *agent skill * *effective model* . sidebarProvider
* `_handlePrompt` 50 if/else deps-free .
*
* :
* 1) agentFile 'none' context undefined,
* base model .
* 2) placeholder
* . placeholder 'placeholder' notification + context undefined.
* 3) agentSkillContext , agent knowledge map
* external skills append. Knowledge map entry model pinned
* base + 'modelOverride' notification.
*
* Webview send provider * *
* (notifications ). provider .
*
* Stateless vscode/webview/instance state . fs/logger .
*/
export type AgentSkillNotification =
| { type: 'placeholder' }
| { type: 'modelOverride'; agent: string; model: string };
export interface AgentSkillResolution {
/** Agent skill blob — placeholder/없음/실패 시 undefined. */
agentSkillContext: string | undefined;
/** Per-agent override 가 있으면 그 모델, 아니면 base model 그대로. */
effectiveModel: string;
/** Provider 가 webview 에 송신해야 할 알림들 (placeholder warning / model override 알림). */
notifications: AgentSkillNotification[];
}
export function resolveAgentSkill(
agentFile: string | undefined | null,
baseModel: string,
): AgentSkillResolution {
const effectiveModel = baseModel || '';
if (!agentFile || agentFile === 'none' || !fs.existsSync(agentFile)) {
return { agentSkillContext: undefined, effectiveModel, notifications: [] };
}
const fileContent = fs.readFileSync(agentFile, 'utf8');
// Placeholder guard: "# Agent Persona: …" 헤더 + "Add your instructions here…" 만 있으면
// 사용자가 아직 본문을 안 채운 freshly-created agent. 그대로 agent prompt 로 쓰면
// 모델이 "Add your instructions here" 를 instructions 로 잘못 해석.
const body = fileContent.replace(/^?#\s*Agent\s*Persona\s*:.*$/im, '').trim();
const isPlaceholder = !body || /^add your instructions here/i.test(body);
if (isPlaceholder) {
logInfo('Selected agent has no real instructions — running without agent mode.', { agentFile });
return {
agentSkillContext: undefined,
effectiveModel,
notifications: [{ type: 'placeholder' }],
};
}
let agentSkillContext: string = fileContent;
let finalModel = effectiveModel;
const notifications: AgentSkillNotification[] = [];
try {
const entry = getOrCreateAgentEntry(agentFile);
const bundle = loadExternalSkills(entry.skillFolders);
const block = formatSkillsAsPromptBlock(bundle);
if (block) {
// External skills 를 agent .md 본문 끝에 append — 다음 파이프라인 (agent.ts /
// agent-mode override) 이 동일 blob 으로 처리해서 추가 분기 불필요.
agentSkillContext = `${agentSkillContext.trim()}\n\n${block}`;
}
const pinned = entry.model?.trim();
if (pinned && pinned !== finalModel) {
logInfo('Per-agent model override applied.', {
agent: entry.name,
requested: finalModel,
pinned,
});
finalModel = pinned;
notifications.push({ type: 'modelOverride', agent: entry.name, model: pinned });
}
} catch (e: any) {
logError('External skill load failed.', { error: e?.message || String(e) });
}
return { agentSkillContext, effectiveModel: finalModel, notifications };
}
@@ -0,0 +1,41 @@
import type { RequirementContract } from '../../features/company';
/**
* Intent Alignment 4 payload . webview provider
* payload shape .
*
* :
* - 'auto-proceed' high confidence + ,
* - 'questions' ,
* - 'confirm' vs
* - 'cancelled' , input
*
* message type ('companyAlignmentCard') `kind` 4
* discriminate. sidebarProvider 3 inline .
*/
export function buildAlignmentAutoProceedPayload(contract: RequirementContract, reachedLimit: boolean) {
return {
type: 'companyAlignmentCard' as const,
value: { kind: 'auto-proceed' as const, contract, reachedLimit },
};
}
export function buildAlignmentAskPayload(
kind: 'questions' | 'confirm',
contract: RequirementContract,
roundsAsked: number,
roundsLimit: number,
) {
return {
type: 'companyAlignmentCard' as const,
value: { kind, contract, roundsAsked, roundsLimit },
};
}
export function buildAlignmentCancelledPayload() {
return {
type: 'companyAlignmentCard' as const,
value: { kind: 'cancelled' as const },
};
}
+41
View File
@@ -0,0 +1,41 @@
import type { CompanyState, RequirementContract } from '../../features/company';
import { listActiveAgentsByCategory } from '../../features/company';
/**
* Intent Alignment *pure *. webview / state mutation
* provider boolean / list .
*
* - shouldAutoProceedAlignment(mode, contract, reachedLimit)
* smart skip pipeline .
* - extractActiveRoleCategories(state)
* enum (analyzer capability ).
*/
/**
* 2 (smart strict ):
* (a) high confidence + openQuestions 0 alignment
* (b) round limit + medium confidence , friction
* (b) confidence='low' confirm ( ).
*/
export function shouldAutoProceedAlignment(
mode: 'smart' | 'strict',
contract: RequirementContract,
reachedLimit: boolean,
): boolean {
if (mode !== 'smart') return false;
if (contract.confidence === 'high' && contract.openQuestions.length === 0) return true;
if (reachedLimit && (contract.confidence === 'high' || contract.confidence === 'medium')) return true;
return false;
}
/**
* (role category) enum key .
* analyzer "이 회사가 어떤 일을 할 수 있나" goal/format capability
* .
*/
export function extractActiveRoleCategories(state: CompanyState): string[] {
const byCat = listActiveAgentsByCategory(state);
return Object.entries(byCat)
.filter(([, list]) => list.length > 0)
.map(([cat]) => cat);
}
@@ -0,0 +1,107 @@
import * as fs from 'fs';
import type { ProjectProfile } from '../../features/projectChronicle';
import { formatArchitectureContextForPrompt } from '../../features/projectArchitecture';
/**
* Architecture (chronicle attach) webview payload + prompt
* context . webview send / fs write / scan ** provider
* `ArchitectureWatchManager` .
*
* - buildArchitectureStatusPayload(profile, hasDoc, wasDetached)
* 'architectureStatus' active/inactive/hidden
* - buildProjectArchitectureContext(profile)
* agent.ts prompt prepend architecture context string
*/
/**
* Sidebar chip 3 :
* 1) active auto-attach doc . "마지막 갱신 N분 전" .
* 2) inactive (canAttach) workspace + project doc or detached.
* [Attach] . `detached` "Activate" vs "Re-attach" .
* 3) hidden profile null. `{ active: false }` .
*
* (1)(2) , hidden (profile
* ).
*/
export function buildArchitectureStatusPayload(profile: ProjectProfile, hasDoc: boolean, wasDetached: boolean) {
const fullyActive = hasDoc && !wasDetached;
if (fullyActive) {
return {
type: 'architectureStatus' as const,
value: {
active: true,
projectId: profile.projectId,
projectName: profile.projectName,
docPath: profile.architectureDocPath,
lastUpdated: profile.architectureLastUpdated || '',
autoUpdate: profile.architectureAutoUpdate !== false,
},
};
}
return {
type: 'architectureStatus' as const,
value: {
active: false,
canAttach: !!profile.projectRoot,
projectId: profile.projectId,
projectName: profile.projectName,
// "never activated" 와 "detached" 구분 — chip 라벨 결정용.
detached: wasDetached,
},
};
}
/** Workspace 도 profile 도 없는 hidden 상태 — chip 자체 숨김. */
export function buildArchitectureHiddenPayload() {
return {
type: 'architectureStatus' as const,
value: { active: false as const },
};
}
/**
* Refresh / Attach ( project / projectRoot ).
* webview spinner .
*/
export function buildArchitectureRefreshFailedPayload(reason: string) {
return {
type: 'architectureRefreshFailed' as const,
value: { reason },
};
}
/**
* Refresh webview . 3 (newly /
* cached / deleted) "Refresh 가 실제로 뭘 했는가"
* timestamp
* .
*/
export function buildArchitectureRefreshResultPayload(opts: {
projectName: string;
docPath: string;
newlyAnalyzed: number;
cached: number;
deleted: number;
durationMs: number;
}) {
return {
type: 'architectureRefreshResult' as const,
value: opts,
};
}
/**
* Agent prompt prepend architecture context block. auto-attach off
* doc (agent.ts skip).
*/
export function buildProjectArchitectureContext(profile: ProjectProfile | null): string {
if (!profile || profile.architectureAutoAttach === false || !profile.architectureDocPath) return '';
if (!fs.existsSync(profile.architectureDocPath)) return '';
return formatArchitectureContextForPrompt({
projectName: profile.projectName,
docPath: profile.architectureDocPath,
// project root 를 같이 넘겨 Source: 헤더가 workspace-relative 로 출력되게.
projectRoot: profile.projectRoot,
lastUpdated: profile.architectureLastUpdated,
});
}
@@ -0,0 +1,88 @@
import * as fs from 'fs';
/**
* "자동 chronicle 기록" ** + assistant
* record type (planning / discussion / decision / development / bug)
* , / .
*
* stateless / fs.statSync sidebarProvider private
* .
*
* - extractLocalProjectPath(text) `/Volumes/...` +
* - inferAutoChronicleRecordType(user, assistant) 6 ( )
* - extractChangedFilesFromText(text) assistant backtick /
*/
/**
* Antigravity workspace .
* fs.statSync throw null.
*/
export function extractLocalProjectPath(text: string): string | null {
const match = text.match(/\/Volumes\/Data\/project\/Antigravity\/[^\s`"'<>]+/i);
if (!match) return null;
const candidate = match[0].replace(/[.,;:)\]]+$/, '');
try {
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
return candidate;
}
} catch {
return null;
}
return null;
}
/**
* 6 record type . :
* 0) opt-out ("기록하지마" / "do not record") null
* 1) topic (// ) ** null
* 2) bug user 'bug'
* 3) "수정 완료/구현 완료/jest passed" assistant 'development'
* 4) "결정/채택/decision" 'decision'
* 5) "계획/설계/아키텍처/roadmap" user 'planning'
* 6) "개선/구현/test" combined 'development'
* 7) 'discussion'
*
* ** . .
*/
export function inferAutoChronicleRecordType(
userText: string,
assistantText: string,
): 'planning' | 'discussion' | 'decision' | 'development' | 'bug' | null {
const combined = `${userText}\n${assistantText}`;
if (!combined.trim()) return null;
if (/(기록하지마|저장하지마|no\s+record|do\s+not\s+record)/i.test(combined)) return null;
if (!/(프로젝트|코드|아키텍처|설계|개선|수정|구현|테스트|검증|이슈|문제|버그|오류|끊김|결정|방향|기록|지식|review|architecture|implement|fix|bug|issue|test|decision)/i.test(combined)) {
return null;
}
if (/(버그|오류|에러|이슈|문제|끊김|안\s*됨|실패|bug|error|issue|failed|failure)/i.test(userText)) {
return 'bug';
}
if (/(수정 완료|개선 완료|구현 완료|패치|테스트.*통과|검증.*완료|변경.*파일|compile|jest|tsc|passed|implemented|fixed)/i.test(assistantText)) {
return 'development';
}
if (/(결정|확정|채택|방향은|하기로|하지 않기로|decision|decide|accepted)/i.test(combined)) {
return 'decision';
}
if (/(계획|설계|아키텍처|조사|방향|로드맵|mvp|planning|architecture|roadmap|design)/i.test(userText)) {
return 'planning';
}
if (/(개선|수정|구현|테스트|검증|패킹|compile|jest|tsc|implement|fix|test|verify)/i.test(combined)) {
return 'development';
}
return 'discussion';
}
/**
* Assistant backtick / . 12 cap.
* 0 placeholder (record changedFiles
* template ).
*/
export function extractChangedFilesFromText(text: string): string[] {
const files = new Set<string>();
for (const match of text.matchAll(/`([^`\n]+\.(?:ts|tsx|js|jsx|json|md|css|html|py|yml|yaml))`/gi)) {
files.add(match[1].trim());
}
return files.size > 0
? Array.from(files).slice(0, 12)
: ['No explicit changed file list was captured automatically.'];
}
@@ -0,0 +1,116 @@
import type {
BugRecord,
DecisionRecord,
DevelopmentLog,
DiscussionRecord,
PlanningDocument,
} from '../../features/projectChronicle';
import { summarizeTextForWiki } from './textHelpers';
import { extractChangedFilesFromText } from './autoChronicleClassifier';
/**
* chronicle 5 record type *payload* . pure
* //title/createdAt ProjectChronicleManager.writeXxx()
* record body .
*
* 코드: sidebarProvider `_autoWriteChronicleAfterPrompt` 5 if/else
* inline payload (~70). `autoChronicleClassifier`
* , * payload * .
*
* Provider payload `this._chronicle.writeXxx(profile, payload, [number])`
* . instance state `summarizeTextForWiki` / `extractChangedFilesFromText`
* builder .
*/
export function buildAutoBugRecord(
title: string,
summary: string,
latestUser: string,
createdAt: string,
): BugRecord {
return {
title,
symptom: summarizeTextForWiki(latestUser || 'Issue was detected during the conversation.'),
cause: 'Captured automatically from the current conversation. Confirm root cause during follow-up review if needed.',
fix: summary,
prevention: 'Keep automatic records tied to the active project and verify the relevant test or reproduction path.',
createdAt,
};
}
export function buildAutoPlanningRecord(
title: string,
summary: string,
latestUser: string,
createdAt: string,
): PlanningDocument {
return {
featureName: title,
purpose: 'Capture the current planning or architecture direction before implementation continues.',
background: summary,
userIntent: summarizeTextForWiki(latestUser),
sourceRequest: latestUser || 'No user request captured in the current chat.',
scope: ['Continue from the active project conversation.', 'Use the selected project record folder automatically.'],
outOfScope: ['Manual record type selection.', 'Blocking the user with record-writing prompts.'],
developmentDirection: summary,
dependencyStrategy: 'Prefer existing project modules and local Markdown records.',
expectedValue: 'Future work can resume with the latest project intent and reasoning preserved.',
successCriteria: ['The record is saved automatically after a meaningful project turn.', 'The record stays under the active project.'],
developerInstruction: 'Use this record as lightweight context for the next development or review pass.',
createdAt,
};
}
export function buildAutoDecisionRecord(
title: string,
summary: string,
latestUser: string,
createdAt: string,
): DecisionRecord {
return {
title,
status: 'accepted',
context: summarizeTextForWiki(latestUser),
decision: summary,
reason: 'Captured automatically because the conversation contained decision-oriented language.',
alternatives: [],
consequences: ['Future prompts should treat this as project context unless the user changes direction.'],
createdAt,
};
}
export function buildAutoDevelopmentRecord(
title: string,
summary: string,
latestAssistant: string,
createdAt: string,
): DevelopmentLog {
return {
featureName: title,
purpose: 'Record the implementation or verification outcome from the current conversation.',
implementationSummary: summary,
architecture: 'Captured automatically from the assistant response and active project context.',
changedFiles: extractChangedFilesFromText(latestAssistant),
dependencyNotes: 'No new dependency note was captured automatically.',
bugs: [],
lessons: ['Automatic project records should be generated in the background when the turn contains durable project knowledge.'],
createdAt,
};
}
export function buildAutoDiscussionRecord(
title: string,
summary: string,
latestUser: string,
createdAt: string,
): DiscussionRecord {
return {
title,
userRequest: summarizeTextForWiki(latestUser || 'No user request captured in the current chat.'),
interpretedIntent: 'Capture a meaningful project discussion automatically instead of requiring manual record selection.',
questions: [],
discussions: [summary],
decisions: [],
createdAt,
};
}
@@ -0,0 +1,24 @@
/**
* Brain profile lifecycle pure helpers sidebarProvider add/edit/delete
* modal UI config * * .
*
* edit "변경된 필드만 머지", delete "next active
* " pure transform .
*/
/**
* slug-style ,
* . slug `'brain'` fallback.
* `-2`, `-3` suffix .
*/
export function generateUniqueBrainId(name: string, existingProfiles: ReadonlyArray<{ id: string }>): string {
const idBase = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'brain';
if (!existingProfiles.some((p) => p.id === idBase)) return idBase;
let suffix = 2;
let id = `${idBase}-${suffix}`;
while (existingProfiles.some((p) => p.id === id)) {
suffix += 1;
id = `${idBase}-${suffix}`;
}
return id;
}

Some files were not shown because too many files have changed in this diff Show More