Refactor project layout and fix workspace/subagent paths

- Remove global workspace (workspace/) — all data migrated to .smallclaw/
- Move ppt/ skins & templates to .smallclaw/skills/presenter/ppt/
- Add .smallclaw/templates/ for user bootstrap files
- Move dental_images to .smallclaw/databases/, update 1,485 DB entries
- Fix subagent store paths to use SMALLCLAW_DATA_DIR instead of workspace.path
- Per-user Telegram bots: MultiUserTelegramManager, vault-stored tokens
- Fix Telegram token persistence (vault fallback, strip from config.json)
- Fix session migration: delete global originals even when skipping
- Delete unused root images, MD docs, tests/, smallclawworkspace/ artifacts
- Add meteorologist skill, counselor skill, presenter skill scaffold
- Strip workspace.path from config.json (was causing path confusion)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kim
2026-05-28 15:21:21 +09:00
co-authored by Claude Sonnet 4.6
parent d7eb0abc6d
commit 5608b83850
146 changed files with 1653 additions and 14870 deletions
+12
View File
@@ -14,6 +14,14 @@ function getWorkspacePath(args?: any): string {
}
}
function isPathInsideDir(base: string, target: string): boolean {
const resolvedBase = path.resolve(base);
const resolvedTarget = path.resolve(target);
if (resolvedBase === resolvedTarget) return true;
const rel = path.relative(resolvedBase, resolvedTarget);
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
}
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff', '.tif', '.heic', '.heif', '.avif']);
function runPython(script: string, timeoutMs = 60_000): Promise<any> {
@@ -92,6 +100,7 @@ export const imageReadTool = {
const workspacePath = getWorkspacePath(args);
const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(workspacePath, filePath);
if (!isPathInsideDir(workspacePath, resolved)) return { success: false, error: 'Access denied: path escapes workspace' };
if (!fs.existsSync(resolved)) {
return { success: false, error: `File not found: ${resolved}` };
}
@@ -175,6 +184,7 @@ export const imagePreviewTool = {
const workspacePath = getWorkspacePath(args);
const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(workspacePath, filePath);
if (!isPathInsideDir(workspacePath, resolved)) return { success: false, error: 'Access denied: path escapes workspace' };
if (!fs.existsSync(resolved)) {
return { success: false, error: `File not found: ${resolved}` };
}
@@ -241,6 +251,7 @@ export const imageInfoTool = {
if (!filePath) return { success: false, error: 'path is required' };
const workspacePath = getWorkspacePath(args);
const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(workspacePath, filePath);
if (!isPathInsideDir(workspacePath, resolved)) return { success: false, error: 'Access denied: path escapes workspace' };
if (!fs.existsSync(resolved)) return { success: false, error: `File not found: ${resolved}` };
const result = await runPython(INFO_SCRIPT(resolved));
@@ -689,6 +700,7 @@ export const imageEditTool = {
const workspacePath = getWorkspacePath(args);
const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(workspacePath, filePath);
if (!isPathInsideDir(workspacePath, resolved)) return { success: false, error: 'Access denied: path escapes workspace' };
if (!fs.existsSync(resolved)) return { success: false, error: `File not found: ${resolved}` };
// determine output path
+10
View File
@@ -15,6 +15,14 @@ function getWorkspacePath(): string {
}
}
function isPathInsideDir(base: string, target: string): boolean {
const resolvedBase = path.resolve(base);
const resolvedTarget = path.resolve(target);
if (resolvedBase === resolvedTarget) return true;
const rel = path.relative(resolvedBase, resolvedTarget);
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
}
// CCITT G4 (fax-compressed) images produced by pdfimages cannot be opened
// directly by most viewers. Wrap them in a minimal TIFF header so Pillow can
// decode and re-save as PNG. Returns the number of files converted.
@@ -352,6 +360,7 @@ export const pdfExtractImagesTool = {
const workspacePath = String(args?._workspacePath || args?._workspace || '') || getWorkspacePath();
const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(workspacePath, filePath);
if (!isPathInsideDir(workspacePath, resolved)) return { success: false, error: 'Access denied: path escapes workspace' };
if (!fs.existsSync(resolved)) return { success: false, error: `File not found: ${resolved}` };
if (path.extname(resolved).toLowerCase() !== '.pdf') return { success: false, error: 'File must have a .pdf extension' };
@@ -593,6 +602,7 @@ export const pdfExtractTablesTool = {
const workspacePath = String(args?._workspacePath || args?._workspace || '') || getWorkspacePath();
const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(workspacePath, filePath);
if (!isPathInsideDir(workspacePath, resolved)) return { success: false, error: 'Access denied: path escapes workspace' };
if (!fs.existsSync(resolved)) return { success: false, error: `File not found: ${resolved}` };
if (path.extname(resolved).toLowerCase() !== '.pdf') return { success: false, error: 'File must have a .pdf extension' };
+11
View File
@@ -17,6 +17,14 @@ function getWorkspacePath(args?: any): string {
}
}
function isPathInsideDir(base: string, target: string): boolean {
const resolvedBase = path.resolve(base);
const resolvedTarget = path.resolve(target);
if (resolvedBase === resolvedTarget) return true;
const rel = path.relative(resolvedBase, resolvedTarget);
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
}
const OCR_SCRIPT = `
import sys, os, subprocess, tempfile
try:
@@ -92,6 +100,9 @@ export const pdfReadTool = {
const workspacePath = getWorkspacePath(args);
const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(workspacePath, filePath);
if (!isPathInsideDir(workspacePath, resolved)) {
return { success: false, error: 'Access denied: path escapes workspace' };
}
if (!fs.existsSync(resolved)) {
return { success: false, error: `File not found: ${resolved}` };
}
+123 -11
View File
@@ -10,8 +10,8 @@ type SSESender = (type: string, data: any) => void;
const PYTHON_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'pptx_gen.py');
// ─── Paths ──────────────────────────────────────────────────────────────────────
const SKIN_DIR = path.join(__dirname, '..', '..', 'ppt', 'skin');
const TEMPLATE_DIR = path.join(__dirname, '..', '..', 'ppt', 'template');
const SKIN_DIR = path.join(__dirname, '..', '..', '.smallclaw', 'skills', 'presenter', 'ppt', 'skin');
const TEMPLATE_DIR = path.join(__dirname, '..', '..', '.smallclaw', 'skills', 'presenter', 'ppt', 'template');
const LEGACY_SKIN_DIR = path.join(__dirname, '..', '..', 'assets', 'pptx_template');
// Resolve skin directory: prefer ppt/skin, fall back to legacy assets/pptx_template
@@ -125,8 +125,48 @@ function repairJson(input: string): string {
return s;
}
const SLIDE_TYPES = new Set(['title', 'content', 'section', 'image', 'table', 'chart', 'timeline', 'blank']);
/** Normalize a single slide spec to handle field-name variants from different LLMs. */
function normalizeSlide(slide: any): any {
const s = { ...slide };
// 1. Infer `type` from `layout` when missing (Kimi uses layout="chart" / layout="table")
if (!s.type && s.layout && SLIDE_TYPES.has(s.layout.toLowerCase())) {
s.type = s.layout.toLowerCase();
delete s.layout;
}
// 2. camelCase chartType → chart_type
if (s.chartType !== undefined && s.chart_type === undefined) {
s.chart_type = s.chartType;
}
// 3. series[].data → series[].values
if (Array.isArray(s.series)) {
s.series = s.series.map((sr: any) => {
if (sr.data !== undefined && sr.values === undefined) {
return { ...sr, values: sr.data };
}
return sr;
});
}
// 4. Nested table object → flatten (table / table_data both supported)
for (const tblKey of ['table', 'table_data']) {
const tbl = s[tblKey];
if (tbl && typeof tbl === 'object' && !Array.isArray(tbl)) {
if (s.headers === undefined && tbl.headers !== undefined) s.headers = tbl.headers;
if (s.rows === undefined && tbl.rows !== undefined) s.rows = tbl.rows;
}
}
// 5. content field is a list → treat as bullets
if (Array.isArray(s.content) && s.bullets === undefined) {
s.bullets = s.content.map((b: any) => String(b));
delete s.content;
}
return s;
}
/** Generate PPTX using python-pptx via the scripts/pptx_gen.py script. */
async function generateWithPython(spec: PresentationSpec, workspacePath: string): Promise<ToolResult> {
spec = { ...spec, slides: spec.slides.map(normalizeSlide) };
const tmpSpecPath = path.join(workspacePath, `.pptx_spec_${Date.now()}.json`);
try {
// Write spec to temp file
@@ -216,6 +256,26 @@ interface SlideSpec {
template?: string;
layout?: string;
notes?: string;
// Table slide
headers?: string[];
rows?: string[][];
// Chart slide
chart_type?: string;
categories?: string[];
series?: { name: string; values: number[] }[];
// Compare layout
left_title?: string;
left_bullets?: string[];
left_points?: string[];
left_body?: string;
right_title?: string;
right_bullets?: string[];
right_points?: string[];
right_body?: string;
// Timeline slide
events?: { year?: string; label?: string; text?: string; description?: string }[];
// Edit mode: 1-based slide number to replace (used with edit_presentation)
replace_index?: number;
}
// ─── Image Search & Download ────────────────────────────────────────────────────
@@ -538,7 +598,7 @@ interface PresentationSpec {
export const pptxTool: import('./registry.js').Tool = {
name: 'create_presentation',
description: 'Generate a PowerPoint (.pptx) file with slides. Use image_search on slides to auto-find images by keyword (e.g. "golden retriever"). Use image_url for direct URLs. Put ALL slides in ONE call — never split across multiple calls. NEVER write Python scripts to create PPTX. Missing images become light-gray placeholders.',
description: 'Generate a PowerPoint (.pptx) file with slides. Slide types: "title", "content", "section", "image", "table", "chart", "timeline", "blank". Content layouts: "split-right" (default), "split-left", "text", "fullscreen", "compare". Use image_search to auto-find images. Put ALL slides in ONE call — never split. NEVER write Python scripts to create PPTX.',
schema: {
spec: 'JSON object: { filename, title, template, theme, font_sizes, slides: [{ type, title, subtitle, bullets, body, content, font_size, image_path, image_url, image_search, background, template }] }',
},
@@ -560,8 +620,8 @@ export const pptxTool: import('./registry.js').Tool = {
items: {
type: 'object',
properties: {
type: { type: 'string', description: 'Slide type: "title", "content", "section", "image", or "blank"' },
layout: { type: 'string', description: 'Column layout: "split-right" (default: small title top, text left, image right), "split-left" (small title top, image left, text right), "text" (full-width text only), "fullscreen" (image fills slide, title/text overlaid at bottom)' },
type: { type: 'string', description: 'Slide type: "title", "content", "section", "image", "table", "chart", "timeline", or "blank"' },
layout: { type: 'string', description: 'Column layout (content slides): "split-right" (default: text left, image right), "split-left" (image left, text right), "text" (full-width text only), "fullscreen" (image fills slide, text overlaid), "compare" (two side-by-side text columns, no image)' },
title: { type: 'string', description: 'Slide title text' },
subtitle: { type: 'string', description: 'Subtitle (for title slides)' },
bullets: { type: 'array', items: { type: 'string' }, description: 'Bullet point texts' },
@@ -575,6 +635,18 @@ export const pptxTool: import('./registry.js').Tool = {
background: { type: 'string', description: `Skin name (${SKIN_NAMES.join(', ')}) or image file path (relative to project folder)` },
template: { type: 'string', description: `Per-slide template override: ${TEMPLATE_NAMES.join(', ')}` },
notes: { type: 'string', description: 'Speaker notes' },
headers: { type: 'array', items: { type: 'string' }, description: 'Table header row (type="table"). E.g. ["항목","2024년","2025년"]' },
rows: { type: 'array', items: { type: 'array', items: { type: 'string' } }, description: 'Table data rows (type="table"). E.g. [["매출","100M","120M"],["비용","80M","90M"]]' },
chart_type: { type: 'string', description: 'Chart type (type="chart"): "column" (default), "bar", "line", "line_markers", "pie", "doughnut", "column_stacked", "bar_stacked"' },
categories: { type: 'array', items: { type: 'string' }, description: 'Chart X-axis categories (type="chart"). E.g. ["Q1","Q2","Q3","Q4"]' },
series: { type: 'array', items: { type: 'object', properties: { name: { type: 'string' }, values: { type: 'array', items: { type: 'number' } } } }, description: 'Chart data series (type="chart"). E.g. [{"name":"2024","values":[10,20,30,40]}]' },
left_title: { type: 'string', description: 'Left column heading (layout="compare")' },
left_bullets: { type: 'array', items: { type: 'string' }, description: 'Left column bullet points (layout="compare")' },
left_body: { type: 'string', description: 'Left column body text (layout="compare")' },
right_title: { type: 'string', description: 'Right column heading (layout="compare")' },
right_bullets: { type: 'array', items: { type: 'string' }, description: 'Right column bullet points (layout="compare")' },
right_body: { type: 'string', description: 'Right column body text (layout="compare")' },
events: { type: 'array', items: { type: 'object', properties: { year: { type: 'string' }, label: { type: 'string' }, text: { type: 'string' }, description: { type: 'string' } } }, description: 'Timeline events (type="timeline"). Each: {year/label: "2024 Q1", text/description: "핵심 이벤트"}. Odd-indexed events appear below the line.' },
},
},
},
@@ -770,7 +842,7 @@ export const pptxTool: import('./registry.js').Tool = {
export const editPptxTool: import('./registry.js').Tool = {
name: 'edit_presentation',
description: 'Append slides to an existing .pptx file. Provide the path and new slides to add. Use image_search to auto-find images. Do NOT use this to split a single presentation — use create_presentation for all slides at once.',
description: 'Append slides to an existing .pptx file. Supports all slide types: title, content, section, image, table, chart, timeline. Use image_search to auto-find images. Do NOT use this to split a single presentation — use create_presentation for all slides at once.',
schema: {
path: 'Path to existing .pptx file (relative to workspace or absolute)',
spec: 'JSON object: { slides: [{ type, title, subtitle, bullets, body, content, font_size, image_path, image_url, image_search, background, template }] }',
@@ -790,8 +862,8 @@ export const editPptxTool: import('./registry.js').Tool = {
items: {
type: 'object',
properties: {
type: { type: 'string', description: 'Slide type: "title", "content", "section", "image", or "blank"' },
layout: { type: 'string', description: 'Column layout: "split-right" (default: small title top, text left, image right), "split-left" (small title top, image left, text right), "text" (full-width text only), "fullscreen" (image fills slide, title/text overlaid at bottom)' },
type: { type: 'string', description: 'Slide type: "title", "content", "section", "image", "table", "chart", "timeline", or "blank"' },
layout: { type: 'string', description: 'Column layout (content slides): "split-right" (default: text left, image right), "split-left" (image left, text right), "text" (full-width text only), "fullscreen" (image fills slide, text overlaid), "compare" (two side-by-side text columns, no image)' },
title: { type: 'string', description: 'Slide title text' },
subtitle: { type: 'string', description: 'Subtitle (for title slides)' },
bullets: { type: 'array', items: { type: 'string' }, description: 'Bullet point texts' },
@@ -805,6 +877,19 @@ export const editPptxTool: import('./registry.js').Tool = {
background: { type: 'string', description: `Skin name (${SKIN_NAMES.join(', ')}) or image file path (relative to project folder)` },
template: { type: 'string', description: `Per-slide template override: ${TEMPLATE_NAMES.join(', ')}` },
notes: { type: 'string', description: 'Speaker notes' },
headers: { type: 'array', items: { type: 'string' }, description: 'Table header row (type="table"). E.g. ["항목","2024년","2025년"]' },
rows: { type: 'array', items: { type: 'array', items: { type: 'string' } }, description: 'Table data rows (type="table"). E.g. [["매출","100M","120M"],["비용","80M","90M"]]' },
chart_type: { type: 'string', description: 'Chart type (type="chart"): "column" (default), "bar", "line", "line_markers", "pie", "doughnut", "column_stacked", "bar_stacked"' },
categories: { type: 'array', items: { type: 'string' }, description: 'Chart X-axis categories (type="chart"). E.g. ["Q1","Q2","Q3","Q4"]' },
series: { type: 'array', items: { type: 'object', properties: { name: { type: 'string' }, values: { type: 'array', items: { type: 'number' } } } }, description: 'Chart data series (type="chart"). E.g. [{"name":"2024","values":[10,20,30,40]}]' },
left_title: { type: 'string', description: 'Left column heading (layout="compare")' },
left_bullets: { type: 'array', items: { type: 'string' }, description: 'Left column bullet points (layout="compare")' },
left_body: { type: 'string', description: 'Left column body text (layout="compare")' },
right_title: { type: 'string', description: 'Right column heading (layout="compare")' },
right_bullets: { type: 'array', items: { type: 'string' }, description: 'Right column bullet points (layout="compare")' },
right_body: { type: 'string', description: 'Right column body text (layout="compare")' },
events: { type: 'array', items: { type: 'object', properties: { year: { type: 'string' }, label: { type: 'string' }, text: { type: 'string' }, description: { type: 'string' } } }, description: 'Timeline events (type="timeline"). Each: {year/label: "2024 Q1", text/description: "핵심 이벤트"}. Odd-indexed events appear below the line.' },
replace_index: { type: 'number', description: '1-based slide number to replace. Removes the existing slide at that position and inserts the new one there. Omit to append.' },
},
},
},
@@ -831,7 +916,29 @@ export const editPptxTool: import('./registry.js').Tool = {
: path.join(workspacePath, existingPath);
if (!fs.existsSync(absPath)) {
return { success: false, error: `PPTX file not found: ${absPath}` };
// Fuzzy fallback: find a PPTX whose folder name starts with the given folder prefix.
// Handles cases where the model truncates the slug (e.g. "투명교정의_최신_술식" →
// actual folder "투명교정의_최신_술식_및_기술").
const givenFolder = path.basename(path.dirname(absPath));
let fuzzyMatch: string | null = null;
try {
for (const entry of fs.readdirSync(workspacePath)) {
if (!entry.startsWith(givenFolder) || entry === givenFolder) continue;
const candidate = path.join(workspacePath, entry);
if (!fs.statSync(candidate).isDirectory()) continue;
const pptxFiles = fs.readdirSync(candidate).filter(f => f.toLowerCase().endsWith('.pptx'));
if (pptxFiles.length > 0) {
fuzzyMatch = path.join(candidate, pptxFiles[0]);
console.log(`[pptx] edit_presentation: fuzzy match "${existingPath}" → "${fuzzyMatch}"`);
break;
}
}
} catch {}
if (!fuzzyMatch) {
return { success: false, error: `PPTX file not found: ${absPath}` };
}
// Use the fuzzy-matched path from here on
(args as any)._resolvedPath = fuzzyMatch;
}
let spec: PresentationSpec;
@@ -844,6 +951,10 @@ export const editPptxTool: import('./registry.js').Tool = {
return { success: false, error: 'spec.slides must be a non-empty array' };
}
// Inject config defaults so Python engine inherits the same skin/template as create_presentation
if (!spec.template && config.ppt?.template) spec.template = config.ppt.template;
if (!spec.default_skin && config.ppt?.skin) spec.default_skin = config.ppt.skin;
// Resolve image API keys
const unsplashKey = process.env.UNSPLASH_ACCESS_KEY || configMgr.resolveSecret(config.ppt?.unsplash_key) || '';
const pexelsKey = process.env.PEXELS_API_KEY || configMgr.resolveSecret(config.ppt?.pexels_key) || '';
@@ -857,7 +968,8 @@ export const editPptxTool: import('./registry.js').Tool = {
}
// ── Download all images into the PPTX directory ──
const editProjectDir = path.dirname(absPath);
const resolvedAbsPath: string = (args as any)._resolvedPath || absPath;
const editProjectDir = path.dirname(resolvedAbsPath);
const sendSSE = (args as any)?._sendSSE as SSESender | undefined;
for (let i = 0; i < spec.slides.length; i++) {
@@ -891,7 +1003,7 @@ export const editPptxTool: import('./registry.js').Tool = {
}
// Pass existing_path to Python engine
(spec as any).existing_path = absPath;
(spec as any).existing_path = resolvedAbsPath;
return await generateWithPython(spec, workspacePath);
},
};
+12
View File
@@ -160,6 +160,18 @@ const KMA_GRID: Record<string, [number, number]> = {
'진주': [81, 75], 'jinju': [81, 75],
'여수': [73, 66], 'yeosu': [73, 66],
'목포': [50, 67], 'mokpo': [50, 67],
'구미': [75, 100], 'gumi': [75, 100],
'안동': [91, 106], 'andong': [91, 106],
'경주': [100, 91], 'gyeongju': [100, 91],
'김해': [95, 77], 'gimhae': [95, 77],
'거제': [95, 71], 'geoje': [95, 71],
'순천': [70, 70], 'suncheon': [70, 70],
'익산': [60, 91], 'iksan': [60, 91],
'원주': [76, 122], 'wonju': [76, 122],
'평택': [62, 114], 'pyeongtaek': [62, 114],
'시흥': [57, 120], 'siheung': [57, 120],
'부천': [56, 125], 'bucheon': [56, 125],
'안산': [57, 121], 'ansan': [57, 121],
};
function latLonToKmaGrid(lat: number, lon: number): { nx: number; ny: number } {