v4.3.12: buildPersonalityContext → gateway/chat/personality-context.ts 분리 (5,455→5,021줄)
detectToolCategories/TOOL_BLOCKS/readMemorySnippets 등 관련 헬퍼 클러스터를 createPersonalityContext(resolvePromptPath) 팩토리로 통째 이동. 외부 의존은 resolvePromptPath 하나뿐이라 순환 의존성 없이 분리 가능했음.
This commit is contained in:
+2
-436
@@ -69,6 +69,7 @@ import { registerChatSessionRoutes } from './routes/routes-chat-sessions';
|
||||
import { createBuildTools } from './chat/build-tools';
|
||||
import { createExecuteTool, type ToolResult } from './chat/execute-tool';
|
||||
import { createHandleChat } from './chat/handle-chat';
|
||||
import { createPersonalityContext } from './chat/personality-context';
|
||||
import { registerOpenAIAuthRoutes } from './routes/routes-auth-openai';
|
||||
import { registerUserAppSessionRoutes } from './routes/routes-user-app-sessions';
|
||||
import { registerChannelMappingRoutes } from './routes/routes-channel-mappings';
|
||||
@@ -1180,442 +1181,7 @@ function resolvePromptPath(workspacePath: string, filename: string): string {
|
||||
return path.join(workspacePath, filename);
|
||||
}
|
||||
|
||||
function loadWorkspaceFile(workspacePath: string, filename: string, maxChars: number = 500): string {
|
||||
try {
|
||||
const filePath = resolvePromptPath(workspacePath, filename);
|
||||
if (!fs.existsSync(filePath)) return '';
|
||||
let content = fs.readFileSync(filePath, 'utf-8').trim();
|
||||
content = content.replace(/<!--[\s\S]*?-->/g, '');
|
||||
content = content.replace(/\n{3,}/g, '\n\n').trim();
|
||||
if (content.length <= maxChars) return content;
|
||||
return content.slice(0, maxChars) + '\n...(truncated)';
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
function readDailyMemoryContext(workspacePath: string, maxTokens: number = 800): string {
|
||||
try {
|
||||
const memDir = path.join(workspacePath, 'memory');
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
|
||||
const sections: string[] = [];
|
||||
|
||||
for (const day of [yesterday, today]) {
|
||||
const p = path.join(memDir, `${day}.md`);
|
||||
if (!fs.existsSync(p)) continue;
|
||||
const raw = fs.readFileSync(p, 'utf-8').trim();
|
||||
if (!raw) continue;
|
||||
sections.push(`### Memory: ${day}\n${raw}`);
|
||||
}
|
||||
|
||||
if (!sections.length) return '';
|
||||
|
||||
let combined = sections.join('\n\n');
|
||||
const charLimit = Math.floor(maxTokens * 3.5);
|
||||
if (combined.length > charLimit) {
|
||||
combined = combined.slice(-charLimit);
|
||||
}
|
||||
return `\n\n## Recent Memory Notes\n${combined}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Intent detection: returns matched tool categories for the message
|
||||
function detectToolCategories(text: string, sessionId?: string): Set<string> {
|
||||
const lower = String(text || '').toLowerCase();
|
||||
const cats = new Set<string>();
|
||||
const WEB = ['search', 'find', 'look up', 'google', 'what is', 'who is', 'news', 'latest', 'research', 'look into', 'check online', 'summarize', 'article', 'read about',
|
||||
'뉴스', '국제 뉴스', '국내 뉴스', '최신 뉴스', '헤드라인', '검색해', '찾아봐', '알아봐', '조사해', '검색', '찾아줘'];
|
||||
// 'open'/'browse'/'download' removed — too broad; 'download' belongs to SHELL
|
||||
const BROWSER = ['website', 'click', 'fill', 'form', 'navigate', 'go to', 'sign in', 'login', 'log in', 'open website', 'open browser', 'open url', 'open tab', 'web browser', 'browser_'];
|
||||
// 'window'/'app'/'screen' removed — too broad
|
||||
const DESKTOP = ['desktop', 'screenshot', 'focus window', 'type into', 'drag', 'clipboard', 'desktop_', 'take screenshot'];
|
||||
const FILES = ['read file', 'write file', 'edit file', 'create file', 'modify', 'replace', 'open file', 'delete file', 'rename', 'copy file', 'make a file', 'update the file', 'change the file', 'save to'];
|
||||
// 'task'/'background'/'run this'/'start a'/'status'/'paused'/'resume' removed — too broad
|
||||
const TASK = ['background task', 'task status', 'what tasks', 'running tasks', 'in progress', 'paused task', 'resume task', 'task list', 'start a task', 'task_control', 'start_task'];
|
||||
// 'at ' removed — matches virtually any sentence
|
||||
const SCHEDULE = ['schedule', 'every day', 'every week', 'recurring', 'cron', 'automate', 'remind me', 'daily', 'weekly'];
|
||||
const SHELL = ['run command', 'execute', 'terminal', 'powershell', 'script', 'command line', 'cmd', 'bash', 'python', 'pip', 'npm', 'node', 'run script', 'shell', 'download', 'curl', 'save image', 'save file', 'install'];
|
||||
const MEMORY = ['remember', 'note that', 'save that', 'write that down', 'dont forget', "don't forget", 'keep in mind', 'update my', 'add to my', 'i prefer', 'i like', 'i hate', 'i use', 'my name', 'call me', 'i work', 'my project', 'my stack'];
|
||||
const PPTX = ['pptx', 'powerpoint', 'presentation', '슬라이드', '발표 자료', '프레젠테이션'];
|
||||
const PUBMED = ['pubmed', 'pmc', 'pmid', 'ncbi', 'medline', 'pubmed_search', 'pubmed_fetch', 'pubmed_fulltext', '논문', 'fulltext', 'full text', '전문 다운', '전문다운', 'abstract', 'mesh'];
|
||||
const SCHOLAR = ['openalex', 'semantic scholar', 'openalex_search', 'semantic_search', 'google scholar', 'semanticscholar', 'paper search', '논문 검색', '학술 검색', '학술논문', 'citation', '인용', 'arxiv', 'preprint'];
|
||||
// 'why' removed — matches casual questions; keep specific error/debug terms
|
||||
const DEBUG = ['error', 'failed', 'how does', 'architecture', 'debug', 'caused', 'broke', 'not working', 'explain how', 'whats wrong', "what's wrong"];
|
||||
const PHOTO =['사진', '이미지', 'photo', 'image', 'picture', 'search_images', 'find photo', 'find image', 'show photo', 'show image', '이미지 편집', '사진 편집', '이미지 수정', '말풍선', '워터마크', '배경 제거', '크롭', '리사이즈', '애니메이션 스타일', '유화', '수채화', '스케치', '만화체', '그림체', '그림으로', '스타일 변환', 'stylize', 'anime style', 'cartoon style'];
|
||||
// Bare "소식" is excluded from WEB above and checked separately so the personal-news idiom
|
||||
// ("무슨 좋은 소식 있어?") doesn't pull in the news_search hint block — matches the same
|
||||
// exclusion isLiveDataRequest applies in prompt-gates.ts.
|
||||
if (WEB.some(k => lower.includes(k)) || (lower.includes('소식') && !PERSONAL_NEWS_IDIOM.test(text))) cats.add('web');
|
||||
if (BROWSER.some(k => lower.includes(k))) cats.add('browser');
|
||||
if (DESKTOP.some(k => lower.includes(k))) cats.add('desktop');
|
||||
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 && /^(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');
|
||||
if (PPTX.some(k => lower.includes(k))) cats.add('pptx');
|
||||
if (PUBMED.some(k => lower.includes(k)) || /PMC\d+|PMID\s*\d+/i.test(text)) cats.add('pubmed');
|
||||
if (SCHOLAR.some(k => lower.includes(k))) cats.add('scholar');
|
||||
const PDF = ['pdf', '.pdf', 'pdf_read', 'pdf_extract', '피디에프', '논문 파일', 'pdf 파일', '이미지 추출', '도표 추출', '그림 추출', '사진 추출', '논문'];
|
||||
// Also add pdf when pptx + any paper/doc cue — guarantees pdf tools are hinted for PDF→PPTX
|
||||
if (PPTX.some(k => lower.includes(k)) && ['논문', 'paper', 'document', '보고서', '문서'].some(k => lower.includes(k))) cats.add('pdf');
|
||||
const IMAGE_OCR = ['ocr', 'image_read', '이미지 읽기', '이미지 텍스트', '스크린샷 텍스트', 'extract text from image', 'read image'];
|
||||
const PYTHON = ['python', 'python_eval', '파이썬', 'pandas', 'numpy', 'matplotlib', 'scipy', 'csv 분석', 'csv 처리', 'data analysis', '데이터 분석', '수식 계산', 'calculate', 'compute'];
|
||||
const SQLITE = ['sqlite', 'sqlite_query', '.db', 'database query', 'sql query', 'sql 쿼리', '데이터베이스', '쿼리'];
|
||||
if (PDF.some(k => lower.includes(k))) cats.add('pdf');
|
||||
if (IMAGE_OCR.some(k => lower.includes(k))) cats.add('image_ocr');
|
||||
if (PYTHON.some(k => lower.includes(k))) cats.add('python');
|
||||
if (SQLITE.some(k => lower.includes(k))) cats.add('sqlite');
|
||||
const SPAWN = ['논문 리서치', '논문 분석', '논문 요약', '여러 논문', '다수 논문', 'pubmed_researcher', 'spawn_agent'];
|
||||
if (SPAWN.some(k => lower.includes(k))) cats.add('spawn');
|
||||
if (DEBUG.some(k => lower.includes(k))) cats.add('debug');
|
||||
if (PHOTO.some(k => lower.includes(k))) cats.add('photo');
|
||||
const WEATHER = ['weather', 'forecast', 'temperature', 'humidity', '날씨', '기온', '기상', '예보', '날씨 알려', '오늘 날씨', '내일 날씨', '이번 주 날씨', '미세먼지', '대기질', '황사', 'pm2.5', 'pm10', '대기오염', '기상청', '초단기', '단기예보', 'weather_search', 'weather_kma', 'weather_airkorea', 'weather_openmeteo', 'weather_airpollution'];
|
||||
if (WEATHER.some(k => lower.includes(k))) cats.add('weather');
|
||||
const EMAIL = ['email', '이메일', '메일', 'inbox', '받은편지함', '받은 메일', '메일함', '메일 보내', '메일 읽', '메일 검색', '메일 삭제', 'email_list', 'email_send', 'email_read', 'email_search', 'email_delete'];
|
||||
if (EMAIL.some(k => lower.includes(k))) cats.add('email');
|
||||
const KAKAO = ['카카오톡', '카톡', 'kakao', 'kakaotalk', 'kakao_send_message'];
|
||||
if (KAKAO.some(k => lower.includes(k))) cats.add('kakao');
|
||||
const OLLAMA_SEARCH = ['ollama 검색', 'ollama search', 'ollama_web', 'ollama web', '올라마 검색', '올라마로 검색', '올라마 웹'];
|
||||
if (OLLAMA_SEARCH.some(k => lower.includes(k))) cats.add('ollama_web');
|
||||
return cats;
|
||||
}
|
||||
|
||||
// Tool rule blocks — compact, injected only when relevant
|
||||
const TOOL_BLOCKS: Record<string, string> = {
|
||||
web: `WEB TOOLS: web_search(query) → headlines+snippets. web_fetch(url) → full page text. Use web_search first to get URLs, then web_fetch to read. For Reddit: web_search with site:reddit.com "keyword", then web_fetch post URLs — never open browser for Reddit. IMPORTANT: NEVER use web_fetch or web_search for local URLs (localhost, 127.0.0.1, /api/files/...) — they are NOT web pages. Local files are already accessible in chat via  or [name](/api/files/name.pptx). SEARCH BUDGET: soft budget of 5 web_search/web_fetch calls combined per request — pick the most relevant angles up front rather than searching topic-by-topic one at a time. Once you have enough to answer, stop searching and write the answer with what you have; do not keep searching for full/exhaustive coverage. If the request genuinely needs more than 5 distinct lookups (e.g. checking the same thing for 8 different cities), that's fine — going past 5 is allowed as long as each additional call covers a genuinely new topic/entity, not a re-search of something you already covered; there is a hard cap of 12 calls total regardless. NEWS: use news_search FIRST for any "오늘 뉴스"/breaking-news request — it returns real articles with real publish dates, not generic web pages. Only fall back to web_search for news if news_search errors or returns nothing useful. NEWS QUERY STRATEGY (for web_search fallback / general search): for Korean domestic news, a Korean-language query works well (e.g. "2026년 7월 16일 국내 뉴스 헤드라인"). For international/world news, the underlying search engines index English far better — search in English (e.g. "international news today", "world news headlines July 16 2026") rather than a Korean query, and do NOT bolt a single news outlet name onto the query (e.g. "... CNN", "... Reuters") — that tends to return the outlet's generic homepage instead of an actual headline. If your first search returns only a generic reference page (e.g. a Wikipedia year-overview) or a homepage instead of real headlines, that query failed — try a different phrasing rather than repeating close variants of the same failing query. NEWS DATING: search results carry their OWN publish date (visible in the snippet, URL, or news_search's pubDate field) — use THAT date in your header/summary, never default to today's date just because the user asked "오늘"/"today". If the freshest result you found is older than today, say so explicitly up front (e.g. "7월 13일 기준 뉴스이며, 이후 업데이트는 확인되지 않았습니다") instead of presenting it under today's date — do not make the user catch this themselves.`,
|
||||
|
||||
browser: `BROWSER TOOLS: browser_open(url) → opens+returns snapshot. browser_snapshot() → refresh. browser_click(ref) → click by @ref. browser_fill(ref,text) → fill input. browser_press_key(key) → Enter/Tab/Escape. browser_wait(ms) → wait+snapshot. browser_close() → close tab. Chrome profile is persistent. NEVER use browser_open for local /api/files/ URLs — those are already inline in chat. SNAPSHOT RULE: browser_open/fill/wait/click all return a snapshot automatically — do NOT call browser_snapshot after them; only call it when you haven't received a fresh snapshot recently.`,
|
||||
|
||||
desktop: `DESKTOP TOOLS: desktop_screenshot() → capture+OCR. desktop_find_window(name) → find window. desktop_focus_window(name) → bring to front (use SHORT process name: msedge, chrome, code). desktop_click(x,y,button) → click coords (button: "left" or "right"). desktop_type(text) → type. desktop_press_key(key) → key combo. desktop_drag(x1,y1,x2,y2) → drag. desktop_get_clipboard()/set_clipboard(text). Always screenshot first. Focus window before click/type. Fail twice on focus → stop and report. IMAGE DOWNLOAD: prefer shell("curl -o <path> <url>") or shell("python -c ...urllib...") for downloading images. Only use desktop right-click (desktop_click(x,y,"right") → screenshot → click "Save as") as a last resort when shell download fails due to auth or hotlink protection.`,
|
||||
|
||||
files: `FILE TOOLS: coder_read_file(filename) → contents+line numbers. coder_write_file(f,content) → create new file (if file exists, auto-converts to full coder_overwrite_lines edit). coder_overwrite_lines(f,start,end,content) → surgical edit. coder_insert_lines(f,line,content). coder_delete_lines(f,start,end). coder_str_replace(f,find,replace). coder_delete_file(f). coder_list_files(dir?). RULES: list first; read before edit; for small changes prefer coder_overwrite_lines or coder_insert_lines over rewriting the whole file. CODE FILES: When writing code (scripts, programs, source files), always save under the code directory. For NEW files use coder_write_file("code/filename", content). For EDITING existing code files use coder_overwrite_lines or coder_insert_lines with the same "code/filename" path. This keeps code files organized and visible in the Code editor tab. FILE LINKS — STRICT: NEVER manually construct /api/files/... paths from memory or assumption. Always use the EXACT path returned by the tool that created/saved the file. If you don't have a path, call coder_list_files FIRST to find the actual location, then build the link from that result. Common locations (for context only — verify before using): Code files → code/, UI uploads → uploads/, email attachments → attachments/uid-{N}/, pubmed_fulltext/shell-saved files → workspace root or task subfolder, PPTX → <project-folder>/. Wrong-folder guesses produce broken download buttons. EXCEPTION: MCP tool results (e.g. dental-dict-sqlite) that return an image_url field — output that value directly as  markdown, no list_objects needed.`,
|
||||
|
||||
task: `TASK TOOLS: task_control(action,...) actions: list/get/resume/rerun/pause/delete. start_task(title,prompt) → launch new background task. Check for existing tasks first before creating — never duplicate. Do NOT use coder_read_file to check task state.`,
|
||||
|
||||
schedule: `SCHEDULE TOOL: schedule_job(action,...) actions: list/create/update/pause/resume/delete/run_now. Always confirm before create/update/delete. Keep schedule timing separate from instruction_prompt content.`,
|
||||
|
||||
shell: `SHELL TOOL: shell(command) → execute terminal commands (python, pip, node, npm, dir, etc.). Returns command output. Use shell for scripts, CLI tools, and any terminal command. For opening GUI apps for the user to see, use run_command (chrome, notepad, vscode). For web automation use browser_* not shell. For desktop interaction use desktop_* not shell. IMPORTANT: NEVER use heredoc syntax (<<'PY', <<'EOF', etc.) — it only works in bash, NOT in PowerShell. Instead: (1) write the script to a .py file using coder_write_file, then (2) tell the user the file is ready. ONLY run the script with shell("python script.py") if the user EXPLICITLY asks to run/execute/실행 it. For one-liners use shell("python -c \\"code\\""). DOWNLOAD IMAGES: shell("curl -L -o <filepath> <url>") is the best way to download images/files. NEVER use xdg-open or open to launch browsers — the user can open files themselves from the UI.`,
|
||||
|
||||
memory: `MEMORY TOOLS: memory_browse(file) → list categories in user.md or soul.md. memory_write(file,category,content) → add/update a fact (creates category if new). memory_read(file) → full file contents. File is "user" or "soul". Browse first to find the right category. Write immediately when you learn something — don't wait.`,
|
||||
|
||||
pubmed: `PUBMED TOOLS: pubmed_search(query, max_results?, sort?, min_year?) → search PubMed, returns PMIDs + titles + authors + links. pubmed_fetch(pmids) → get abstract + metadata for comma-separated PMIDs. pubmed_fulltext(pmcid, format?, save_path?) → download full text or PDF of an open-access PMC article to workspace. RULES: NEVER use web_search or browser_open for PubMed/PMC queries — they return no useful results. NEVER use browser_open on pubmed.ncbi.nlm.nih.gov URLs — call pubmed_fetch or pubmed_fulltext instead. When user gives you a PMC ID (e.g. PMC8765432) and asks for full text/전문: call pubmed_fulltext immediately. For multi-paper research tasks (여러 논문, 다수 논문, N편 검색+요약): use spawn_agent(agentId="pubmed_researcher") instead. CITATION & LINKS (ALWAYS): For every paper in your response, include: ① 저자(들), 제목, 학술지, 연도 (APA 형식 권장) ② PubMed URL (https://pubmed.ncbi.nlm.nih.gov/<PMID>/) ③ DOI 링크 (있을 경우) ④ PMC ID가 있으면 전문 링크 (https://www.ncbi.nlm.nih.gov/pmc/articles/<PMCID>/) — 검색 결과에 이미 포함되어 있으므로 반드시 그대로 전달할 것.`,
|
||||
|
||||
scholar: `SCHOLAR TOOLS: openalex_search(query, max_results?, min_year?, open_access_only?, sort?) → search 200M+ papers across all fields via OpenAlex (no API key needed). semantic_search(query, max_results?, year_from?) → search Semantic Scholar (great for CS/AI, has citation influence metrics; add api_key to config for higher rate limits). Use openalex_search as the default broad search. Use semantic_search for CS/AI papers or when influential citation count matters. NEVER use web_search for academic paper searches — use openalex_search or semantic_search instead.`,
|
||||
|
||||
spawn: `SPAWN TOOL: spawn_agent(agentId, task, context?) → 전문 서브에이전트 실행 후 결과 반환. 사용 가능한 에이전트: "pubmed_researcher" (논문 다건 검색·요약·전문 수집 자율 수행). 단순 1건 조회는 pubmed_search/pubmed_fetch 직접 사용. 다건 리서치(논문 요약, 여러 논문 분석 등)는 spawn_agent 사용.`,
|
||||
|
||||
debug: `DEBUG: If asked about errors or architecture, use read_source(file) to inspect SmallClaw source. SELF.md has architecture overview. Workspace prompt files (in prompts/): IDENTITY.md, SOUL.md, USER.md, TOOLS.md, SELF.md. Read the relevant one before diagnosing.`,
|
||||
|
||||
pptx: `PPTX RULES — create_presentation for ALL decks:
|
||||
- ONE call with ALL slides. Never split.
|
||||
- Cover slides: slide 1 = title, last slide = title or blank.
|
||||
- Layouts: split-right (default), split-left, text, fullscreen.
|
||||
- Images: image_search for auto-find, image_url for direct URL/LOCAL PATH. No shell/curl.
|
||||
- Missing image → gray placeholder. Do not retry.
|
||||
- Add to existing deck: edit_presentation(path, spec).
|
||||
- DOWNLOAD LINK: use EXACT tool output. Never rewrite URLs.
|
||||
|
||||
PDF→PPTX WORKFLOW — MANDATORY. When source is a PDF, ALL steps below are REQUIRED before create_presentation. NEVER skip image extraction:
|
||||
1. pdf_read(path) → get text content.
|
||||
2. pdf_extract_images(path, mode:"figures") → get figure_* file paths. ALWAYS run this. Then immediately run python_eval to filter:
|
||||
from PIL import Image; import os
|
||||
figs = []
|
||||
for f in sorted(os.listdir("<abs_img_dir>")):
|
||||
if not f.startswith("figure_"): continue
|
||||
try:
|
||||
w,h = Image.open(os.path.join("<abs_img_dir>",f)).size
|
||||
if w < 250 or h < 200: continue # too small: title headers, tiny elements
|
||||
figs.append(f)
|
||||
except: pass
|
||||
print(figs)
|
||||
Use only the returned filenames as figures in slides.
|
||||
3. pdf_extract_tables(path) → if tables found, embed as markdown in slide content. Skip if no tables.
|
||||
4. pdf_extract_images(path, mode:"images") → get img-* file paths, then immediately run python_eval to filter embedded photos:
|
||||
from PIL import Image; import os
|
||||
out = []
|
||||
for f in sorted(os.listdir("<abs_img_dir>")):
|
||||
if not f.startswith("img-"): continue
|
||||
try:
|
||||
w,h = Image.open(os.path.join("<abs_img_dir>",f)).size; r=w/h
|
||||
if (w>1200 and h>1200 and 0.60<r<0.85): continue # full-page scan
|
||||
if w<150 or h<150: continue # icon/logo
|
||||
out.append(f)
|
||||
except: pass
|
||||
print(out)
|
||||
Replace <abs_img_dir> with the absolute path of the images output folder. Use only the returned filenames as embedded photos in slides.
|
||||
5. DEDUP RULE: figure_* = charts/graphs/diagrams/photos only. Tables detected by pdf_extract_tables → use markdown, NOT figure_* image.
|
||||
6. In slide specs, use image_url = EXACT workspace-relative path. Never strip folders, never use image_path.
|
||||
7. ONLY use figure_* and filtered img-* files. Raw unfiltered img-* files are FORBIDDEN.`,
|
||||
|
||||
photo: `PHOTO SEARCH: search_images(query, count?) → searches Pexels + Unsplash and returns embeddable image URLs. Display results as markdown images in your response. Use when the user asks to find, show, or search for photos or images.
|
||||
|
||||
IMAGE EDIT TOOL: image_edit(path, operation, ...params) → 이미지 편집 및 변환.
|
||||
CRITICAL: 이미지 편집/변환 요청에는 반드시 image_edit 툴을 사용. shell/python_eval로 직접 코드 작성하거나 웹사이트/브라우저 사용 금지.
|
||||
STRICT: 사용자가 편집을 명시적으로 요청한 경우에만 image_edit 호출. 사진을 업로드했다고 해서 자동으로 회전·보정·분석하지 말 것. 요청 없이 먼저 편집하는 것은 금지.
|
||||
operations: crop | resize | rotate | flip | grayscale | brightness | contrast | saturation | sharpen | blur | thumbnail | auto_enhance | filter | vignette | watermark | speech_bubble | stylize | remove_bg | convert
|
||||
saturation: value(0=무채색, 1=원본, 2=선명) — 채도 조절
|
||||
blur: radius(기본 3) — 가우시안 블러
|
||||
auto_enhance: 파라미터 없음 — 자동 레벨+채도 보정 (원클릭 보정)
|
||||
filter: preset("warm"|"cool"|"vintage"|"sepia"|"fade"|"dramatic"|"bw_film") — 사진 필터
|
||||
vignette: strength(0.3–0.8, 기본 0.5) — 가장자리 어둡게
|
||||
stylize: style 파라미터로 스타일 지정.
|
||||
- 신경망(고품질): "anime"(기본, 애니/그림체), "painting"(유화), "celeba"(만화체), "anime_v1"
|
||||
- 전통필터: "sketch"(연필스케치), "sketch_color"(컬러스케치), "cartoon"(만화), "watercolor"(수채화)
|
||||
사용 예: image_edit({path:"uploads/photo.jpg", operation:"stylize", style:"anime"})
|
||||
remove_bg: AI 배경 제거(birefnet-portrait, 머리카락 디테일까지 정교), PNG 출력, CPU라 20~30초 정도 걸림 — 오래 걸린다고 미리 안내할 것
|
||||
speech_bubble: text, position("top-left"|"top-right"|"bottom-left"|"bottom-right"), bg_color, text_color, font_size(자동)
|
||||
워크플로우: (1) [IMAGE PATH for image_edit/python_eval — use EXACTLY this string: ...] 힌트에서 입력 경로 읽기 → (2) image_edit 호출 → (3) 결과 자동 표시 → 재편집 가능.
|
||||
출력: uploads/ 폴더에 자동 저장.
|
||||
|
||||
IMAGE STYLE TRANSFORM TOOL (SDXL+IP-Adapter-FaceID, 고품질 화풍): image_style_transform(image, style) → style="cartoon"(캐리커쳐/만화 일러스트)만 지원. image_edit의 stylize cartoon은 기계적 필터라 은은한 보정에 가깝고, 이 툴은 SDXL img2img로 실제 화풍을 새로 그려서 훨씬 더 그림다운 결과를 냄. 얼굴이 감지되면 IP-Adapter-FaceID로 identity를 고정해서 원본과 다른 사람이 되는 문제를 막음. 대신 15~40초로 느림. 사용자가 "캐리커쳐로", "화질 좋게 카툰으로" 등 고품질 화풍 변환을 명시적으로 요청할 때만 사용 — 기본 카툰 요청에는 image_edit(stylize)를 먼저 쓰고, 결과가 밋밋하다고 하면 이 툴을 제안. watercolor와 sketch는 SDXL로 시도했다가 모두 되돌렸음(시드 고정 없이 강한 strength가 필요해서 원본과 다른 사람 얼굴로 드리프트하는 경우가 있었음) — 항상 image_edit 사용. anime/흑백 요청에도 이 툴 대신 image_edit 사용(더 빠름).`,
|
||||
|
||||
weather: `WEATHER TOOLS (8개):
|
||||
① weather_search(location, type?, units?) → OpenWeather 현재날씨/5일예보. type:"current"|"forecast".
|
||||
② weather_airpollution(location, type?) → OpenWeather 대기질. AQI/PM2.5/PM10/O3/NO2/CO/SO2. type:"current"|"forecast"(96h).
|
||||
③ weather_openmeteo(location, hours?, variables?) → Open-Meteo 시간별 예보(무료). ECMWF/GFS. 최대 384시간. UV지수·강수확률 포함.
|
||||
④ weather_kma(location, type?) → 기상청 공식 API. 한국 전용. type:"current"|"forecast". API 키 필요.
|
||||
⑤ weather_airkorea(sido) → 에어코리아 실시간 대기질. 시도명. API 키 필요.
|
||||
⑥ weather_nasa_power(location, temporal?, start?, end?, parameters?) → NASA POWER 기후 데이터(키 불필요). temporal:"daily"|"monthly"|"climatology"(30년 평균). 1981년~현재.
|
||||
⑦ weather_era5(location, start_date, end_date?, variables?, hourly?) → ERA5 재분석 일별/시간별 데이터(키 불필요). 1940년~5일 전. 과거 날씨 사실 확인·특정 사건 당시 기상에 사용.
|
||||
⑧ weather_cds(location, year, month, dataset?, preset?, request?) → Copernicus CDS 정식 API. ERA5 압력면·ERA5-Land·CMIP6 SSP 시나리오 비교. cds.api_key vault 등록 필요. preset: surface_daily|pressure_500hpa|cmip6_ssp126|cmip6_ssp245|cmip6_ssp585.
|
||||
⑨ weather_cmip6(location, start_date, end_date, variables?, aggregate?) → CMIP6 기후 모델 데이터(키 불필요). 1950~2050년. aggregate:"daily"|"monthly"|"annual". 기후 변화 추이 분석. 2015년 이후 SSP2-4.5 추정.
|
||||
RULES: 현재날씨/예보 → weather_search. 대기질(국내) → weather_airkorea. 대기질(해외) → weather_airpollution. 한국 정확도 → weather_kma. 시간별/UV → weather_openmeteo. 과거 날씨(날짜 지정) → weather_era5. 30년 기후평균 → weather_nasa_power. CMIP6 기후 추이/미래전망(~2050) → weather_cmip6. SSP 시나리오 비교/압력면 → weather_cds. NEVER use web_search for weather data.`,
|
||||
|
||||
ollama_web: `OLLAMA WEB TOOLS: ollama_web_search(query, max_results?) → Ollama 모델이 DDG 검색 후 결과를 자연어로 요약 반환. ollama_web_fetch(url, instruction?) → URL 내용을 가져와 Ollama가 요약. instruction 예: "주요 수치만 뽑아줘". 일반 web_search/web_fetch보다 느리지만 모델이 결과를 직접 해석해서 반환.`,
|
||||
|
||||
email: `EMAIL TOOLS (멀티 계정 지원): 모든 도구에 account? 파라미터로 계정 ID 지정 가능. 미지정 시 모든 계정 순회. 사용자별 설정에 등록된 계정만 접근 가능. email_list(folder?,limit?,account?) → 받은편지함 목록 (uid·제목·발신자·날짜·읽음여부). email_read(uid,folder?,account?) → 메일 전문 조회. email_send(to,subject,body,cc?,account?) → 메일 발송. email_search(query?,from?,subject?,since?,before?,folder?,limit?,account?) → 메일 검색. email_delete(uid,folder?,account?) → 메일 삭제. RULES: 이메일 관련 요청에는 항상 email_* 도구 사용 — web_search 사용 금지. email_list 결과는 계정별로 그룹핑된 원본 포맷(📬/📭 마커, 번호, UID 포함)을 그대로 사용자에게 전달할 것 — 자의적 재포맷팅 금지.`,
|
||||
|
||||
kakao: `KAKAO TOOL: kakao_send_message(text?, image_path?) → 서버에 등록된 카카오 계정으로 "나에게 보내기" 전송. text만 주면 텍스트/링크 메시지. image_path(워크스페이스 내 PNG/JPG/GIF/WEBP 경로, 다른 도구가 반환한 정확한 경로 사용)를 주면 사진 메시지로 전송하고 text는 캡션이 됨. PDF·문서·압축파일 등 이미지가 아닌 첨부는 카카오 API 자체가 지원하지 않으므로 불가능 — 그런 요청에는 안 된다고 답할 것. RULES: 카카오톡 전송을 언급하기 전에 반드시 이 도구를 호출해서 결과를 확인할 것 — 도구 호출 없이 "보내드리겠습니다"라고만 답하지 말 것. 도구가 실패(카카오 미연동 등)하면 실패 사유를 그대로 사용자에게 전달할 것.`,
|
||||
|
||||
pdf: `PDF TOOLS: pdf_read(path, page_from?, page_to?, max_chars?) → extract text from a PDF. pdf_extract_images(path, mode?, out_dir?, page_from?, page_to?, dpi?) → extract images/figures from a PDF. mode: "figures" (PREFERRED — OpenCV auto-crop figures/tables, best for academic PDFs), "images" (embedded rasters — raw, often full-page scans), "both" = images+figures (default). out_dir: save to specific folder (e.g. PPTX project folder). Returns workspace-relative paths. NOTE: "pages" mode no longer exists — use "figures" instead. pdf_extract_tables(path, format?, engine?, page_from?, page_to?) → extract tables as structured data. format: "markdown" (default) | "csv" | "json". engine: "auto" (PyMuPDF first, pdfplumber fallback) | "pymupdf" | "pdfplumber". For scanned PDFs use pdf_extract_images mode "figures" instead.`,
|
||||
|
||||
image_ocr: `IMAGE OCR TOOL: image_read(path, lang?) → extract text from an image file via OCR. Supported formats: PNG, JPG, WEBP, BMP, TIFF. lang: "eng" (English, default), "kor" (Korean), "kor+eng" (both). Returns extracted text and confidence score. Useful for screenshots, scanned documents, and diagrams containing text.`,
|
||||
|
||||
python: `PYTHON TOOL: python_eval(code, timeout?, packages?) → execute Python code in the workspace directory. Returns stdout/stderr. timeout default 15s, max 60s. packages: comma-separated pip packages to install if missing (e.g. "numpy,pandas"). Standard library and pre-installed packages available. Use for math, data analysis, CSV/JSON processing, charting (save chart to workspace file). IMAGE EDITING: input path = exact string from [IMAGE PATH for image_edit/python_eval — use EXACTLY this string: ...] hint in the message. Save output to uploads/<name>.jpg. FONT: For Korean/CJK text in Pillow (speech bubbles, watermarks, etc.) use ImageFont.truetype("/usr/share/fonts/truetype/nanum/NanumGothicBold.ttf", size) — DejaVuSans does NOT support Korean.`,
|
||||
|
||||
sqlite: `SQLITE TOOL: sqlite_query(db_path, query, write?, max_rows?) → query a SQLite .db file in the workspace. SELECT queries allowed by default. Set write=true for INSERT/UPDATE/DELETE/CREATE/DROP. Returns formatted table. db_path is relative to workspace or absolute (must be inside workspace).`,
|
||||
};
|
||||
|
||||
// Build a dynamic email tool block that lists the user's own accounts only
|
||||
function buildDynamicEmailBlock(workspacePath: string): string | null {
|
||||
try {
|
||||
const perUserPath = path.join(workspacePath, '.smallclaw', 'email-config.json');
|
||||
if (!fs.existsSync(perUserPath)) return null;
|
||||
const data = JSON.parse(fs.readFileSync(perUserPath, 'utf-8'));
|
||||
const accounts = data?.email?.accounts;
|
||||
if (!Array.isArray(accounts) || accounts.length === 0) return null;
|
||||
const accountList = accounts.map((a: any) => a.id).join(', ');
|
||||
return `EMAIL TOOLS (멀티 계정 지원): 모든 도구에 account? 파라미터로 계정 ID 지정 가능. 등록된 계정: ${accountList}. 미지정 시 모든 계정 순회. email_list(folder?,limit?,account?) → 받은편지함 목록. email_read(uid,folder?,account?) → 메일 전문 조회. email_send(to,subject,body,cc?,account?) → 메일 발송. email_search(query?,from?,subject?,since?,before?,folder?,limit?,account?) → 메일 검색. email_delete(uid,folder?,account?) → 메일 삭제. RULES: 이메일 관련 요청에는 항상 email_* 도구 사용 — web_search 사용 금지. email_list 결과는 계정별로 그룹핑된 원본 포맷(📬/📭 마커, 번호, UID 포함)을 그대로 사용자에게 전달할 것 — 자의적 재포맷팅 금지.`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Read memory categories for a specific file (for category-aware injection)
|
||||
function readMemoryCategories(workspacePath: string, file: 'user' | 'soul'): string[] {
|
||||
const filename = file === 'user' ? 'USER.md' : 'SOUL.md';
|
||||
const filePath = resolvePromptPath(workspacePath, filename);
|
||||
if (!fs.existsSync(filePath)) return [];
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const matches = content.match(/^##\s+([^\n]+)/gm) || [];
|
||||
return matches.map(m => m.replace(/^##\s+/, '').trim());
|
||||
}
|
||||
|
||||
// Read memory snippets matching detected categories (for category-aware injection)
|
||||
function readMemorySnippets(workspacePath: string, categories: string[], fileCache?: Map<string, string>): string {
|
||||
if (categories.length === 0) return '';
|
||||
const snippets: string[] = [];
|
||||
// USER.md is already fully injected as [USER] in the historyLength>0 path — skip it here
|
||||
for (const file of ['SOUL.md']) {
|
||||
let content: string;
|
||||
if (fileCache?.has(file)) {
|
||||
content = fileCache.get(file)!;
|
||||
} else {
|
||||
const filePath = resolvePromptPath(workspacePath, file);
|
||||
if (!fs.existsSync(filePath)) continue;
|
||||
content = fs.readFileSync(filePath, 'utf-8');
|
||||
}
|
||||
const lines = content.split('\n');
|
||||
let inSection = false;
|
||||
let currentSection = '';
|
||||
const sectionLines: string[] = [];
|
||||
for (const line of lines) {
|
||||
const headingMatch = line.match(/^##\s+(.+)/);
|
||||
if (headingMatch) {
|
||||
if (inSection && sectionLines.length > 0) {
|
||||
snippets.push(`[${file}:${currentSection}]\n${sectionLines.join('\n')}`);
|
||||
}
|
||||
currentSection = headingMatch[1].trim();
|
||||
inSection = categories.some(cat => currentSection.toLowerCase().includes(cat.toLowerCase()) || cat.toLowerCase().includes(currentSection.toLowerCase()));
|
||||
sectionLines.length = 0;
|
||||
} else if (inSection && line.trim()) {
|
||||
sectionLines.push(line);
|
||||
}
|
||||
}
|
||||
if (inSection && sectionLines.length > 0) {
|
||||
snippets.push(`[${file}:${currentSection}]\n${sectionLines.join('\n')}`);
|
||||
}
|
||||
}
|
||||
return snippets.slice(0, 3).join('\n\n');
|
||||
}
|
||||
|
||||
// Map tool categories to memory category keywords
|
||||
const TOOL_TO_MEMORY_CATS: Record<string, string[]> = {
|
||||
web: ['web', 'research', 'search'],
|
||||
browser: ['browser', 'web'],
|
||||
files: ['files', 'coding', 'editing', 'development'],
|
||||
task: ['tasks', 'workflow'],
|
||||
schedule: ['schedule', 'automation'],
|
||||
shell: ['shell', 'commands'],
|
||||
memory: ['preferences', 'communication'],
|
||||
};
|
||||
|
||||
async function buildPersonalityContext(
|
||||
sessionId: string,
|
||||
workspacePath: string,
|
||||
messageText: string,
|
||||
executionMode: string,
|
||||
historyLength: number,
|
||||
recentContextText: string = '',
|
||||
): Promise<string> {
|
||||
|
||||
// Per-request file cache — prevents reading USER.md/SOUL.md twice
|
||||
// (once for context blocks, once for readMemorySnippets)
|
||||
const fileCache = new Map<string, string>();
|
||||
const loadFile = (filename: string, maxChars: number): string => {
|
||||
if (!fileCache.has(filename)) {
|
||||
fileCache.set(filename, loadWorkspaceFile(workspacePath, filename, 99999));
|
||||
}
|
||||
const raw = fileCache.get(filename) ?? '';
|
||||
if (!raw) return '';
|
||||
if (raw.length > maxChars) {
|
||||
console.warn(`[Prompt] ${filename} truncated: ${raw.length} > ${maxChars} chars`);
|
||||
return raw.slice(0, maxChars) + '\n...(truncated)';
|
||||
}
|
||||
return raw;
|
||||
};
|
||||
|
||||
// Intraday notes: only read if file exists (avoid stat overhead on every request)
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const intradayPath = path.join(workspacePath, 'memory', `${today}-intraday-notes.md`);
|
||||
const intradayNotes = fs.existsSync(intradayPath) ? fs.readFileSync(intradayPath, 'utf-8').trim().slice(-600) : '';
|
||||
|
||||
const isAutonomous = executionMode === 'background_task' || executionMode === 'cron' || executionMode === 'heartbeat';
|
||||
if (isAutonomous) {
|
||||
const parts = [
|
||||
loadFile('IDENTITY.md', 1500) ? `[IDENTITY]\n${loadFile('IDENTITY.md', 1500)}` : '',
|
||||
loadFile('SOUL.md', 4000) ? `[SOUL]\n${loadFile('SOUL.md', 4000)}` : '',
|
||||
loadFile('USER.md', 3000) ? `[USER]\n${loadFile('USER.md', 3000)}` : '',
|
||||
loadFile('AGENTS.md', 4000) ? `[AGENTS]\n${loadFile('AGENTS.md', 4000)}` : '',
|
||||
intradayNotes ? `[TODAY_NOTES]\n${intradayNotes}` : '',
|
||||
].filter(Boolean);
|
||||
await hookBus.fire({ type: 'agent:bootstrap', sessionId, workspacePath, bootstrapFiles: [], timestamp: Date.now() });
|
||||
return parts.length > 0 ? '\n\n' + parts.join('\n\n') : '';
|
||||
}
|
||||
|
||||
if (historyLength === 0) {
|
||||
const parts = [
|
||||
loadFile('IDENTITY.md', 1500) ? `[IDENTITY]\n${loadFile('IDENTITY.md', 1500)}` : '',
|
||||
loadFile('USER.md', 3000) ? `[USER]\n${loadFile('USER.md', 3000)}` : '',
|
||||
loadFile('AGENTS.md', 4000) ? `[AGENTS]\n${loadFile('AGENTS.md', 4000)}` : '',
|
||||
intradayNotes ? `[TODAY_NOTES]\n${intradayNotes}` : '',
|
||||
].filter(Boolean);
|
||||
await hookBus.fire({ type: 'agent:bootstrap', sessionId, workspacePath, bootstrapFiles: [], timestamp: Date.now() });
|
||||
return parts.length > 0 ? '\n\n' + parts.join('\n\n') : '';
|
||||
}
|
||||
|
||||
// 짧은 후속 응답("응", "다시 보내줘")은 그 자체로 키워드가 없어 카테고리가
|
||||
// 감지되지 않는다 — 직전 어시스턴트 메시지까지 함께 스캔해서, 방금 제안한
|
||||
// 작업(예: "카카오톡으로 보내드릴까요?")에 대한 후속 턴에서도 관련 카테고리
|
||||
// (및 "반드시 도구를 호출할 것" 규칙 텍스트)가 계속 활성화되도록 한다.
|
||||
const cats = detectToolCategories(`${recentContextText}\n${messageText}`, sessionId);
|
||||
|
||||
// Build tool blocks for detected categories
|
||||
const toolBlockParts: string[] = [];
|
||||
for (const cat of cats) {
|
||||
if (cat === 'email') {
|
||||
const dynamicEmail = buildDynamicEmailBlock(workspacePath);
|
||||
if (dynamicEmail) {
|
||||
toolBlockParts.push(dynamicEmail);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (TOOL_BLOCKS[cat]) toolBlockParts.push(TOOL_BLOCKS[cat]);
|
||||
}
|
||||
|
||||
// Build memory snippets — pass fileCache so USER.md/SOUL.md are not re-read
|
||||
const memoryCategoryKeywords: string[] = [];
|
||||
for (const cat of cats) {
|
||||
const memCats = TOOL_TO_MEMORY_CATS[cat] || [];
|
||||
memoryCategoryKeywords.push(...memCats);
|
||||
}
|
||||
const memorySnippets = memoryCategoryKeywords.length > 0
|
||||
? readMemorySnippets(workspacePath, memoryCategoryKeywords, fileCache)
|
||||
: '';
|
||||
|
||||
// Tier 3: complex/multi-domain — add tools.md hint
|
||||
const isComplex = cats.size >= 3 || /\b(how do i|help me|can you|i need to|i want to)\b/i.test(messageText);
|
||||
const toolsHint = isComplex ? `\nFor full tool reference: coder_read_file prompts/TOOLS.md` : '';
|
||||
|
||||
// Self.md for debug (cached with fileCache)
|
||||
const self = cats.has('debug') ? loadFile('SELF.md', 4000) : '';
|
||||
|
||||
// SOUL only when tool categories are detected — pure conversational turns don't need it
|
||||
const soulContent = cats.size > 0 ? loadFile('SOUL.md', 4000) : '';
|
||||
|
||||
// Vector-store recall: relevance-based, not the fixed-recency/fixed-char-cap USER.md/SOUL.md
|
||||
// path — this is what lets facts survive past loadFile's truncation cutoff (see loadFile
|
||||
// above). Scoped to this workspace via the `where` filter so users never see each other's
|
||||
// facts. Best-effort — a Chroma/embedding outage must not break prompt building.
|
||||
//
|
||||
// Two collections, one query embedding (2026-07-24 design — see project_vector_memory_chroma
|
||||
// memory): user_facts (memory_write-sourced, clean) gets a loose threshold; daily_extracts
|
||||
// (LLM-extracted from raw conversation logs, noisier) gets a stricter one. Both merged and
|
||||
// ranked together by distance so the model just sees one flat, relevance-sorted list.
|
||||
let vectorMemory = '';
|
||||
if (messageText.trim().length >= 4) {
|
||||
try {
|
||||
const queryEmbedding = await embedQuery(messageText);
|
||||
const [userFactHits, dailyExtractHits] = await Promise.all([
|
||||
queryVectorsWithEmbedding(USER_FACTS_COLLECTION, queryEmbedding, 6, { workspace: workspacePath }),
|
||||
queryVectorsWithEmbedding(DAILY_EXTRACTS_COLLECTION, queryEmbedding, 6, { workspace: workspacePath }),
|
||||
]);
|
||||
// Thresholds recalibrated for EmbeddingGemma's wider distance spread (2026-07-24 switch
|
||||
// from nomic-embed-text) — a 3-fact spot check gave ~0.51 for a genuinely relevant match,
|
||||
// ~0.66 for same-topic-wrong-entity, ~0.97 for unrelated. Rough starting points, not a
|
||||
// rigorous calibration — revisit if recall feels off/noisy in real use.
|
||||
const relevant = [
|
||||
...userFactHits.filter(h => h.distance < 0.65),
|
||||
...dailyExtractHits.filter(h => h.distance < 0.55 && !h.metadata?.empty),
|
||||
].sort((a, b) => a.distance - b.distance).slice(0, 8);
|
||||
if (relevant.length > 0) {
|
||||
vectorMemory = relevant.map(h => `- ${h.text}`).join('\n');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn('[buildPersonalityContext] vector memory query failed (non-fatal):', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
const parts = [
|
||||
loadFile('IDENTITY.md', 1500) ? `[IDENTITY]\n${loadFile('IDENTITY.md', 1500)}` : '',
|
||||
loadFile('USER.md', 3000) ? `[USER]\n${loadFile('USER.md', 3000)}` : '',
|
||||
loadFile('AGENTS.md', 4000) ? `[AGENTS]\n${loadFile('AGENTS.md', 4000)}` : '',
|
||||
soulContent ? `[SOUL]\n${soulContent}` : '',
|
||||
intradayNotes ? `[TODAY_NOTES]\n${intradayNotes}` : '',
|
||||
toolBlockParts.length > 0 ? `[TOOLS]\n${toolBlockParts.join('\n\n')}${toolsHint}` : (toolsHint ? `[TOOLS]${toolsHint}` : ''),
|
||||
memorySnippets ? `[RELEVANT_MEMORY]\n${memorySnippets}` : '',
|
||||
vectorMemory ? `[RECALLED_FACTS]\n${vectorMemory}` : '',
|
||||
self ? `[SELF]\n${self}` : '',
|
||||
].filter(Boolean);
|
||||
|
||||
await hookBus.fire({ type: 'agent:bootstrap', sessionId, workspacePath, bootstrapFiles: [], timestamp: Date.now() });
|
||||
return parts.length > 0 ? '\n\n' + parts.join('\n\n') : '';
|
||||
}
|
||||
const buildPersonalityContext = createPersonalityContext(resolvePromptPath);
|
||||
|
||||
|
||||
function logToDaily(workspacePath: string, role: string, content: string, sessionId?: string) {
|
||||
|
||||
Reference in New Issue
Block a user