// Onboarding scan — real filesystem scan, Git analysis, and profile compilation. // Runs synchronously on the host machine (no sandbox needed). import { spawn } from "node:child_process"; import { access, readdir, stat } from "node:fs/promises"; import { dirname, join } from "node:path"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface ScanResult { localRepos: RepoInfo[]; projectFolders: ProjectFolder[]; fileSummary: FileSummary; profileSummary: ProfileSummary; agentBriefing: string; createdProjects: CreatedProject[]; subAgentRoles: SubAgentRole[]; } interface RepoInfo { path: string; name: string; remote: string | null; branch: string; lastCommit: string | null; languages: Record; sizeBytes: number; } interface ProjectFolder { path: string; name: string; type: "git" | "workspace" | "documents" | "media" | "music" | "code"; fileCount: number; modifiedAt: string; } interface FileSummary { totalFiles: number; totalSizeBytes: number; byType: Record; byExtension: Record; } interface ProfileSummary { interests: string[]; skills: string[]; workStyle: string; communication: string; languages: string[]; techStack: string[]; } interface CreatedProject { id: string; name: string; spaceId: string; description: string; } interface ScanRecord { id: string; botId: string; status: "scanning" | "connecting" | "building" | "briefing" | "complete" | "failed"; progress: number; message: string; result: string; startedAt: Date; completedAt: Date | null; } // --------------------------------------------------------------------------- // Registry (in-memory, 1h TTL) // --------------------------------------------------------------------------- const scanRegistry = new Map(); const emptyScanResult = (): ScanResult => ({ localRepos: [], projectFolders: [], fileSummary: { totalFiles: 0, totalSizeBytes: 0, byType: {}, byExtension: {} }, profileSummary: { interests: [], skills: [], workStyle: "structured", communication: "direct", languages: [], techStack: [], }, agentBriefing: "Profile ready", createdProjects: [], subAgentRoles: [], }); async function runSimulatedScan(record: ScanRecord) { const phases: Array<{ status: ScanRecord["status"]; progress: number; message: string }> = [ { status: "scanning", progress: 25, message: "Scanning local files and repositories..." }, { status: "connecting", progress: 40, message: "Connecting to your services..." }, { status: "building", progress: 60, message: "Building your profile..." }, { status: "briefing", progress: 80, message: "Briefing your agent team..." }, ]; for (const phase of phases) { record.status = phase.status; record.progress = phase.progress; record.message = phase.message; await new Promise((resolve) => setTimeout(resolve, 400)); } record.status = "complete"; record.progress = 100; record.message = "Scan complete — profile ready"; record.result = JSON.stringify(emptyScanResult()); record.completedAt = new Date(); } export async function startOnboardingScan( botId: string | undefined, prisma: any, ): Promise<{ scanId: string }> { const scanId = `scan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const record: ScanRecord = { id: scanId, botId: botId ?? "", status: "scanning", progress: 0, message: "Scanning your computer...", result: "[]", startedAt: new Date(), completedAt: null, }; scanRegistry.set(scanId, record); // Run async void (async () => { try { if (process.env.AGENT_RUNTIME === "scripted" || process.env.SANDBOX_PROVIDER === "fake") { await runSimulatedScan(record); return; } const homeDir = process.env.HOME ?? "/home/cynar"; const result = await executeFullScan(homeDir, botId ?? "", prisma); record.status = "complete"; record.progress = 100; record.message = "Scan complete — profile ready"; record.result = JSON.stringify(result); record.completedAt = new Date(); } catch (error) { record.status = "failed"; record.message = error instanceof Error ? error.message : "Scan failed"; record.completedAt = new Date(); } })(); return { scanId }; } export async function getScanStatus(scanId: string): Promise<{ status: ScanRecord["status"]; progress: number; message: string; result?: unknown; }> { const record = scanRegistry.get(scanId); if (!record) throw new Error("Scan not found"); const result = record.result ? JSON.parse(record.result) : undefined; return { status: record.status, progress: record.progress, message: record.message, result }; } // --------------------------------------------------------------------------- // Full scan pipeline // --------------------------------------------------------------------------- async function executeFullScan(homeDir: string, _botId: string, prisma: any): Promise { // Phase 1: Local file scan const [fileSummary, projectFolders] = await scanFiles(homeDir); // Phase 2: Git repository discovery const localRepos = await discoverGitRepos(homeDir); // Phase 3: External service analysis (GitHub, etc.) // Phase 4: Profile compilation from findings const profileSummary = compileProfile(localRepos, fileSummary, projectFolders); // Phase 6: Project folder creation const createdProjects = await createProjectFolders(localRepos, prisma); // Phase 7: Sub-agent role creation const subAgentRoles = generateSubAgentRoles(profileSummary, localRepos); // Phase 5: Agent briefing (includes sub-agent roles) const agentBriefing = generateBriefing(localRepos, profileSummary, fileSummary, subAgentRoles); return { localRepos, projectFolders, fileSummary, profileSummary, agentBriefing, createdProjects, subAgentRoles, }; } // --------------------------------------------------------------------------- // Phase 1: File scan // --------------------------------------------------------------------------- async function scanFiles(homeDir: string): Promise<[FileSummary, ProjectFolder[]]> { const summary: FileSummary = { totalFiles: 0, totalSizeBytes: 0, byType: {}, byExtension: {} }; const folders: ProjectFolder[] = []; // Scan common directories const scanDirs = [ "Projects", "Documents", "Pictures", "Music", "Downloads", "Desktop", "src", "code", "work", "data", "repos", "dev", "workspace", ]; for (const subDir of scanDirs) { const dirPath = join(homeDir, subDir); try { await access(dirPath); const stats = await scanDirectory(dirPath, homeDir, subDir); summary.totalFiles += stats.totalFiles; summary.totalSizeBytes += stats.totalSizeBytes; Object.assign(summary.byType, stats.byType); Object.assign(summary.byExtension, stats.byExtension); if (stats.totalFiles > 0) { folders.push({ path: dirPath, name: subDir, type: classifyFolderType(subDir), fileCount: stats.totalFiles, modifiedAt: new Date().toISOString(), }); } } catch { /* directory doesn't exist, skip */ } } return [summary, folders]; } interface DirStats { totalFiles: number; totalSizeBytes: number; byType: Record; byExtension: Record; } async function scanDirectory( dirPath: string, _homeDir: string, _relativePath: string, ): Promise { const stats: DirStats = { totalFiles: 0, totalSizeBytes: 0, byType: {}, byExtension: {} }; try { const entries = await readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(dirPath, entry.name); if (entry.isDirectory()) { // Skip node_modules, .git, etc. if (entry.name === "node_modules" || entry.name === ".git" || entry.name === ".venv") continue; const subStats = await scanDirectory(fullPath, _homeDir, _relativePath); stats.totalFiles += subStats.totalFiles; stats.totalSizeBytes += subStats.totalSizeBytes; Object.assign(stats.byType, subStats.byType); Object.assign(stats.byExtension, subStats.byExtension); } else if (entry.isFile()) { try { const fileStat = await stat(fullPath); stats.totalFiles++; stats.totalSizeBytes += fileStat.size; // Classify by type const type = classifyFileType(entry.name); stats.byType[type] = (stats.byType[type] || 0) + 1; // Classify by extension const ext = entry.name.split(".").pop()?.toLowerCase() || "none"; stats.byExtension[ext] = (stats.byExtension[ext] || 0) + 1; } catch { /* permission denied */ } } } } catch { /* permission denied */ } return stats; } function classifyFileType(filename: string): string { const ext = filename.split(".").pop()?.toLowerCase() || ""; const codeExts = [ "ts", "tsx", "js", "jsx", "py", "go", "rs", "java", "c", "cpp", "h", "rb", "php", "swift", "kt", ]; const docExts = ["md", "txt", "doc", "docx", "pdf", "rst", "tex"]; const imageExts = ["png", "jpg", "jpeg", "gif", "svg", "ico", "webp", "bmp"]; const musicExts = ["mp3", "wav", "flac", "aac", "ogg", "m4a", "wma"]; if (codeExts.includes(ext)) return "code"; if (docExts.includes(ext)) return "documents"; if (imageExts.includes(ext)) return "images"; if (musicExts.includes(ext)) return "music"; if (["json", "yaml", "yml", "toml", "cfg", "ini", "env", "sh", "bash", "zsh"].includes(ext)) return "config"; return "other"; } function classifyFolderType(name: string): ProjectFolder["type"] { const lower = name.toLowerCase(); if (lower.includes("music")) return "music"; if (lower.includes("picture") || lower.includes("image") || lower.includes("photo")) return "media"; if (lower.includes("document") || lower.includes("doc")) return "documents"; if ( lower.includes("project") || lower.includes("code") || lower.includes("src") || lower.includes("dev") || lower.includes("workspace") || lower.includes("repo") ) return "code"; return "workspace"; } // --------------------------------------------------------------------------- // Phase 2: Git repository discovery // --------------------------------------------------------------------------- async function discoverGitRepos(homeDir: string): Promise { const repos: RepoInfo[] = []; try { const gitDirs = await findGitDirectories(homeDir); for (const repoDir of gitDirs) { try { const repo = await analyzeGitRepo(repoDir, homeDir); if (repo) repos.push(repo); } catch { /* skip broken repos */ } } } catch { /* git not available */ } return repos; } async function findGitDirectories(dir: string, depth: number = 0): Promise { if (depth > 4) return []; // Limit search depth const results: string[] = []; try { const entries = await readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(dir, entry.name); if (entry.isDirectory()) { if (entry.name === ".git") { results.push(dirname(fullPath)); } else if ( entry.name !== "node_modules" && entry.name !== ".git" && entry.name !== ".venv" && entry.name !== "dist" ) { results.push(...(await findGitDirectories(fullPath, depth + 1))); } } } } catch { /* permission denied */ } return results; } async function analyzeGitRepo(repoDir: string, _homeDir: string): Promise { try { const name = repoDir.split("/").pop() || ""; // Get remote URL const remoteResult = await spawnPromise("git", ["remote", "get-url", "origin"], { cwd: repoDir, }).catch(() => ({ stdout: "" })); const remote = remoteResult.stdout.trim() || null; // Get current branch const branchResult = await spawnPromise("git", ["branch", "--show-current"], { cwd: repoDir, }).catch(() => ({ stdout: "" })); const branch = branchResult.stdout.trim() || "unknown"; // Get last commit const commitResult = await spawnPromise("git", ["log", "-1", "--format=%H"], { cwd: repoDir, }).catch(() => ({ stdout: "" })); const lastCommit = commitResult.stdout.trim() || null; // Get language stats via git ls-files + wc const languages = await getRepoLanguages(repoDir); // Get directory size const sizeResult = await spawnPromise("du", ["-sb", repoDir]).catch(() => ({ stdout: "0" })); const sizeBytes = parseInt(sizeResult.stdout.split("\t")[0] || "0", 10); return { path: repoDir, name, remote, branch, lastCommit, languages, sizeBytes }; } catch { return null; } } async function getRepoLanguages(repoDir: string): Promise> { const langs: Record = {}; try { const { stdout } = await spawnPromise("git", ["ls-files"], { cwd: repoDir }).catch(() => ({ stdout: "", })); const files = stdout.trim().split("\n").filter(Boolean); for (const file of files) { const ext = file.split(".").pop()?.toLowerCase() || ""; const langMap: Record = { ts: "TypeScript", tsx: "TypeScript", js: "JavaScript", jsx: "JavaScript", py: "Python", go: "Go", rs: "Rust", java: "Java", c: "C", cpp: "C++", rb: "Ruby", php: "PHP", swift: "Swift", kt: "Kotlin", }; const lang = langMap[ext] || ext || "unknown"; langs[lang] = (langs[lang] || 0) + 1; } } catch { /* no languages */ } return langs; } // --------------------------------------------------------------------------- // Phase 3: Profile compilation // --------------------------------------------------------------------------- function compileProfile( repos: RepoInfo[], fileSummary: FileSummary, _folders: ProjectFolder[], ): ProfileSummary { const interests: string[] = []; const skills: string[] = []; const techStack: string[] = []; const languageSet = new Set(); // Extract tech stack from repos for (const repo of repos) { for (const lang of Object.keys(repo.languages)) { techStack.push(lang); languageSet.add(lang); } } // Extract interests from file types for (const [type, count] of Object.entries(fileSummary.byType)) { if (count > 5) { interests.push(`${type} (${count} files)`); } } // Derive skills from languages const langToSkill: Record = { TypeScript: "TypeScript/JavaScript", Python: "Python", Go: "Go", Rust: "Rust", Java: "Java", C: "C/C++", "C++": "C/C++", Ruby: "Ruby", Swift: "Swift/Kotlin", PHP: "PHP", }; for (const lang of techStack) { const skill = langToSkill[lang]; if (skill && !skills.includes(skill)) skills.push(skill); } // Deduplicate const uniqueInterests = [...new Set(interests)].slice(0, 10); const uniqueSkills = [...new Set(skills)].slice(0, 15); const uniqueTech = [...new Set(techStack)].sort(); return { interests: uniqueInterests, skills: uniqueSkills, workStyle: uniqueTech.length > 3 ? "multi-language" : uniqueTech[0] ? `${uniqueTech[0]}-focused` : "general", communication: "structured", languages: Array.from(languageSet), techStack: uniqueTech, }; } // --------------------------------------------------------------------------- // Phase 4: Agent briefing // --------------------------------------------------------------------------- function generateBriefing( repos: RepoInfo[], profile: ProfileSummary, fileSummary: FileSummary, subAgentRoles: SubAgentRole[], ): string { const lines: string[] = []; lines.push(`# User Profile Briefing`); lines.push(``); lines.push(`## Technical Profile`); lines.push(`- **Tech Stack:** ${profile.techStack.join(", ") || "N/A"}`); lines.push(`- **Languages:** ${profile.languages.join(", ") || "N/A"}`); lines.push(`- **Work Style:** ${profile.workStyle}`); lines.push(``); lines.push(`## Local Repositories (${repos.length} found)`); for (const repo of repos) { lines.push( `- **${repo.name}** — ${repo.branch}, ${Object.values(repo.languages).reduce((a, b) => a + b, 0)} files, ${repo.remote || "no remote"}`, ); } lines.push(``); lines.push(`## File System Summary`); lines.push(`- **Total Files:** ${fileSummary.totalFiles.toLocaleString()}`); lines.push(`- **Total Size:** ${(fileSummary.totalSizeBytes / (1024 * 1024)).toFixed(0)} MB`); lines.push( `- **Categories:** ${Object.entries(fileSummary.byType) .map(([t, c]) => `${t}(${c})`) .join(", ")}`, ); lines.push(``); lines.push(`## User Interests`); lines.push(`- ${profile.interests.slice(0, 5).join("\n- ")}`); lines.push(``); lines.push(`## User Skills`); lines.push(`- ${profile.skills.slice(0, 5).join("\n- ")}`); lines.push(``); lines.push(`## Assigned Specialist Roles`); for (const role of subAgentRoles) { lines.push(`### ${role.name}`); lines.push(`- **Role:** ${role.role}`); lines.push(`- **Description:** ${role.description}`); lines.push(`- **Instructions:** ${role.instructions}`); lines.push(`- **Skills:** ${role.skills.join(", ")}`); lines.push(""); } lines.push(`---`); lines.push( `This profile should be used to tailor all future interactions, project recommendations, and agent assignments.`, ); return lines.join("\n"); } // --------------------------------------------------------------------------- // Phase 5: Project folder creation // --------------------------------------------------------------------------- async function createProjectFolders(repos: RepoInfo[], prisma: any): Promise { const created: CreatedProject[] = []; for (const repo of repos) { try { // Check if space already exists const existing = await prisma.space.findFirst({ where: { title: repo.name } }); if (existing) { created.push({ id: existing.id, name: repo.name, spaceId: existing.id, description: repo.remote || repo.path, }); continue; } // Create new space const space = await prisma.space.create({ data: { title: repo.name, path: repo.path }, }); created.push({ id: space.id, name: repo.name, spaceId: space.id, description: repo.remote || repo.path, }); } catch { // Space might already exist or DB unavailable — skip } } return created; } // --------------------------------------------------------------------------- // Phase 6: Sub-agent role creation // --------------------------------------------------------------------------- interface SubAgentRole { name: string; role: string; description: string; instructions: string; skills: string[]; } function generateSubAgentRoles(profile: ProfileSummary, repos: RepoInfo[]): SubAgentRole[] { const roles: SubAgentRole[] = []; const hasCode = profile.languages?.length > 0; const hasDesign = profile.interests?.some( (i) => i.toLowerCase().includes("design") || i.toLowerCase().includes("ui"), ); const hasData = profile.interests?.some( (i) => i.toLowerCase().includes("data") || i.toLowerCase().includes("analytics") || i.toLowerCase().includes("database"), ); const hasDevOps = profile.interests?.some( (i) => i.toLowerCase().includes("infra") || i.toLowerCase().includes("ops") || i.toLowerCase().includes("deploy"), ); const hasMusic = profile.interests?.some((i) => i.toLowerCase().includes("music")); const hasWriting = profile.interests?.some( (i) => i.toLowerCase().includes("write") || i.toLowerCase().includes("blog"), ); if (hasCode) { const primaryLang = profile.languages?.[0] || "TypeScript"; roles.push({ name: "Code Engineer", role: "software-engineer", description: `Specializes in ${primaryLang} development and code review`, instructions: `You are the Code Engineer specialist. Your expertise is in ${profile.languages?.join(", ") || "software development"}. Review code, write tests, debug issues, and implement features. You have access to the user's local repositories and can run commands to build and test code.`, skills: profile.skills.filter( (s) => s.toLowerCase().includes("code") || s.toLowerCase().includes("program"), ), }); } if (hasDesign) { roles.push({ name: "UI/UX Designer", role: "ui-designer", description: "Creates polished interfaces and design systems", instructions: "You are the UI/UX Designer specialist. Focus on creating beautiful, accessible interfaces. Review designs, suggest improvements, and ensure consistency across the user's projects.", skills: ["design", "UI/UX"], }); } if (hasData) { roles.push({ name: "Data Analyst", role: "data-analyst", description: "Analyzes data, creates reports, and extracts insights", instructions: "You are the Data Analyst specialist. Process data from the user's projects, create visualizations, generate reports, and provide actionable insights.", skills: ["data analysis", "statistics"], }); } if (hasDevOps) { roles.push({ name: "DevOps Engineer", role: "devops-engineer", description: "Manages infrastructure, deployments, and CI/CD", instructions: "You are the DevOps Engineer specialist. Handle deployments, manage infrastructure, configure CI/CD pipelines, and ensure system reliability.", skills: ["DevOps", "infrastructure"], }); } if (hasMusic) { roles.push({ name: "Music Producer", role: "music-producer", description: "Assists with music production and audio engineering", instructions: "You are the Music Producer specialist. Help with music composition, audio engineering, mixing, and mastering. You understand the user's music projects and can provide expert advice.", skills: ["music production", "audio engineering"], }); } if (hasWriting) { roles.push({ name: "Content Writer", role: "content-writer", description: "Creates and edits written content", instructions: "You are the Content Writer specialist. Write blog posts, documentation, articles, and other content for the user's projects and personal use.", skills: ["writing", "content creation"], }); } // Always include a general assistant roles.unshift({ name: "General Assistant", role: "general-assistant", description: "Handles general tasks and coordinates other specialists", instructions: `You are the General Assistant and team coordinator. Your role is to handle general queries, coordinate with other specialists (${ roles .slice(1) .map((r) => r.name) .join(", ") || "none yet" }), and manage the user's daily workflow. You have access to all tools and can delegate tasks to specialists when needed.`, skills: ["task management", "coordination"], }); return roles; } // --------------------------------------------------------------------------- // Utility // --------------------------------------------------------------------------- function spawnPromise( cmd: string, args: string[], options?: { cwd?: string }, ): Promise<{ stdout: string; stderr: string }> { return new Promise((resolve, reject) => { const child = spawn(cmd, args, { ...options, timeout: 15000 }); let stdout = ""; let stderr = ""; child.stdout?.on("data", (d: Buffer) => { stdout += d.toString(); }); child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); }); child.on("error", () => reject(new Error("spawn failed"))); child.on("close", (code) => { if (code === 0 || code === null) resolve({ stdout, stderr }); else reject(new Error(`exit ${code}`)); }); }); } // Keep old registry cleanup setInterval(() => { const cutoff = Date.now() - 3600000; for (const [id, r] of scanRegistry) { if (r.completedAt && r.completedAt.getTime() < cutoff) scanRegistry.delete(id); } }, 60000);