The scan's project phase called prisma.space with fields the Space model does not have (title, path). Both calls threw, a per-repository try/catch swallowed the errors, and no project was ever created. With the field names fixed, the space would still have had no organization or membership, so nobody could have seen it. The phase now goes through createSpaceForMember and wireProjectWorkspace, the helpers behind spaces.create: each repository becomes a project of the deployment owner in their current organization, linked to its folder with its git remote, branch and head. It still runs only in the owner's scan with ONBOARDING_SCAN_HOME set. A repository whose real folder one of the owner's projects already links is skipped, so scanning again creates nothing; the phase stops at the per-member space limit; and a second start while a scan runs (a StrictMode remount, a retry) joins it instead of linking everything twice. Discovery no longer descends into dot folders, where shell and editor plugin checkouts would otherwise become projects. The Prisma client is typed, so a field the schema lacks is a compile error again instead of a swallowed one. readGitCheckout loaded child_process with require(), which does not exist in the ES module runtime the API runs in, so linking any folder with a .git directory threw "require is not defined", in the scan and in the New project dialog alike. It now imports spawn statically; the module is only reachable through its server-only subpath, so browser bundles still never load it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ebcq9seT9eCoHtqxuWEUzg
837 lines
26 KiB
TypeScript
837 lines
26 KiB
TypeScript
// 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, realpath, stat } from "node:fs/promises";
|
|
import { basename, dirname, join } from "node:path";
|
|
import type { Actor } from "@rakazo/contracts";
|
|
import type { PrismaClient } from "@rakazo/db";
|
|
import {
|
|
createSpaceForMember,
|
|
InvalidSpaceNameError,
|
|
SpaceLimitError,
|
|
wireProjectWorkspace,
|
|
} from "@rakazo/db";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<string, number>;
|
|
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<string, number>;
|
|
byExtension: Record<string, number>;
|
|
}
|
|
|
|
interface ProfileSummary {
|
|
interests: string[];
|
|
skills: string[];
|
|
workStyle: string;
|
|
communication: string;
|
|
languages: string[];
|
|
techStack: string[];
|
|
}
|
|
|
|
interface CreatedProject {
|
|
spaceId: string;
|
|
name: string;
|
|
path: string;
|
|
}
|
|
|
|
interface ScanRecord {
|
|
id: string;
|
|
botId: string;
|
|
ownerUserId: 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<string, ScanRecord>();
|
|
|
|
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();
|
|
}
|
|
|
|
type ScanActor = Pick<Actor, "userId" | "spaceId" | "isDeploymentOwner">;
|
|
|
|
export interface OnboardingScanRequest {
|
|
actor: ScanActor;
|
|
botId?: string;
|
|
/** Home-style directory to discover. Unset keeps the scan off the host filesystem. */
|
|
scanHome?: string;
|
|
}
|
|
|
|
export async function startOnboardingScan(
|
|
{ actor, botId, scanHome }: OnboardingScanRequest,
|
|
prisma: PrismaClient,
|
|
): Promise<{ scanId: string }> {
|
|
// A repeated start (a remount, a retry) joins the running scan, so no repository is linked twice.
|
|
for (const running of scanRegistry.values()) {
|
|
if (running.ownerUserId === actor.userId && !running.completedAt) return { scanId: running.id };
|
|
}
|
|
const scanId = `scan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
const record: ScanRecord = {
|
|
id: scanId,
|
|
botId: botId ?? "",
|
|
ownerUserId: actor.userId,
|
|
status: "scanning",
|
|
progress: 0,
|
|
message: "Scanning your computer...",
|
|
result: "[]",
|
|
startedAt: new Date(),
|
|
completedAt: null,
|
|
};
|
|
scanRegistry.set(scanId, record);
|
|
|
|
// Run async
|
|
void (async () => {
|
|
try {
|
|
// The host is the deployment owner's machine: only their onboarding may discover it, and
|
|
// only inside the directory the deployment explicitly configured.
|
|
if (
|
|
!scanHome ||
|
|
!actor.isDeploymentOwner ||
|
|
process.env.AGENT_RUNTIME === "scripted" ||
|
|
process.env.SANDBOX_PROVIDER === "fake"
|
|
) {
|
|
await runSimulatedScan(record);
|
|
return;
|
|
}
|
|
const result = await executeFullScan(scanHome, actor, 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,
|
|
userId: string,
|
|
): Promise<{
|
|
status: ScanRecord["status"];
|
|
progress: number;
|
|
message: string;
|
|
result?: unknown;
|
|
}> {
|
|
const record = scanRegistry.get(scanId);
|
|
if (!record || record.ownerUserId !== userId) 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,
|
|
actor: ScanActor,
|
|
prisma: PrismaClient,
|
|
): Promise<ScanResult> {
|
|
// 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: Projects for discovered repositories
|
|
const createdProjects = await createProjects(localRepos, actor, 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<string, number>;
|
|
byExtension: Record<string, number>;
|
|
}
|
|
|
|
async function scanDirectory(
|
|
dirPath: string,
|
|
_homeDir: string,
|
|
_relativePath: string,
|
|
): Promise<DirStats> {
|
|
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<RepoInfo[]> {
|
|
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<string[]> {
|
|
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 (
|
|
// Dot folders hold tools and caches (shell and editor plugins), not the person's projects.
|
|
!entry.name.startsWith(".") &&
|
|
entry.name !== "node_modules" &&
|
|
entry.name !== "dist"
|
|
) {
|
|
results.push(...(await findGitDirectories(fullPath, depth + 1)));
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
/* permission denied */
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
async function analyzeGitRepo(repoDir: string, _homeDir: string): Promise<RepoInfo | null> {
|
|
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<Record<string, number>> {
|
|
const langs: Record<string, number> = {};
|
|
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<string, string> = {
|
|
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<string>();
|
|
|
|
// 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<string, string> = {
|
|
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: Projects for discovered repositories
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Give each discovered repository a project linked to its folder, unless one already links it. */
|
|
async function createProjects(
|
|
repos: RepoInfo[],
|
|
actor: ScanActor,
|
|
prisma: PrismaClient,
|
|
): Promise<CreatedProject[]> {
|
|
const linked = await prisma.space.findMany({
|
|
where: { workspacePath: { not: null }, memberships: { some: { userId: actor.userId } } },
|
|
select: { workspacePath: true },
|
|
});
|
|
const linkedFolders = new Set(linked.map((space) => space.workspacePath));
|
|
const created: CreatedProject[] = [];
|
|
|
|
for (const repo of repos) {
|
|
// Compare and link the real folder, so the next scan recognizes the projects of this one.
|
|
const folder = await realpath(repo.path).catch(() => null);
|
|
if (!folder || linkedFolders.has(folder)) continue;
|
|
let space: { id: string; name: string };
|
|
try {
|
|
space = await createSpaceForMember(prisma, {
|
|
currentSpaceId: actor.spaceId,
|
|
userId: actor.userId,
|
|
name: basename(folder),
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof SpaceLimitError) break;
|
|
if (error instanceof InvalidSpaceNameError) continue;
|
|
throw error;
|
|
}
|
|
const workspace = await wireProjectWorkspace(prisma, {
|
|
spaceId: space.id,
|
|
workspacePath: folder,
|
|
});
|
|
created.push({ spaceId: space.id, name: space.name, path: workspace.path });
|
|
}
|
|
|
|
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);
|