Release 2.9.8: 코드 에디터 로컬 모드 개선 + 프로젝트 마법사 리팩토링
- 로컬 모드 shell 차단: error:true로 변경, 시스템 프롬프트에 명시적 금지 문구 추가 - 로컬 모드 파일 자동 저장: 새 파일 생성 시 승인 팝업 없이 즉시 디스크 저장 + "💾 저장" 알림 - 계획 단계 프롬프트 강화: 파일별 주요 함수/로직/데이터흐름/주의사항 포함 - 프로젝트 마법사 디폴트화: 새 세션 생성 시 자동으로 계획 단계 진입 - _isNewProjectIntent / _isSingleFileIntent 제거: 수동 프로젝트 생성으로 대체 - 채팅 히스토리 버그 수정: codeAiClear에서 history 지우기 전 saveProjectSession 호출 - 초기 로드 버그 수정: restoreLocalDirHandle에서 채팅 패널 렌더링 누락 수정 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+276
-38
@@ -483,6 +483,15 @@ const TOOL_PROFILES: Record<SubagentProfile, Set<string>> = {
|
||||
// Track last-used filename per session for when model forgets to pass it
|
||||
const lastFilenameUsed: Map<string, string> = new Map();
|
||||
|
||||
// Code-editor sessions whose files live in the browser's local folder (File System
|
||||
// Access API), NOT the server workspace. For these, the CLIENT is the source of
|
||||
// truth: it intercepts tool_call events and applies edits to the local folder. The
|
||||
// server must NOT execute file tools against its own workspace (it would either
|
||||
// fail with "not found" for files it never received, or clutter the workspace with
|
||||
// duplicate copies). Instead it returns a synthetic success so the model proceeds.
|
||||
// Keyed by sessionId; set per-request from req.body.codeAiLocal.
|
||||
const codeAiLocalSessions: Set<string> = new Set();
|
||||
|
||||
// Skills system
|
||||
const configuredSkillsDir = (config as any).skills?.directory || path.join(CONFIG_DIR_PATH, 'skills');
|
||||
const fallbackSkillsDir = path.join(CONFIG_DIR_PATH, 'skills');
|
||||
@@ -1208,7 +1217,7 @@ function detectToolCategories(text: string, sessionId?: string): Set<string> {
|
||||
if (FILES.some(k => lower.includes(k))) cats.add('files');
|
||||
if (TASK.some(k => lower.includes(k))) cats.add('task');
|
||||
// Code AI sessions should never see task tools
|
||||
if (sessionId && String(sessionId).startsWith('code_ai_')) cats.delete('task');
|
||||
if (sessionId && /^(code_ai_|proj-plan-|proj-gen-)/.test(String(sessionId))) cats.delete('task');
|
||||
if (SCHEDULE.some(k => lower.includes(k))) cats.add('schedule');
|
||||
if (SHELL.some(k => lower.includes(k))) cats.add('shell');
|
||||
if (MEMORY.some(k => lower.includes(k))) cats.add('memory');
|
||||
@@ -1648,6 +1657,20 @@ function buildTools() {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'edit_file',
|
||||
description: 'Edit an existing file using SEARCH/REPLACE blocks. PREFERRED tool for making targeted edits — no line numbers needed. ALWAYS read_file first to get exact content. Multiple edits allowed in one call.',
|
||||
parameters: {
|
||||
type: 'object', required: ['filename', 'edits'],
|
||||
properties: {
|
||||
filename: { type: 'string', description: 'File path relative to project root (e.g. "code/snake.py")' },
|
||||
edits: { type: 'string', description: 'One or more SEARCH/REPLACE blocks. Format:\n------- SEARCH\nexact text to find (must match file character-by-character)\n=======\nreplacement text\n+++++++ REPLACE\n\nMultiple blocks are separated by the same pattern. SEARCH text MUST match exactly — copy from read_file output.' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
@@ -1904,6 +1927,8 @@ function buildTools() {
|
||||
},
|
||||
}];
|
||||
}
|
||||
// Orchestrator disabled entirely — no sub-agent delegation
|
||||
if (!isOrchestrationSkillEnabled()) return [];
|
||||
// Conservative 4B-safe mode: fixed specialist templates (multi-agent OFF)
|
||||
return [{
|
||||
type: 'function' as const,
|
||||
@@ -3123,7 +3148,7 @@ function userRequestedImageEdit(message: string): boolean {
|
||||
|
||||
async function executeTool(name: string, args: any, workspacePath: string, sessionId: string = 'default', sendSSE?: (type: string, data: any) => void, username?: string): Promise<ToolResult> {
|
||||
// Filename inference: if the model forgot to pass filename, use the last one
|
||||
const needsFilename = ['read_file', 'create_file', 'replace_lines', 'insert_after', 'delete_lines', 'find_replace', 'delete_file'];
|
||||
const needsFilename = ['read_file', 'create_file', 'replace_lines', 'insert_after', 'delete_lines', 'find_replace', 'edit_file', 'delete_file'];
|
||||
if (needsFilename.includes(name)) {
|
||||
// Normalize: secondary AI sometimes returns "path" or "file" instead of "filename"
|
||||
if (!args.filename && !args.name) {
|
||||
@@ -3139,6 +3164,32 @@ async function executeTool(name: string, args: any, workspacePath: string, sessi
|
||||
}
|
||||
}
|
||||
|
||||
// Local-folder code session: the browser owns the files, not the server
|
||||
// workspace. Short-circuit file tools with a synthetic result — the client
|
||||
// intercepts the tool_call and applies the change to the local folder. We never
|
||||
// touch the server disk here (avoids "not found" errors and workspace clutter).
|
||||
if (codeAiLocalSessions.has(sessionId)) {
|
||||
const fname = String(args.filename || args.name || '(file)');
|
||||
switch (name) {
|
||||
case 'create_file': return { name, args, result: `Created ${fname}.`, error: false };
|
||||
case 'edit_file': return { name, args, result: `Applied edits to ${fname}.`, error: false };
|
||||
case 'replace_lines': return { name, args, result: `Updated ${fname} (replaced lines ${args.start_line}-${args.end_line}).`, error: false };
|
||||
case 'insert_after': return { name, args, result: `Updated ${fname} (inserted after line ${args.after_line}).`, error: false };
|
||||
case 'delete_lines': return { name, args, result: `Updated ${fname} (deleted lines ${args.start_line}-${args.end_line}).`, error: false };
|
||||
case 'find_replace': return { name, args, result: `Updated ${fname}.`, error: false };
|
||||
case 'delete_file': return { name, args, result: `Deleted ${fname}.`, error: false };
|
||||
case 'read_file': return { name, args, result: `"${fname}"의 내용은 이미 대화 컨텍스트에 줄 번호와 함께 포함되어 있습니다. 컨텍스트의 해당 내용을 그대로 사용하세요. 컨텍스트에 없다면 사용자에게 에디터에서 파일을 열어달라고 요청하세요.`, error: false };
|
||||
case 'list_files':
|
||||
case 'search_files': return { name, args, result: `프로젝트 파일과 내용은 이미 대화 컨텍스트의 [파일: ...] 블록에 포함되어 있습니다. 별도 조회 없이 컨텍스트를 참고하세요.`, error: false };
|
||||
// Shell/run tools see the server workspace, not the client's local folder.
|
||||
// Returning a clear message prevents the model from mistaking "file not found
|
||||
// on server" for a failed create_file and re-creating the file server-side.
|
||||
case 'shell':
|
||||
case 'run_command':
|
||||
case 'bash': return { name, args, result: `[ERROR] 클라이언트 로컬 모드에서는 셸 명령을 사용할 수 없습니다. 파일은 브라우저의 로컬 폴더에 저장됩니다. shell/run_command/bash 도구를 호출하지 마세요.`, error: true };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
switch (name) {
|
||||
case 'list_files': {
|
||||
@@ -3395,6 +3446,63 @@ print(json.dumps({'slides': slides, 'total': len(prs.slides)}, ensure_ascii=Fals
|
||||
return { name, args, result: `${filename} updated`, error: false };
|
||||
}
|
||||
|
||||
case 'edit_file': {
|
||||
const filename = resolveCodeFilename(args.filename || args.name, workspacePath);
|
||||
const filePath = path.join(workspacePath, filename);
|
||||
if (!isPathInsideDir(workspacePath, filePath)) return { name, args, result: 'Access denied: path escapes workspace', error: true };
|
||||
if (!fs.existsSync(filePath)) return { name, args, result: `"${filename}" not found. Use create_file for new files.`, error: true };
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const edits = String(args.edits || '');
|
||||
// Parse SEARCH/REPLACE blocks
|
||||
const srRegex = /------- SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n\+\+\+\+\+\+\+ REPLACE/g;
|
||||
let match;
|
||||
let result = content;
|
||||
let editCount = 0;
|
||||
const failedEdits: string[] = [];
|
||||
while ((match = srRegex.exec(edits)) !== null) {
|
||||
const searchText = match[1];
|
||||
const replaceText = match[2];
|
||||
if (result.includes(searchText)) {
|
||||
result = result.replace(searchText, replaceText);
|
||||
editCount++;
|
||||
} else {
|
||||
// Fallback: try with normalized whitespace (trim each line)
|
||||
const normalize = (s: string) => s.split('\n').map(l => l.trimEnd()).join('\n');
|
||||
if (normalize(result).includes(normalize(searchText))) {
|
||||
// Find the original text with actual whitespace
|
||||
const lines = result.split('\n');
|
||||
const searchLines = searchText.split('\n');
|
||||
const normLines = lines.map(l => l.trimEnd());
|
||||
const normSearch = searchLines.map(l => l.trimEnd());
|
||||
for (let i = 0; i <= normLines.length - normSearch.length; i++) {
|
||||
if (normLines.slice(i, i + normSearch.length).join('\n') === normSearch.join('\n')) {
|
||||
const originalText = lines.slice(i, i + searchLines.length).join('\n');
|
||||
result = result.replace(originalText, replaceText);
|
||||
editCount++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const firstLine = searchText.split('\n')[0]?.slice(0, 80) || '(empty)';
|
||||
failedEdits.push(firstLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (editCount > 0) {
|
||||
fs.writeFileSync(filePath, result, 'utf-8');
|
||||
const msg = `${filename}: ${editCount} edit(s) applied`;
|
||||
if (failedEdits.length > 0) {
|
||||
return { name, args, result: `${msg}. ${failedEdits.length} edit(s) could not find exact match: ${failedEdits.join('; ')}. Use read_file to verify exact content.`, error: false };
|
||||
}
|
||||
return { name, args, result: msg, error: false };
|
||||
}
|
||||
// No srRegex matches — check if the model used a different format
|
||||
if (!edits.includes('------- SEARCH') && edits.length > 0) {
|
||||
return { name, args, result: `No SEARCH/REPLACE blocks found. Use the format:\n------- SEARCH\nexact text\n=======\nreplacement\n+++++++ REPLACE`, error: true };
|
||||
}
|
||||
return { name, args, result: `No edits applied. SEARCH text not found in "${filename}". Use read_file to verify exact content, including whitespace and indentation. Failed matches:\n${failedEdits.join('\n')}`, error: true };
|
||||
}
|
||||
|
||||
case 'delete_file': {
|
||||
const filename = resolveCodeFilename(args.filename || args.name, workspacePath);
|
||||
const filePath = path.join(workspacePath, filename);
|
||||
@@ -4844,6 +4952,7 @@ async function handleChat(
|
||||
modelOverride?: string,
|
||||
executionMode: ExecutionMode = 'interactive',
|
||||
username?: string,
|
||||
historyOverride?: Array<{ role: string; content: string }>,
|
||||
): Promise<HandleChatResult> {
|
||||
const ollama = getOllamaClient();
|
||||
const isBootStartupTurn = /\bBOOT\.md\b/i.test(String(callerContext || ''));
|
||||
@@ -4860,13 +4969,18 @@ async function handleChat(
|
||||
setWorkspace(sessionId, workspacePath, username);
|
||||
}
|
||||
const historyTurns = (getConfig().getConfig() as any)?.session?.historyTurns ?? 8;
|
||||
const history = getHistoryForApiCall(sessionId, historyTurns, username);
|
||||
const history = historyOverride
|
||||
? historyOverride.map(m => ({ role: m.role as 'user' | 'assistant', content: String(m.content) }))
|
||||
: getHistoryForApiCall(sessionId, historyTurns, username);
|
||||
const isCodeAiSession = String(sessionId || '').startsWith('code_ai_');
|
||||
const isTranslateSession = String(sessionId || '').startsWith('translate-');
|
||||
// ── Code AI session: block tools that break streaming ───────────────────
|
||||
// create_file writes to disk in one shot (breaks live streaming).
|
||||
// start_task / task_control would spawn background tasks (inappropriate in editor).
|
||||
const codeAiBlockedTools = new Set(['start_task', 'task_control', 'create_file', 'python_eval', 'shell', 'run_command']);
|
||||
const isProjSession = String(sessionId || '').startsWith('proj-');
|
||||
// ── Code AI session: allow file/shell tools, block inappropriate ones ────
|
||||
// Code AI can now use read_file, create_file, replace_lines, insert_after,
|
||||
// delete_lines, list_files, shell, python_eval, and web_search to directly
|
||||
// inspect and fix code. Blocked tools are ones that don't make sense in
|
||||
// an editor context (browser automation, messaging, timers, presentations, etc.)
|
||||
const codeAiBlockedTools = new Set(['start_task', 'task_control', 'write_note', 'browser_open', 'browser_snapshot', 'browser_click', 'browser_fill', 'browser_press_key', 'browser_wait', 'browser_close', 'image_search', 'image_url', 'image_edit', 'image_info', 'image_read', 'weather_search', 'start_timer', 'send_message', 'send_telegram', 'create_presentation', 'edit_presentation', 'spawn_subagent', 'schedule_job', 'parse_schedule_pattern']);
|
||||
const weatherToolNames = new Set(['weather_search', 'weather_airpollution', 'weather_openmeteo', 'weather_kma', 'weather_airkorea', 'weather_nasa_power', 'weather_era5', 'weather_cds', 'weather_cmip6']);
|
||||
const legalToolNames = new Set(['korean_law_search', 'korean_law_fetch', 'us_case_search']);
|
||||
const pptxToolNames = new Set(['create_presentation', 'edit_presentation']);
|
||||
@@ -4883,18 +4997,17 @@ async function handleChat(
|
||||
};
|
||||
const tools = isBootStartupTurn
|
||||
? buildTools().filter((t: any) => bootAllowedTools.has(String(t?.function?.name || '')))
|
||||
: isTranslateSession
|
||||
: isTranslateSession || isProjSession
|
||||
? []
|
||||
: buildTools().filter((t: any) => {
|
||||
if (isCodeAiSession && codeAiBlockedTools.has(String(t?.function?.name || ''))) return false;
|
||||
return skillToolFilter(t);
|
||||
});
|
||||
: isCodeAiSession
|
||||
? buildTools().filter((t: any) => !codeAiBlockedTools.has(String(t?.function?.name || '')))
|
||||
: buildTools().filter((t: any) => skillToolFilter(t));
|
||||
const allToolResults: ToolResult[] = [];
|
||||
let allThinking = '';
|
||||
let preflightRoute: 'primary_direct' | 'primary_with_plan' | 'secondary_chat' | 'background_task' | null = null;
|
||||
let preflightReasonForTurn = '';
|
||||
let continuationNudges = 0;
|
||||
const MAX_CONTINUATION_NUDGES = 2;
|
||||
const MAX_CONTINUATION_NUDGES = 5;
|
||||
const orchestrationSkillEnabled = isOrchestrationSkillEnabled();
|
||||
const greetingLikeTurn = isGreetingLikeMessage(message);
|
||||
|
||||
@@ -5073,8 +5186,14 @@ async function handleChat(
|
||||
const orchestrationState = new OrchestrationTriggerState();
|
||||
const orchestrationLog: string[] = [];
|
||||
const orchestrationStats = getOrchestrationSessionStats(sessionId);
|
||||
// Cached once per turn — used by browser interception, preempt nudge, and advisor calls
|
||||
const multiAgentActive = orchestrationSkillEnabled && ((getOrchestrationConfig()?.enabled) ?? false);
|
||||
// Cached once per turn — used by browser interception, preempt nudge, and advisor calls.
|
||||
// Code-editor and project-generation sessions are interactive, fast, and run their
|
||||
// own dedicated flow (codeAiSystemPrompt + client-side tool application + own retry).
|
||||
// They must NEVER engage the orchestrator — doing so previously routed an editor turn
|
||||
// to a background task, which (on a stall) triggered the self-healer loop and spammed
|
||||
// Telegram escalations. Keep all orchestration off for these sessions.
|
||||
const multiAgentActive = orchestrationSkillEnabled && ((getOrchestrationConfig()?.enabled) ?? false)
|
||||
&& !isCodeAiSession && !isProjSession;
|
||||
const fileOpV2Active = multiAgentActive
|
||||
&& fileOpSettings.enabled
|
||||
&& (fileOpClassification.type === 'FILE_ANALYSIS' || fileOpClassification.type === 'FILE_CREATE' || fileOpClassification.type === 'FILE_EDIT');
|
||||
@@ -5186,7 +5305,7 @@ async function handleChat(
|
||||
continue;
|
||||
}
|
||||
if (isCodeAiSession && codeAiBlockedTools.has(toolName)) {
|
||||
allToolResults.push({ name: toolName, args: toolArgs, result: `[BLOCKED] ${toolName} is not available in Code mode. Output the code as a fenced code block instead.`, error: true });
|
||||
allToolResults.push({ name: toolName, args: toolArgs, result: `[BLOCKED] ${toolName} is not available in Code mode. Use the available file/shell tools instead.`, error: true });
|
||||
continue;
|
||||
}
|
||||
const toolResult = await executeTool(toolName, toolArgs, workspacePath, sessionId, undefined, username);
|
||||
@@ -5296,21 +5415,68 @@ async function handleChat(
|
||||
// coding system prompt with NO main persona, NO user skills (meteorologist/
|
||||
// presenter…), and NO "keep responses short" rule (which otherwise made it
|
||||
// answer a "make space invaders" request with a single sentence and no code).
|
||||
const codeAiSystemPrompt = `You are a coding assistant embedded directly in the user's code editor. Current date: ${dateStr}, ${timeStr}.
|
||||
const isCodeAiLocalSession = codeAiLocalSessions.has(String(sessionId));
|
||||
const codeAiSystemPrompt = `You are an expert coding assistant — like Cline — embedded in the user's code editor. Current date: ${dateStr}, ${timeStr}.
|
||||
|
||||
RULES:
|
||||
1. When CREATING a new file, output the FULL code as ONE fenced code block with a filename comment on line 1. When EDITING an existing file, output ONLY the changed parts — use a fenced diff block like \`\`\`diff showing only added/removed/changed lines, prefixed with + or - and the line numbers. The code streams live into the editor — do NOT use create_file or shell tools.
|
||||
2. ALWAYS start the code with a filename comment on line 1: \`# filename: snake_game.py\` (Python), \`// filename: app.js\` (JS/TS/C/C++/Java/Rust), \`<!-- filename: index.html -->\` (HTML). Pick a descriptive name reflecting what the code DOES — never "main", "script", "untitled".
|
||||
3. When the user asks to MODIFY or UPGRADE existing code, use the SAME filename as the current file shown in the context. Output a DIFF block (not the whole file) — show only lines that changed, with - for removed lines and + for added lines, including line number context so the editor can apply the changes precisely.
|
||||
4. NEVER output the entire file when only parts changed. Outputting unchanged lines wastes tokens, time, and UX. ALWAYS use a \`\`\`diff block for edits — the editor applies diffs live. Full-file output for edits is a CRITICAL ERROR.
|
||||
5. NEVER repeat code you already wrote in this conversation. If continuing after a cutoff, write ONLY the remaining lines — do NOT restart from the beginning.
|
||||
6. Write code directly. Do NOT ask "진행할까요?" or wait for approval — all changes are shown as a diff for the user to review and accept or reject. Just write the code. You may briefly explain what you're changing (1 sentence) before the code block, but never ask for permission.
|
||||
7. If the request is ambiguous, ask a short clarifying question. Otherwise, proceed immediately.
|
||||
8. NEVER run pip install, npm install, apt-get, or any package installation command. The user's environment already has the necessary packages — if a package is missing, just write the code and let the user decide whether to install it. Do NOT attempt to install packages yourself.`;
|
||||
You MUST use tools to read and edit files. NEVER output code in markdown fenced blocks (\\\`\\\`\\\`). Every code change goes through a tool call. Analysis without action is a critical failure.${isCodeAiLocalSession ? `
|
||||
|
||||
CLIENT-LOCAL MODE: Files are stored in the user's local browser folder via the File System Access API — NOT on the server. The shell, run_command, and bash tools are NOT available in this mode and will return an error if called. After creating or editing files, tell the user the files are ready and they can open them in their browser. NEVER call shell/run_command/bash to open, run, or verify files in this mode.` : ''}
|
||||
|
||||
CONTEXT: The current file and all open project files are ALREADY provided in the conversation WITH line numbers (look for "[현재 파일: …]" and "[파일: …]" blocks). Do NOT call read_file for a file whose content is already shown — edit it directly. Only call read_file for a file that is NOT present in the context.
|
||||
|
||||
AVAILABLE TOOLS:
|
||||
- read_file(filename): Read file content WITH line numbers. Use ONLY for files not already shown in the context above.
|
||||
- list_files(directory): List files in a directory.
|
||||
- search_files(pattern): Search file contents with regex.
|
||||
- edit_file(filename, edits): PREFERRED edit tool. Uses SEARCH/REPLACE blocks — no line numbers needed.
|
||||
- create_file(filename, content): Create a new file. For existing files, use edit_file or replace_lines instead.
|
||||
- replace_lines(filename, start_line, end_line, new_content): Replace lines by number (1-based).
|
||||
- insert_after(filename, after_line, content): Insert lines after a line number (0 = beginning).
|
||||
- delete_lines(filename, start_line, end_line): Delete lines by number.
|
||||
- find_replace(filename, find, replace): Find exact text and replace.
|
||||
- shell(command): Run terminal commands to test code.
|
||||
- python_eval(code): Run Python code.
|
||||
- web_search(query): Search the web.
|
||||
|
||||
edit_file FORMAT (PREFERRED for all edits):
|
||||
------- SEARCH
|
||||
exact text to find (must match the file CHARACTER-BY-CHARACTER including indentation and whitespace)
|
||||
=======
|
||||
replacement text (leave empty between ======= and +++++++ to delete)
|
||||
+++++++ REPLACE
|
||||
|
||||
Multiple edits: list SEARCH/REPLACE blocks in file order. Each SEARCH text must match EXACTLY — copy it verbatim from read_file output. Do NOT modify the marker format.
|
||||
|
||||
TOOL USE RULES:
|
||||
- Use ONE tool per message. Wait for the result before proceeding.
|
||||
- The current/open files are already in context with line numbers — edit them directly without read_file. Only read_file for files NOT shown.
|
||||
- Default to edit_file for most changes. Use create_file only for new files.
|
||||
- After editing: briefly state what you changed (1 line). No extended explanations.
|
||||
- NEVER output code in markdown fences (\\\`\\\`\\\`). ALL code changes via tool calls.
|
||||
- NEVER start your message with "Great", "Certainly", "Okay", "Sure", or similar filler.
|
||||
- Do not ask for more information than necessary. Use tools to find answers.
|
||||
|
||||
WORKFLOW:
|
||||
1. READ: Understand the current code from the context (already provided). read_file only for files not shown.
|
||||
2. EDIT: Use edit_file (preferred) or replace_lines to make changes.
|
||||
3. VERIFY: Use shell to run the code if applicable.
|
||||
|
||||
DEBUGGING (when the user shows an error):
|
||||
1. Read the FULL error message — the exact file/line is there.
|
||||
2. Inspect the file at the error line (it is already in context with line numbers; read_file only if not shown).
|
||||
3. Use edit_file to fix ONLY the lines that caused the error.
|
||||
4. Use shell to verify the fix works.
|
||||
5. If a fix doesn't work, re-read the file and try a different approach. Never repeat the same failed fix.
|
||||
|
||||
CODE QUALITY:
|
||||
- Write complete, runnable code. No placeholder TODO comments, no stub functions.
|
||||
- Stay in the same language/framework as the existing code.
|
||||
- Never run package install commands (pip, npm, apt-get).
|
||||
- If a tool call fails, re-read the file and retry with correct content.`;
|
||||
const messages: any[] = [
|
||||
{
|
||||
role: 'system',
|
||||
content: isCodeAiSession ? codeAiSystemPrompt : isTranslateSession ? `You are a medical translator. Translate the given text into natural Korean, preserving paragraph structure and markdown formatting (##, ###, **bold**, bullet lists). Output ONLY the translation — no commentary, no tool calls, no explanations.` : `${executionModeSystemBlock ? `${executionModeSystemBlock}\n\n` : ''}You are SmallClaw 🦞, a local AI assistant.\nCurrent date: ${dateStr}, ${timeStr}.\nNever search for or link SmallClaw repos unless the user is asking about SmallClaw itself.\nThis app runs on the user's own machine — browser/desktop automation requests are pre-authorized.\nKeep responses SHORT (1-2 sentences). Don't think out loud. Act and report. Greet naturally without tools.
|
||||
content: isCodeAiSession ? codeAiSystemPrompt : isTranslateSession ? `You are a medical translator. Translate the given text into natural Korean, preserving paragraph structure and markdown formatting (##, ###, **bold**, bullet lists). Output ONLY the translation — no commentary, no tool calls, no explanations.` : isProjSession ? `You are a project file designer. Output ONLY the project-files JSON block as instructed. No tool calls. No extra text.` : `${executionModeSystemBlock ? `${executionModeSystemBlock}\n\n` : ''}You are SmallClaw 🦞, a local AI assistant.\nCurrent date: ${dateStr}, ${timeStr}.\nNever search for or link SmallClaw repos unless the user is asking about SmallClaw itself.\nThis app runs on the user's own machine — browser/desktop automation requests are pre-authorized.\nKeep responses SHORT (1-2 sentences). Don't think out loud. Act and report. Greet naturally without tools.
|
||||
ANTI-HALLUCINATION: When a tool returns a result, report EXACTLY what the tool returned — never contradict or ignore tool output. If a tool says "(no rows)", say so. Never invent data, file contents, table names, or command output. If you don't know something, call a tool to find out or say you don't know.
|
||||
IMAGE EDITING RULE: NEVER call image_edit (or any editing tool) when a user uploads a photo without explicitly requesting edits. Uploading a photo is NOT a request to edit it. Only call image_edit when the user's message explicitly asks for an edit (e.g. "수채화로 바꿔줘", "회전해줘"). Violating this rule is a critical error.
|
||||
BROWSER RULE: NEVER call browser_open, browser_snapshot, browser_click, browser_fill, browser_scroll, or any browser_* tool unless the user EXPLICITLY asks to use the browser (e.g. "브라우저로 열어줘", "Chrome으로 봐줘", "사이트 직접 들어가봐"). "열어줘" or "보여줘" alone is NOT a browser request — answer from knowledge, use web_fetch/web_search, or output the relevant URL/embed. Pasting a URL or asking a question is also NOT a browser request.
|
||||
@@ -5449,6 +5615,7 @@ OUTPUT FORMAT: When presenting 3+ items (news articles, emails, search results,
|
||||
// Task runner sessions (sessionId starts with 'task_') are already inside a background task
|
||||
// execution — skip preflight entirely to prevent recursive task spawning loops.
|
||||
const isTaskRunnerSession = sessionId.startsWith('task_');
|
||||
const isCodeAiSessionForPreflight = isCodeAiSession;
|
||||
|
||||
const isDirectCall = callerContext?.includes('no_preflight');
|
||||
if (
|
||||
@@ -5456,6 +5623,7 @@ OUTPUT FORMAT: When presenting 3+ items (news articles, emails, search results,
|
||||
!isBootStartupTurn &&
|
||||
!skipGenericPreflightForFileOp &&
|
||||
!isTaskRunnerSession &&
|
||||
!isCodeAiSessionForPreflight &&
|
||||
!isDirectCall &&
|
||||
shouldRunPreflight(message, preflightCfg.preflight.mode) &&
|
||||
orchestrationStats.assistCount < preflightCfg.limits.max_assists_per_session
|
||||
@@ -6531,7 +6699,7 @@ RULES:
|
||||
tools,
|
||||
temperature: 0.3,
|
||||
num_ctx: 8192,
|
||||
num_predict: isCodeAiSession ? 16384 : (needsLongOutput ? 8192 : 4096),
|
||||
num_predict: (isCodeAiSession || isProjSession) ? 32768 : (needsLongOutput ? 32768 : 4096),
|
||||
think: primaryThinkMode,
|
||||
model: effectiveModel,
|
||||
});
|
||||
@@ -6727,15 +6895,21 @@ RULES:
|
||||
// stops without producing any fenced code block. Nudge to write code.
|
||||
// (VSCode Copilot style: model should just write code, not ask for permission)
|
||||
if (!hasCodeFence) {
|
||||
const announceLike = response.content.trim().length < 240
|
||||
// Short announcement: "만들겠습니다!" (< 240 chars)
|
||||
const shortAnnounce = response.content.trim().length < 240
|
||||
&& /(겠습니다|드릴게요|드리겠|만들어\s*드리|작성하겠|짜드리|짜\s*드리|구현하겠|I[''']?ll\b|let me\b|going to)/i.test(response.content);
|
||||
// Long plan/announcement: model produced a detailed plan but no code.
|
||||
// Detects "진행하려면 만들어줘", "확인해 주세요", "알려주세요", etc.
|
||||
const longPlanAnnounce = response.content.trim().length >= 240
|
||||
&& /(진행하려면|확인해\s*주세요|알려\s*주세요|입력하거나|입력해\s*주|승인|코드를\s*작성해|작성하겠습니|시작하겠습니|시작할까요|proceed|confirm|shall I|should I|want me to)/i.test(response.content);
|
||||
const announceLike = shortAnnounce || longPlanAnnounce;
|
||||
const buildRequest = /(만들|짜줘|짜봐|작성|구현|생성|고쳐|수정|build|create|make|write|implement|코드|게임|game|함수|클래스|스크립트|script|app|html|페이지)/i.test(message);
|
||||
if (announceLike && buildRequest) {
|
||||
console.log('[v2] CODE-AI: announcement without code — nudging');
|
||||
continuationNudges++;
|
||||
allThinking += (allThinking ? '\n\n' : '') + response.content;
|
||||
messages.push({ role: 'assistant', content: response.content });
|
||||
messages.push({ role: 'user', content: '코드를 작성해주세요. 기존 파일을 수정할 때는 전체 코드를 다시 출력하지 말고 ```diff 블록으로 변경된 줄만 출력하세요.' });
|
||||
messages.push({ role: 'user', content: '코드를 작성해주세요. 기존 파일을 수정할 때는 read_file로 먼저 확인한 후 replace_lines로 변경하세요. 새 파일은 create_file을 사용하세요.' });
|
||||
sendSSE('info', { message: '코드를 작성하도록 재요청...' });
|
||||
continue;
|
||||
}
|
||||
@@ -7790,11 +7964,11 @@ RULES:
|
||||
}
|
||||
// Block execution tools in Code AI sessions — the model should output code as text, not run it
|
||||
if (isCodeAiSession && codeAiBlockedTools.has(toolName)) {
|
||||
const blockedResult: ToolResult = { name: toolName, args: toolArgs, result: `[BLOCKED] ${toolName} is not available in Code mode. Output the code as a fenced code block instead — do not execute it.`, error: true };
|
||||
const blockedResult: ToolResult = { name: toolName, args: toolArgs, result: `[BLOCKED] ${toolName} is not available in Code mode. Use read_file, replace_lines, insert_after, create_file, shell, or web_search instead.`, error: true };
|
||||
allToolResults.push(blockedResult);
|
||||
sendSSE('tool_result', { action: toolName, result: blockedResult.result, error: true, stepNum: allToolResults.length });
|
||||
messages.push({ role: 'tool', name: toolName, tool_call_id: toolCallId || undefined, content: blockedResult.result });
|
||||
messages.push({ role: 'user', content: `Tool ${toolName} is blocked in Code mode. Write the code in a fenced code block with a filename comment on line 1 instead of executing it. Example:\n\`\`\`python\n# filename: tetris.py\nimport pygame\n...\n\`\`\`` });
|
||||
messages.push({ role: 'user', content: `Tool ${toolName} is blocked in Code mode. Use the available file tools (read_file, replace_lines, insert_after, create_file) or shell to accomplish this instead.` });
|
||||
continue;
|
||||
}
|
||||
const toolResult = await executeTool(toolName, toolArgs, workspacePath, sessionId, sendSSE, username);
|
||||
@@ -10515,6 +10689,27 @@ app.post('/api/upload/file', requireGatewayAuth, (req: express.Request, res: exp
|
||||
});
|
||||
req.on('error', (err: any) => { res.status(500).json({ success: false, error: String(err?.message || err) }); });
|
||||
});
|
||||
// ── Settings: paths (code dir) ───────────────────────────────────────────
|
||||
app.get('/api/settings/paths', (_req: express.Request, res: express.Response) => {
|
||||
const cfg = getConfig().getConfig() as any;
|
||||
res.json({ code_dir: cfg?.workspace?.codeDir || 'code' });
|
||||
});
|
||||
|
||||
app.post('/api/settings/paths', (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const { code_dir } = req.body || {};
|
||||
if (!code_dir || typeof code_dir !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'code_dir required' }); return;
|
||||
}
|
||||
const safe = code_dir.replace(/[^a-zA-Z0-9가-힣._-]/g, '_').replace(/^_|_$/g, '') || 'code';
|
||||
const cfg = getConfig().getConfig() as any;
|
||||
getConfig().updateConfig({ workspace: { ...(cfg?.workspace || {}), codeDir: safe } } as any);
|
||||
res.json({ success: true, code_dir: safe });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, error: String(e?.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Code editor: save file to workspace/code/ ────────────────────────────
|
||||
function getCodeWorkspacePath(req: express.Request): string {
|
||||
const user = (req as any).user;
|
||||
@@ -10595,6 +10790,36 @@ app.delete('/api/code/file', (req: express.Request, res: express.Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Code editor: scaffold a new project (dirs + files) ───────────────────
|
||||
app.post('/api/code/scaffold', (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const { project, files } = req.body || {};
|
||||
if (!project || typeof project !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'project name required' }); return;
|
||||
}
|
||||
if (!Array.isArray(files) || files.length === 0) {
|
||||
res.status(400).json({ success: false, error: 'files array required' }); return;
|
||||
}
|
||||
const codeDir = getCodeDir(req);
|
||||
const projectSlug = project.replace(/[^a-zA-Z0-9가-힣._-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '');
|
||||
const projectDir = path.join(codeDir, projectSlug);
|
||||
|
||||
const created: string[] = [];
|
||||
for (const f of files) {
|
||||
if (!f.path || typeof f.path !== 'string') continue;
|
||||
const rel = path.normalize(f.path).replace(/^\.\.[\\/]/, '');
|
||||
const target = path.join(projectDir, rel);
|
||||
if (!target.startsWith(projectDir)) continue;
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, f.content ?? '', 'utf-8');
|
||||
created.push(`${projectSlug}/${rel}`);
|
||||
}
|
||||
res.json({ success: true, project: projectSlug, created });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, error: String(e?.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
// ── File overwrite (PUT /api/files/:relPath) ──────────────────────────────
|
||||
// Used by the xlsx editor to save changes back to the workspace.
|
||||
app.put('/api/files/{*filePath}', (req: express.Request, res: express.Response) => {
|
||||
@@ -10859,18 +11084,26 @@ app.get('/api/status', async (_req, res) => {
|
||||
});
|
||||
|
||||
app.post('/api/chat', async (req, res) => {
|
||||
const { message, sessionId = 'default', pinnedMessages, model: reqModel, direct } = req.body;
|
||||
const { message, sessionId = 'default', pinnedMessages, model: reqModel, direct, history: clientHistory, codeAiLocal } = req.body;
|
||||
if (!message || typeof message !== 'string') { res.status(400).json({ error: 'Message required' }); return; }
|
||||
const user = (req as any).user;
|
||||
|
||||
// Record whether this code-editor turn uses the browser's local folder. The
|
||||
// server then skips disk file-tool execution (client is source of truth).
|
||||
if (/^code_ai_/.test(String(sessionId || ''))) {
|
||||
if (codeAiLocal) codeAiLocalSessions.add(String(sessionId));
|
||||
else codeAiLocalSessions.delete(String(sessionId));
|
||||
}
|
||||
|
||||
// Pre-initialise the session under the authenticated user's directory so that
|
||||
// all subsequent addMessage / getHistory calls land in the right place.
|
||||
// ensureUserWorkspace() bootstraps the user's workspace on first login.
|
||||
const _isCodeEditorSid = /^(code_ai_|proj-plan-|proj-gen-)/.test(String(sessionId || ''));
|
||||
if (user?.username) {
|
||||
try { ensureUserWorkspace(user.username); } catch { /* non-fatal */ }
|
||||
getSession(String(sessionId || 'default'), user.username);
|
||||
if (!_isCodeEditorSid) getSession(String(sessionId || 'default'), user.username);
|
||||
}
|
||||
if (user?.workspace) setWorkspace(String(sessionId || 'default'), user.workspace, user?.username);
|
||||
if (!_isCodeEditorSid && user?.workspace) setWorkspace(String(sessionId || 'default'), user.workspace, user?.username);
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
lastMainSessionId = String(sessionId || 'default');
|
||||
@@ -10896,8 +11129,11 @@ app.post('/api/chat', async (req, res) => {
|
||||
});
|
||||
|
||||
try {
|
||||
const isCodeEditorSessionEarly = /^(code_ai_|proj-plan-|proj-gen-)/.test(String(sessionId));
|
||||
const userMsg = { role: 'user' as const, content: message, timestamp: Date.now() };
|
||||
const addResult = addMessage(sessionId, userMsg, { deferOnMemoryFlush: true, deferOnCompaction: true }, user?.username);
|
||||
const addResult = isCodeEditorSessionEarly
|
||||
? { deferredForCompaction: false, deferredForMemoryFlush: false, compactionPrompt: undefined, memoryFlushPrompt: undefined, estimatedTokens: 0, contextLimitTokens: 0 }
|
||||
: addMessage(sessionId, userMsg, { deferOnMemoryFlush: true, deferOnCompaction: true }, user?.username);
|
||||
if (addResult.deferredForCompaction && addResult.compactionPrompt) {
|
||||
console.log(`[v2] Context compaction triggered for session ${sessionId} (${addResult.estimatedTokens}/${addResult.contextLimitTokens} est. tokens)`);
|
||||
try {
|
||||
@@ -10976,11 +11212,13 @@ app.post('/api/chat', async (req, res) => {
|
||||
const pins = Array.isArray(pinnedMessages) ? pinnedMessages.slice(0, 3) : [];
|
||||
const modelOverride = reqModel ? String(reqModel).trim() || undefined : undefined;
|
||||
const callerCtx = direct ? 'no_preflight' : undefined;
|
||||
const result = await handleChat(message, sessionId, sendSSE, pins.length > 0 ? pins : undefined, abortSignal, callerCtx, modelOverride, 'interactive', user?.username);
|
||||
const isCodeEditorSession = /^(code_ai_|proj-plan-|proj-gen-)/.test(String(sessionId));
|
||||
const histOverride = (isCodeEditorSession && Array.isArray(clientHistory)) ? clientHistory : undefined;
|
||||
const result = await handleChat(message, sessionId, sendSSE, pins.length > 0 ? pins : undefined, abortSignal, callerCtx, modelOverride, 'interactive', user?.username, histOverride);
|
||||
if (!abortSignal.aborted) {
|
||||
result.text = await validateLinksInText(result.text);
|
||||
console.log(`[v2] ASSISTANT [${userTag}]: ${result.text}`);
|
||||
addMessage(sessionId, { role: 'assistant', content: result.text, timestamp: Date.now() }, {}, user?.username);
|
||||
if (!isCodeEditorSession) addMessage(sessionId, { role: 'assistant', content: result.text, timestamp: Date.now() }, {}, user?.username);
|
||||
sendSSE('final', { text: result.text });
|
||||
sendSSE('done', {
|
||||
reply: result.text, mode: result.type,
|
||||
|
||||
+1436
-188
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user