* fix(api): keep the onboarding scan off the host unless the owner opts in The first-run scan walked the home directory of the API process user for every account that signed up, with no consent, and its status endpoint accepted any scan id. Any account could read the repository paths and remotes of the server owner. The scan now stays simulated unless the deployment configures ONBOARDING_SCAN_HOME and the requesting account is the deployment owner; the previous HOME fallback is gone. Scan records remember who started them and the status endpoint answers only that account. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * ci: give each runner on a shared host its own E2E ports Two Linux runners live on the same host. Every Web E2E job first frees "stale" listeners on the fixed ports 3110, 3111, 5180 and 4321 with fuser -k, so a job starting on the second runner killed the Vite server of the job already running on the first one. The victim died with SIGKILL (exit 137), and its remaining tests failed on reloads that could no longer reach a server. This is what made the Web E2E run on this PR red, and it explains the recurring reload failures on main whenever two runs overlapped. The workflow now derives API_PORT, WEB_PORT and WWW_PORT from the runner name (suffix -N means an offset of N times 100), and the port-freeing script follows those settings instead of a hard-coded list. The harness, Vite, the API and the marketing site already read these variables. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u (cherry picked from commit 2f05ab6a817e5198e2a740706976108b7b1edde1) * test(testkit): cover the env-driven E2E ports instead of a fixed list The ports test parsed a hard-coded array that no longer exists. It now runs the script on a fake host whose fuser finds nothing, and checks the default ports, the per-runner override, the rejection of a non-numeric setting, and that the workflow picks the ports before freeing them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u (cherry picked from commit cd0aedafcdec1b24dea924d8924b3b29af29d598) * ci: keep every runner off the default E2E ports Branches that still free the fixed ports 3110, 3111, 5180 and 4321 keep running on the shared host (main, the nightly run, other open PRs) until they carry the per-runner ports. A job of ours on the runner without a numeric suffix used exactly those defaults, so such a neighbour could still kill it, and its own port freeing could kill the neighbour. The unsuffixed runner now starts at offset 100, so no job from this workflow touches the defaults in either direction. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u (cherry picked from commit ddf5febdb3072425442ef375ca0be63fbe09e7f1) * fix(web): recover lazy routes and overlays from a failed module fetch When the browser aborts a module request while a page loads, the lazy import rejects and the route throws, leaving a blank page. Chromium does this whenever a network interface appears or disappears mid-request (net::ERR_NETWORK_CHANGED), which happens on a laptop that changes networks and on the CI host every time a container starts or stops. A Web E2E run hit exactly this: two modules of the onboarding page failed to load and the suite waited on an empty document. Lazy routes and overlays now load through a helper that retries the import once and, when the browser keeps rejecting it from its module map, reloads the page a single time before surfacing the error. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u (cherry picked from commit d5dacb12f14380071ef14ba8845d18c9d39e3fac) * fix(web): reload once when the module graph is aborted while booting The lazy-route recovery cannot help when the browser aborts a module of the static import graph: nothing of the app runs, the root stays empty, and the only trace is a console error. A Web E2E run hit this right after a page reload, when four modules of the shell failed with net::ERR_NETWORK_CHANGED and the shell never rendered. An inline script in the HTML entry now listens for the error event of the module script and reloads the page once, guarded by a session flag that the entry module clears after a successful boot. The existing preload recovery stays in place; it only covers Vite's chunk preloads of production builds. A new E2E spec aborts one module request during boot and one lazy route request, and expects the sign-in page to render in both cases. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u (cherry picked from commit e5b7e036e1a60e7e270592c4b9382ee23ed45ccb) --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
816 lines
25 KiB
TypeScript
816 lines
25 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, stat } from "node:fs/promises";
|
|
import { dirname, join } from "node:path";
|
|
import type { Actor } from "@rakazo/contracts";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 {
|
|
id: string;
|
|
name: string;
|
|
spaceId: string;
|
|
description: 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();
|
|
}
|
|
|
|
export interface OnboardingScanRequest {
|
|
actor: Pick<Actor, "userId" | "isDeploymentOwner">;
|
|
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: any,
|
|
): Promise<{ scanId: string }> {
|
|
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, 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,
|
|
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, _botId: string, prisma: any): 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: 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<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 (
|
|
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<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: Project folder creation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function createProjectFolders(repos: RepoInfo[], prisma: any): Promise<CreatedProject[]> {
|
|
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);
|