diff --git a/packages/squad-sdk/package.json b/packages/squad-sdk/package.json index 5fc8902e3..87f8b5318 100644 --- a/packages/squad-sdk/package.json +++ b/packages/squad-sdk/package.json @@ -30,6 +30,10 @@ "types": "./dist/agents/index.d.ts", "import": "./dist/agents/index.js" }, + "./agents/personal": { + "types": "./dist/agents/personal.d.ts", + "import": "./dist/agents/personal.js" + }, "./adapter": { "types": "./dist/adapter/types.d.ts", "import": "./dist/adapter/types.js" diff --git a/packages/squad-sdk/src/agents/index.ts b/packages/squad-sdk/src/agents/index.ts index e6f8f3e5f..ff14e4e09 100644 --- a/packages/squad-sdk/src/agents/index.ts +++ b/packages/squad-sdk/src/agents/index.ts @@ -71,6 +71,14 @@ export { type OnboardResult, } from './onboarding.js'; +// --- Personal Squad Agents --- +export { + resolvePersonalAgents, + mergeSessionCast, + type PersonalAgentMeta, + type PersonalAgentManifest, +} from './personal.js'; + // --- Charter Types --- export interface AgentCharter { diff --git a/packages/squad-sdk/src/agents/personal.ts b/packages/squad-sdk/src/agents/personal.ts new file mode 100644 index 000000000..e200f4974 --- /dev/null +++ b/packages/squad-sdk/src/agents/personal.ts @@ -0,0 +1,96 @@ +/** + * Personal Squad Agent Resolution + * + * Discovers and merges personal agents from the user's personal squad directory + * into project session casts. Personal agents are ambient — they're automatically + * available in all project contexts with ghost protocol enforced. + * + * @module agents/personal + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { resolvePersonalSquadDir } from '../resolution.js'; +import { AgentManifest } from '../config/agent-source.js'; + +/** Metadata tag for personal agents in a session cast */ +export interface PersonalAgentMeta { + /** Always 'personal' for personal squad agents */ + origin: 'personal'; + /** Absolute path to the personal agent's directory */ + sourceDir: string; + /** Whether ghost protocol is enforced (always true in project context) */ + ghostProtocol: boolean; +} + +/** A project agent manifest augmented with personal origin info */ +export type PersonalAgentManifest = AgentManifest & { + personal: PersonalAgentMeta; +}; + +/** + * Discover personal agents from the user's personal squad directory. + * Returns empty array if personal squad is disabled or doesn't exist. + */ +export async function resolvePersonalAgents(): Promise { + const personalDir = resolvePersonalSquadDir(); + if (!personalDir) return []; + + const agentsDir = path.join(personalDir, 'agents'); + if (!fs.existsSync(agentsDir)) return []; + + const entries = await fs.promises.readdir(agentsDir, { withFileTypes: true }); + const agents: PersonalAgentManifest[] = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const charterPath = path.join(agentsDir, entry.name, 'charter.md'); + if (!fs.existsSync(charterPath)) continue; + + const charterContent = await fs.promises.readFile(charterPath, 'utf-8'); + const meta = parseCharterMetadataBasic(charterContent); + + agents.push({ + name: entry.name, + role: meta.role || 'personal', + source: 'personal', + personal: { + origin: 'personal', + sourceDir: path.join(agentsDir, entry.name), + ghostProtocol: true, + }, + }); + } + + return agents; +} + +/** Basic charter metadata parser for personal agents */ +function parseCharterMetadataBasic(content: string): { role?: string; name?: string } { + const roleMatch = content.match(/\*\*Role:\*\*\s*(.+)/); + const nameMatch = content.match(/\*\*Name:\*\*\s*(.+)/); + return { + role: roleMatch?.[1]?.trim(), + name: nameMatch?.[1]?.trim(), + }; +} + +/** + * Merge personal agents into a project session cast. + * Personal agents are tagged with origin: 'personal' and ghost protocol is enforced. + * Duplicate names: project agents take precedence over personal agents. + */ +export function mergeSessionCast( + projectAgents: AgentManifest[], + personalAgents: PersonalAgentManifest[] +): (AgentManifest | PersonalAgentManifest)[] { + const projectNames = new Set(projectAgents.map(a => a.name.toLowerCase())); + + // Filter out personal agents that conflict with project agent names + const uniquePersonal = personalAgents.filter( + a => !projectNames.has(a.name.toLowerCase()) + ); + + return [...projectAgents, ...uniquePersonal]; +} diff --git a/packages/squad-sdk/src/index.ts b/packages/squad-sdk/src/index.ts index 903c56885..dfac014fb 100644 --- a/packages/squad-sdk/src/index.ts +++ b/packages/squad-sdk/src/index.ts @@ -10,10 +10,12 @@ const pkg = require('../package.json'); export const VERSION: string = pkg.version; // Export public API -export { resolveSquad, resolveGlobalSquadPath, ensureSquadPath, loadDirConfig, isConsultMode } from './resolution.js'; +export { resolveSquad, resolveGlobalSquadPath, resolvePersonalSquadDir, ensureSquadPath, ensureSquadPathTriple, loadDirConfig, isConsultMode } from './resolution.js'; export type { SquadDirConfig, ResolvedSquadPaths } from './resolution.js'; export * from './config/index.js'; export * from './agents/onboarding.js'; +export { resolvePersonalAgents, mergeSessionCast } from './agents/personal.js'; +export type { PersonalAgentMeta, PersonalAgentManifest } from './agents/personal.js'; export * from './casting/index.js'; export * from './skills/index.js'; export { selectResponseTier, getTier } from './coordinator/response-tiers.js'; diff --git a/packages/squad-sdk/src/resolution.ts b/packages/squad-sdk/src/resolution.ts index cbc3acefe..fe45a8bfd 100644 --- a/packages/squad-sdk/src/resolution.ts +++ b/packages/squad-sdk/src/resolution.ts @@ -47,6 +47,8 @@ export interface ResolvedSquadPaths { projectDir: string; /** Team identity root (agents, casting, skills) */ teamDir: string; + /** User's personal squad dir, null if not found or disabled */ + personalDir: string | null; config: SquadDirConfig | null; name: '.squad' | '.ai-team'; isLegacy: boolean; @@ -199,6 +201,7 @@ export function resolveSquadPaths(startDir?: string): ResolvedSquadPaths | null mode: 'remote', projectDir, teamDir, + personalDir: resolvePersonalSquadDir(), config, name, isLegacy, @@ -210,6 +213,7 @@ export function resolveSquadPaths(startDir?: string): ResolvedSquadPaths | null mode: 'local', projectDir, teamDir: projectDir, + personalDir: resolvePersonalSquadDir(), config, name, isLegacy, @@ -254,6 +258,25 @@ export function resolveGlobalSquadPath(): string { return globalDir; } +/** + * Resolves the user's personal squad directory. + * Returns null if SQUAD_NO_PERSONAL is set or directory doesn't exist. + * + * Platform paths: + * - Windows: %APPDATA%/squad/personal-squad + * - macOS: ~/Library/Application Support/squad/personal-squad + * - Linux: $XDG_CONFIG_HOME/squad/personal-squad or ~/.config/squad/personal-squad + */ +export function resolvePersonalSquadDir(): string | null { + if (process.env['SQUAD_NO_PERSONAL']) return null; + + const globalDir = resolveGlobalSquadPath(); + const personalDir = path.join(globalDir, 'personal-squad'); + + if (!fs.existsSync(personalDir)) return null; + return personalDir; +} + /** * Validate that a file path is within `.squad/` or the system temp directory. * @@ -323,6 +346,33 @@ export function ensureSquadPathDual(filePath: string, projectDir: string, teamDi ); } +/** + * Validates a file path is inside one of three allowed directories: + * projectDir, teamDir, personalDir, or system temp. + * Extends ensureSquadPathDual() for triple-root (project + team + personal). + */ +export function ensureSquadPathTriple( + filePath: string, + projectDir: string, + teamDir: string, + personalDir: string | null +): string { + const resolved = path.resolve(filePath); + const tmpDir = os.tmpdir(); + + const allowed = [projectDir, teamDir, personalDir, tmpDir].filter(Boolean) as string[]; + + for (const dir of allowed) { + if (resolved.startsWith(path.resolve(dir) + path.sep) || resolved === path.resolve(dir)) { + return resolved; + } + } + + throw new Error( + `Path "${resolved}" is outside all allowed directories: ${allowed.join(', ')}` + ); +} + /** * ensureSquadPath that works with resolved dual-root paths. * Convenience wrapper around ensureSquadPathDual.