feat: expand live chat and work server tools
This commit is contained in:
@@ -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(/ /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);
|
||||
|
||||
Reference in New Issue
Block a user