feat: expand live chat and work server tools

This commit is contained in:
2026-04-30 11:40:02 +09:00
parent 42ae640470
commit 2df0ba30cb
112 changed files with 15241 additions and 996 deletions

View File

@@ -26,11 +26,29 @@ const SOURCE_SNAPSHOT_MAX_BYTES = 200 * 1024;
const ERROR_SUMMARY_MAX_LENGTH = 500;
const SOURCE_SNAPSHOT_TEXT_PATTERN =
/\.(txt|log|csv|md|mdx|json|jsonl|ya?ml|xml|diff|patch|sh|bash|zsh|ini|cfg|conf|sql|js|jsx|ts|tsx|css|scss|less|html?|java|kt|py|rb|go|rs|svg)$/i;
const NAVER_STOCK_ITEM_URL = 'https://finance.naver.com/item/main.naver?code=';
const DEFAULT_STOCK_ALERT_TITLE = '현재 주가';
const STOCK_ALERT_COMPANIES = [
{
code: '005930',
labels: ['삼성전자'],
pattern: /삼성전자/i,
},
{
code: '000660',
labels: ['SK하이닉스', '하이닉스'],
pattern: /(?:sk\s*)?하이닉스|hynix/i,
},
];
const STOCK_ALERT_REGISTERED_CURRENT_PRICE_PATTERN =
/stock알림|현재가로 등록된 종목|등록된 알림유형이 현재가|현재가 알림.*등록된 종목/i;
const ERROR_SUMMARY_LINE_PATTERN =
/(ERROR:|failed|failure|capacity|Read-only file system|permission|not permitted|sandbox|os error|ENOENT|EACCES|ECONN|SyntaxError|TypeError|ReferenceError)/i;
/(ERROR:|\bfailed\b|\bfailure\b|capacity|Read-only file system|permission|not permitted|sandbox|os error|ENOENT|EACCES|ECONN|SyntaxError|TypeError|ReferenceError)/i;
const ERROR_SUMMARY_NOISE_PATTERN =
/^(exec|codex|tokens used|succeeded in \d+ms:?|[><=]{3,}|[A-Za-z]:\\|\/bin\/bash\b|\d+\s*[:|])/i;
/^(exec|codex|tokens used|succeeded in \d+ms:?|```|[><=]{3,}|[A-Za-z]:\\|\/bin\/bash\b|\d+\s*[:|])/i;
const ERROR_SUMMARY_STRUCTURED_NOISE_PATTERN =
/^(?:(?:[+\-]\s*)?(?:"(?:failed|failedCount|iosFailed|webFailed)"|'(?:failed|failedCount|iosFailed|webFailed)'|(?:failed|failedCount|iosFailed|webFailed))\s*(?::|=)\s*(?:\[\s*\]|0)\s*[,;]?|[+\-]\s*(?:"(?:failed|failedCount|iosFailed|webFailed)"|'(?:failed|failedCount|iosFailed|webFailed)'|(?:failed|failedCount|iosFailed|webFailed))\s*(?::|=).+|return\s+Promise\.resolve\(\{\s*ok:\s*true,\s*skipped:\s*(?:true|false),[\s\S]*failedCount:\s*0[\s\S]*\}\);?)$/i;
const CODEX_EXEC_MAX_ATTEMPTS = 2;
const CODEX_EXEC_RETRY_DELAY_MS = 3000;
const MAX_ERROR_LOG_PLAN_REGISTRATIONS = 6;
@@ -47,6 +65,7 @@ const CODEX_HOME_RUNTIME_PATHS = [
'models_cache.json',
'version.json',
];
const ZERO_TOKEN_USAGE_TEXT = 'total 0 input 0 output 0 cached 0 reasoning 0';
function parseCodexTokenUsage(output) {
const text = stripAnsi(String(output ?? ''));
@@ -99,6 +118,11 @@ function parseCodexTokenUsage(output) {
.trim();
}
function buildTokenUsageLine(tokenUsage) {
const normalized = String(tokenUsage ?? '').trim();
return `토큰 사용량: ${normalized || ZERO_TOKEN_USAGE_TEXT}`;
}
function reportProgress(message) {
const text = String(message ?? '').replace(/\s+/g, ' ').trim();
@@ -506,7 +530,8 @@ function summarizeFailureOutput(output, fallback) {
.split('\n')
.map((line) => normalizeErrorSummaryLine(line))
.filter(Boolean)
.filter((line) => !ERROR_SUMMARY_NOISE_PATTERN.test(line));
.filter((line) => !ERROR_SUMMARY_NOISE_PATTERN.test(line))
.filter((line) => !ERROR_SUMMARY_STRUCTURED_NOISE_PATTERN.test(line));
const bestLine =
normalizedLines.filter((line) => ERROR_SUMMARY_LINE_PATTERN.test(line)).at(-1) ??
@@ -577,6 +602,18 @@ function summarizeRequest(note, limit = PROMPT_REQUEST_SUMMARY_LIMIT) {
return `${text.slice(0, Math.max(0, limit - 1)).trim()}`;
}
function truncateInlineText(value, maxLength = 400) {
const normalized = String(value ?? '')
.replace(/\s+/g, ' ')
.trim();
if (!normalized) {
return '';
}
return normalized.length <= maxLength ? normalized : `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}`;
}
function escapeTemplateValue(value) {
return encodeURIComponent(String(value ?? '').trim());
}
@@ -636,7 +673,7 @@ function formatRecentActionHistories(histories) {
.slice(0, 5)
.map(
(history, index) =>
`${index + 1}. [${history.actionType}] ${history.createdAt}\n${String(history.note ?? '').trim()}`,
`${index + 1}. [${history.actionType}] ${history.createdAt}\n${truncateInlineText(history.note, 600)}`,
)
.join('\n\n');
}
@@ -649,12 +686,297 @@ function formatRecentIssueHistories(histories) {
return histories
.slice(0, 5)
.map((history, index) => {
const actionNote = history.actionNote ? `\n조치이력:\n${String(history.actionNote).trim()}` : '';
return `${index + 1}. ${history.issueTag} / ${history.createdAt}\n${String(history.message ?? '').trim()}${actionNote}`;
const actionNote = history.actionNote ? `\n조치이력:\n${truncateInlineText(history.actionNote, 500)}` : '';
return `${index + 1}. ${history.issueTag} / ${history.createdAt}\n${truncateInlineText(history.message, 500)}${actionNote}`;
})
.join('\n\n');
}
function isStockAlertRequest(note) {
const text = extractPrimaryRequestText(note);
const isExplicitCompanyRequest = /주가/.test(text) && STOCK_ALERT_COMPANIES.some((item) => item.pattern.test(text));
return isExplicitCompanyRequest || isRegisteredCurrentPriceStockAlertRequest(text);
}
function isRegisteredCurrentPriceStockAlertRequest(noteOrText) {
const text = extractPrimaryRequestText(noteOrText);
return STOCK_ALERT_REGISTERED_CURRENT_PRICE_PATTERN.test(text);
}
function extractPrimaryRequestText(note) {
const text = String(note ?? '');
const requestBodyMatch = text.match(/## 요청 본문\s*([\s\S]*?)(?:\n##\s+|\s*$)/);
return (requestBodyMatch?.[1] ?? text).trim();
}
function extractTargetAppDomains(note) {
const text = extractPrimaryRequestText(note);
const matches = text.match(/\b(?:[a-z0-9-]+\.)+[a-z]{2,}\b/gi) ?? [];
return [...new Set(matches.map((value) => value.trim().toLowerCase()).filter(Boolean))];
}
function resolveRequestedStockCompanies(note) {
const text = extractPrimaryRequestText(note);
return STOCK_ALERT_COMPANIES.filter((item) => item.pattern.test(text));
}
function stripHtmlTags(value) {
return String(value ?? '')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function parseQuotedNumber(value) {
const normalized = String(value ?? '').replace(/[^\d.-]/g, '');
return normalized ? Number(normalized) : NaN;
}
async function fetchNaverStockPage(code) {
const response = await fetch(`${NAVER_STOCK_ITEM_URL}${code}`, {
headers: {
'user-agent': 'Mozilla/5.0 (compatible; ai-code-app/1.0; +https://test.sm-home.cloud/)',
accept: 'text/html,application/xhtml+xml',
'accept-language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7',
},
});
if (!response.ok) {
throw new Error(`주가 페이지를 불러오지 못했습니다. code=${code}, status=${response.status}`);
}
return response.text();
}
function parseNaverStockQuote(html, code) {
const infoMatch = html.match(
new RegExp(
`<dd>\\s*종목명\\s*([^<]+?)\\s*</dd>[\\s\\S]*?<dd>\\s*종목코드\\s*${code}[^<]*</dd>[\\s\\S]*?<dd>\\s*현재가\\s*([\\d,]+)\\s*전일대비\\s*(상승|하락|보합)\\s*([\\d,]+)?[\\s\\S]*?([\\d.]+)\\s*퍼센트`,
'i',
),
);
const timeMatch = html.match(/<em class="date">\s*([^<]+?)\s*<span>([^<]+)<\/span>\s*<\/em>/i);
if (!infoMatch) {
throw new Error(`주가 페이지 파싱에 실패했습니다. code=${code}`);
}
const name = stripHtmlTags(infoMatch[1]);
const priceText = String(infoMatch[2] ?? '').trim();
const direction = String(infoMatch[3] ?? '').trim();
const changeText = String(infoMatch[4] ?? '0').trim() || '0';
const percentText = String(infoMatch[5] ?? '0').trim() || '0';
const price = parseQuotedNumber(priceText);
const change = parseQuotedNumber(changeText);
const changePercent = Number(percentText);
if (!Number.isFinite(price) || !Number.isFinite(change) || !Number.isFinite(changePercent)) {
throw new Error(`주가 숫자 파싱에 실패했습니다. code=${code}`);
}
return {
code,
name,
price,
priceText,
direction,
change,
changeText,
changePercent,
changePercentText: Number(changePercent).toFixed(2),
quotedAt: timeMatch ? `${stripHtmlTags(timeMatch[1])} ${stripHtmlTags(timeMatch[2])}`.trim() : '',
};
}
function formatStockAlertPercent(quote) {
if (quote.direction === '상승') {
return `(+${quote.changePercentText}% ▲)`;
}
if (quote.direction === '하락') {
return `(-${quote.changePercentText}% ▼)`;
}
return `(0.00% -)`;
}
function buildStockAlertBodyLines(quotes) {
return quotes.map((quote) => `${quote.name} ${quote.priceText}${formatStockAlertPercent(quote)}`);
}
async function processStockAlertPlan(item) {
if (isRegisteredCurrentPriceStockAlertRequest(item.note)) {
return processRegisteredCurrentPriceStockAlertPlan(item);
}
const requestedCompanies = resolveRequestedStockCompanies(item.note);
if (requestedCompanies.length === 0) {
throw new Error('주가 요청에서 대상 종목을 찾지 못했습니다.');
}
const targetAppDomains = extractTargetAppDomains(item.note);
if (targetAppDomains.length === 0) {
throw new Error('주가 알림 요청에서 대상 앱 도메인을 찾지 못했습니다.');
}
reportProgress(`주가 알림 요청을 감지해 ${requestedCompanies.length}개 종목 시세를 조회하는 중입니다.`);
const quotes = await Promise.all(
requestedCompanies.map(async (company) => parseNaverStockQuote(await fetchNaverStockPage(company.code), company.code)),
);
const bodyLines = buildStockAlertBodyLines(quotes);
const notificationKey = [
'stock-alert',
targetAppDomains.join(','),
quotes.map((quote) => quote.code).join(','),
].join(':');
const sendResult = await request('/notifications/send', {
method: 'POST',
body: JSON.stringify({
title: DEFAULT_STOCK_ALERT_TITLE,
body: bodyLines.join('\n'),
threadId: `stock-alert:${targetAppDomains.join(',')}`,
targetAppDomains,
data: {
category: 'stock-alert',
eventType: 'stock-alert',
notificationKey,
source: 'finance.naver.com',
symbols: quotes.map((quote) => quote.code).join(','),
},
}),
});
const webSentCount = Number(sendResult?.web?.sentCount ?? 0);
const webFailedCount = Number(sendResult?.web?.failedCount ?? 0);
if (webSentCount <= 0) {
throw new Error(
`대상 도메인(${targetAppDomains.join(', ')})에 보낼 Web Push 구독이 없거나 발송에 실패했습니다. failed=${webFailedCount}`,
);
}
const summary = [
`주가 알림 전송 완료: ${targetAppDomains.join(', ')}`,
...bodyLines,
].join('\n');
const tokenUsageLine = buildTokenUsageLine(null);
await request(`/plan/items/${item.id}/source-works`, {
method: 'POST',
body: JSON.stringify({
summary,
branchName: item.assignedBranch || item.releaseTarget || 'main',
commitHash: null,
changedFiles: [],
commandLog: [
...requestedCompanies.map((company) => `fetch ${NAVER_STOCK_ITEM_URL}${company.code}`),
tokenUsageLine,
`POST /api/notifications/send targetAppDomains=${targetAppDomains.join(',')}`,
].join('\n'),
diffText: null,
}),
});
await request(`/plan/items/${item.id}/actions/note`, {
method: 'POST',
body: JSON.stringify({
actionType: '자동완료메모',
actionNote: [
summary,
tokenUsageLine,
quotes.some((quote) => quote.quotedAt) ? `시세 기준: ${quotes.map((quote) => quote.quotedAt).filter(Boolean).join(' / ')}` : null,
`notificationKey: ${notificationKey}`,
`web sent=${webSentCount}, failed=${webFailedCount}`,
]
.filter(Boolean)
.join('\n'),
}),
});
await request(`/plan/items/${item.id}/actions/complete`, {
method: 'POST',
body: JSON.stringify({
note: `주가 알림을 ${targetAppDomains.join(', ')} 대상으로 전송했습니다.`,
}),
});
return {
id: item.id,
outcome: 'stock-alert-complete',
targetAppDomains,
quotes,
sendResult,
};
}
async function processRegisteredCurrentPriceStockAlertPlan(item) {
reportProgress('주가 알림 요청을 감지해 stock알림 현재가 등록 종목 기준으로 웹푸시를 전송하는 중입니다.');
const sendResult = await request('/stock-alerts/notify-current-price', {
method: 'POST',
});
const lines = Array.isArray(sendResult?.lines)
? sendResult.lines.map((value) => String(value ?? '').trim()).filter(Boolean)
: [];
const itemCount = Number(sendResult?.itemCount ?? lines.length ?? 0);
const summary = [
sendResult?.skipped ? '주가 알림 전송 건너뜀' : '주가 알림 전송 완료',
`대상 도메인: test.sm-home.cloud`,
lines.length > 0 ? lines.join('\n') : String(sendResult?.web?.reason ?? sendResult?.ios?.reason ?? sendResult?.reason ?? '').trim(),
]
.filter(Boolean)
.join('\n');
const tokenUsageLine = buildTokenUsageLine(null);
await request(`/plan/items/${item.id}/source-works`, {
method: 'POST',
body: JSON.stringify({
summary,
branchName: item.assignedBranch || item.releaseTarget || 'main',
commitHash: null,
changedFiles: [],
commandLog: [tokenUsageLine, 'POST /api/stock-alerts/notify-current-price'].filter(Boolean).join('\n'),
diffText: null,
}),
});
await request(`/plan/items/${item.id}/actions/note`, {
method: 'POST',
body: JSON.stringify({
actionType: '자동완료메모',
actionNote: [
summary,
tokenUsageLine,
`itemCount=${Number.isFinite(itemCount) ? itemCount : lines.length}`,
`web sent=${Number(sendResult?.web?.sentCount ?? 0)}, failed=${Number(sendResult?.web?.failedCount ?? 0)}`,
]
.filter(Boolean)
.join('\n'),
}),
});
await request(`/plan/items/${item.id}/actions/complete`, {
method: 'POST',
body: JSON.stringify({
note: sendResult?.skipped
? '현재가 알림 대상이 없어 웹푸시를 건너뛰었습니다.'
: 'stock알림 현재가 등록 종목 대상으로 웹푸시를 전송했습니다.',
}),
});
return {
id: item.id,
outcome: sendResult?.skipped ? 'noop-complete' : 'stock-alert-complete',
targetAppDomains: ['test.sm-home.cloud'],
itemCount,
lines,
sendResult,
};
}
async function runCommand(command, args) {
if (command === 'git') {
await execFileAsync('git', ['-c', `safe.directory=${repoPath}`, '-C', repoPath, 'config', 'user.name', gitUserName], {
@@ -1296,6 +1618,23 @@ async function processErrorLogReviewPlan(item) {
}
const summary = summaryLines.join('\n');
const tokenUsageLine = buildTokenUsageLine(null);
await request(`/plan/items/${item.id}/source-works`, {
method: 'POST',
body: JSON.stringify({
summary,
branchName: item.assignedBranch || item.releaseTarget || 'main',
commitHash: null,
changedFiles: [],
commandLog: [
'POST /api/plan/registrations/error-logs',
tokenUsageLine,
'ERROR_LOG_REVIEW: 저장소 파일 변경 없음',
].join('\n'),
diffText: null,
}),
});
await request(`/plan/items/${item.id}/actions/note`, {
method: 'POST',
@@ -1303,6 +1642,7 @@ async function processErrorLogReviewPlan(item) {
actionType: '에러로그점검',
actionNote: [
summary,
tokenUsageLine,
'',
'Plan 게시판 등록 전용 요청으로 처리했고, 저장소 소스 수정 이력은 남기지 않았습니다.',
].join('\n'),
@@ -1330,12 +1670,16 @@ async function processPlan(item) {
return processErrorLogReviewPlan(item);
}
if (isStockAlertRequest(item.note)) {
return processStockAlertPlan(item);
}
const baselineChangedFiles = localMainMode ? await listChangedFiles() : [];
const baselineChangedPathSet = new Set(normalizeChangedPaths(baselineChangedFiles));
const codexResult = await runCodexForPlan(item);
const result = codexResult.message;
const tokenUsage = codexResult.tokenUsage;
const tokenUsageLine = tokenUsage ? `토큰 사용량: ${tokenUsage}` : null;
const tokenUsageLine = buildTokenUsageLine(tokenUsage);
const summary =
result.replace(/^DONE:\s*/, '').replace(/^NOOP:\s*/, '').trim() || '요청 검토를 완료했습니다.';
const boardPost = parseBoardPostResult(result);