16d7f9aec0
* feat: monorepo-friendly discovery for AGENTS.md and skills
Support hierarchical config discovery in monorepos by walking up from
cwd through ancestor directories, combining files from all levels
instead of only checking the immediate working directory.
AGENTS.md context files:
- Change dedup key from file.level to file.level + depth, so context
files at different directory levels coexist instead of shadowing
- Root AGENTS.md and sub-project AGENTS.md are both included in the
system prompt, ordered from least specific to most specific
Skills:
- Modify all 4 providers with project-level skill directories (native,
claude, codex, opencode) to walk up from cwd through ancestors,
scanning for skills at each level
- Skills at closer directories win on name conflicts via existing
name-based dedup
Repo root boundary:
- Add findRepoRoot() utility that detects .git to identify repo root
- Add repoRoot field to LoadContext, computed once in loadCapability()
- All walk-up traversals (AGENTS.md, skills, nearest config dir) stop
at the repo root, preventing discovery from leaking outside the repo
- When not in a git repo, falls back to walking to filesystem root
Files changed:
- capability/fs.ts: add findRepoRoot()
- capability/types.ts: add repoRoot to LoadContext
- capability/index.ts: compute repoRoot in loadCapability()
- capability/context-file.ts: depth-aware dedup key
- discovery/agents-md.ts: bound walk-up at repo root
- discovery/builtin.ts: getAncestorDirs stopAt param, skill walk-up
- discovery/claude.ts: skill walk-up with repo root bound
- discovery/codex.ts: skill walk-up with repo root bound
- discovery/opencode.ts: skill walk-up with repo root bound
- extensibility/skills.ts: add repoRoot to inline LoadContext
* feat(agents-provider): add project-level discovery with ancestor walk-up for all capability types
The agents provider (.agent/.agents directories) previously only loaded
capabilities from the user home directory (~/.agent/, ~/.agents/). This
meant project-level .agents/ directories in monorepo roots were not
discovered when sessions started from subdirectories.
Add getProjectPathCandidates() helper that walks from cwd up to repoRoot,
scanning both .agent/ and .agents/ at each ancestor level. Apply this to
all six capability types: skills, rules, prompts, commands, context files
(AGENTS.md), and system prompts (SYSTEM.md). This matches the ancestor
walk-up behavior already present in the builtin (.omp), claude, codex,
and opencode providers.
All loaders now parallelize project-level and user-level scans via
Promise.all. Project-level results appear closest-first so dedup at the
capability layer picks the nearest override.
* fix(discovery): remove hard cap of 20 on ancestor directory walking
All walk-up loops already terminate naturally at repoRoot or filesystem
root. The depth < 20 / .slice(0, 20) / MAX_DEPTH caps were redundant
safety guards that would silently stop discovery in deeply nested
projects.
Removed from: agents.ts, agents-md.ts, builtin.ts, claude.ts, codex.ts,
opencode.ts, and corresponding test files.
* fix(agents-provider): set depth on project-level ContextFile entries for correct dedup
loadContextFiles was creating project-level ContextFile entries without
a depth field. Since contextFileCapability.key uses
`project:${file.depth ?? 0}`, all ancestor-level AGENTS.md files
collapsed to the same key and only the first survived dedup.
Compute depth via calculateDepth(cwd, ancestorDir) where ancestorDir is
two levels up from the file path (past the .agent/.agents config dir).
This gives each ancestor level a distinct dedup key.
* fix(discovery): stop ancestor walk-up at $HOME when not in a git repo
When repoRoot is null (no .git found), walk-up loops traversed all the
way to filesystem root. This caused $HOME to be scanned as a project-
level directory and then again as user-level, producing duplicates.
All providers now use `ctx.repoRoot ?? ctx.home` as the stop boundary:
stop at repoRoot if in a repo, otherwise stop at home. Applied to
agents.ts, agents-md.ts, builtin.ts, claude.ts, codex.ts, and
opencode.ts.
* fix(context-files): clamp depth >= 0 in dedup key and fix provider depth computations
The dedup key `project:${file.depth}` used raw depth, which could be
negative when providers computed it from config subdirectories (e.g.
.claude/, .github/, .gemini/) rather than the ancestor directory. This
caused same-scope cwd-level files to get distinct keys like project:-1
and project:0, bypassing dedup and injecting conflicting instructions.
Two-layer fix:
1. Key function: clamp to Math.max(0, depth) so any file at or below
cwd is treated as cwd-scope (depth 0). Defensive against future
providers.
2. Providers: fix root cause in claude.ts, gemini.ts, github.ts to
compute depth from the ancestor directory (parent of the config
subdir), not the config subdir itself.
---------
Co-authored-by: Can Bölük <can1357@users.noreply.github.com>
424 lines
12 KiB
TypeScript
424 lines
12 KiB
TypeScript
/**
|
|
* Capability Registry
|
|
*
|
|
* Central registry for capabilities and providers. Provides the main API for:
|
|
* - Defining capabilities (what we're looking for)
|
|
* - Registering providers (where to find it)
|
|
* - Loading items for a capability across all providers
|
|
*/
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
|
|
|
|
import type { Settings } from "../config/settings";
|
|
import { clearCache as clearFsCache, findRepoRoot, cacheStats as fsCacheStats, invalidate as invalidateFs } from "./fs";
|
|
import type {
|
|
Capability,
|
|
CapabilityInfo,
|
|
CapabilityResult,
|
|
LoadContext,
|
|
LoadOptions,
|
|
Provider,
|
|
ProviderInfo,
|
|
SourceMeta,
|
|
} from "./types";
|
|
|
|
// =============================================================================
|
|
// Registry State
|
|
// =============================================================================
|
|
|
|
/** Registry of all capabilities */
|
|
const capabilities = new Map<string, Capability<unknown>>();
|
|
|
|
/** Reverse index: provider ID -> capability IDs it's registered for */
|
|
const providerCapabilities = new Map<string, Set<string>>();
|
|
|
|
/** Provider display metadata (shared across capabilities) */
|
|
const providerMeta = new Map<string, { displayName: string; description: string }>();
|
|
|
|
/** Disabled providers (by ID) */
|
|
const disabledProviders = new Set<string>();
|
|
|
|
/** Settings manager for persistence (if set) */
|
|
let settings: Settings | null = null;
|
|
|
|
// =============================================================================
|
|
// Registration API
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Define a new capability.
|
|
*/
|
|
export function defineCapability<T>(def: Omit<Capability<T>, "providers">): Capability<T> {
|
|
if (capabilities.has(def.id)) {
|
|
throw new Error(`Capability "${def.id}" is already defined`);
|
|
}
|
|
const capability: Capability<T> = { ...def, providers: [] };
|
|
capabilities.set(def.id, capability as Capability<unknown>);
|
|
return capability;
|
|
}
|
|
|
|
/**
|
|
* Register a provider for a capability.
|
|
*/
|
|
export function registerProvider<T>(capabilityId: string, provider: Provider<T>): void {
|
|
const capability = capabilities.get(capabilityId);
|
|
if (!capability) {
|
|
throw new Error(`Unknown capability: "${capabilityId}". Define it first with defineCapability().`);
|
|
}
|
|
|
|
// Store provider metadata (for cross-capability display)
|
|
if (!providerMeta.has(provider.id)) {
|
|
providerMeta.set(provider.id, {
|
|
displayName: provider.displayName,
|
|
description: provider.description,
|
|
});
|
|
}
|
|
|
|
// Track which capabilities this provider is registered for
|
|
if (!providerCapabilities.has(provider.id)) {
|
|
providerCapabilities.set(provider.id, new Set());
|
|
}
|
|
providerCapabilities.get(provider.id)!.add(capabilityId);
|
|
|
|
// Insert in priority order (highest first)
|
|
const providers = capability.providers as Provider<T>[];
|
|
const idx = providers.findIndex(p => p.priority < provider.priority);
|
|
if (idx === -1) {
|
|
providers.push(provider);
|
|
} else {
|
|
providers.splice(idx, 0, provider);
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Loading API
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Async loading logic shared by loadCapability().
|
|
*/
|
|
async function loadImpl<T>(
|
|
capability: Capability<T>,
|
|
providers: Provider<T>[],
|
|
ctx: LoadContext,
|
|
options: LoadOptions,
|
|
): Promise<CapabilityResult<T>> {
|
|
const allItems: Array<T & { _source: SourceMeta; _shadowed?: boolean }> = [];
|
|
const allWarnings: string[] = [];
|
|
const contributingProviders: string[] = [];
|
|
|
|
const results = await Promise.all(
|
|
providers.map(async provider => {
|
|
try {
|
|
const result = await logger.timeAsync(`capability:${capability.id}:${provider.id}`, () =>
|
|
provider.load(ctx),
|
|
);
|
|
return { provider, result };
|
|
} catch (error) {
|
|
logger.debug(`capability:${capability.id}:${provider.id}:error`);
|
|
return { provider, error };
|
|
}
|
|
}),
|
|
);
|
|
|
|
for (const entry of results) {
|
|
const { provider } = entry;
|
|
if ("error" in entry) {
|
|
allWarnings.push(`[${provider.displayName}] Failed to load: ${entry.error}`);
|
|
continue;
|
|
}
|
|
|
|
const result = entry.result;
|
|
if (!result) continue;
|
|
|
|
if (result.warnings) {
|
|
allWarnings.push(...result.warnings.map(w => `[${provider.displayName}] ${w}`));
|
|
}
|
|
|
|
if (result.items.length > 0) {
|
|
contributingProviders.push(provider.id);
|
|
|
|
for (const item of result.items) {
|
|
const itemWithSource = item as T & { _source: SourceMeta };
|
|
if (itemWithSource._source) {
|
|
itemWithSource._source.providerName = provider.displayName;
|
|
allItems.push(itemWithSource as T & { _source: SourceMeta; _shadowed?: boolean });
|
|
} else {
|
|
allWarnings.push(`[${provider.displayName}] Item missing _source metadata, skipping`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Deduplicate by key (first wins = highest priority)
|
|
const seen = new Map<string, number>();
|
|
const deduped: Array<T & { _source: SourceMeta }> = [];
|
|
|
|
for (let i = 0; i < allItems.length; i++) {
|
|
const item = allItems[i];
|
|
const key = capability.key(item);
|
|
|
|
if (key === undefined) {
|
|
deduped.push(item);
|
|
} else if (!seen.has(key)) {
|
|
seen.set(key, i);
|
|
deduped.push(item);
|
|
} else {
|
|
item._shadowed = true;
|
|
}
|
|
}
|
|
|
|
// Validate items (only non-shadowed items)
|
|
if (capability.validate && !options.includeInvalid) {
|
|
for (let i = deduped.length - 1; i >= 0; i--) {
|
|
const error = capability.validate(deduped[i]);
|
|
if (error) {
|
|
const source = deduped[i]._source;
|
|
allWarnings.push(
|
|
`[${source?.providerName ?? "unknown"}] Invalid item at ${source?.path ?? "unknown"}: ${error}`,
|
|
);
|
|
deduped.splice(i, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
items: deduped,
|
|
all: allItems,
|
|
warnings: allWarnings,
|
|
providers: contributingProviders,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Filter providers based on options and disabled state.
|
|
*/
|
|
function filterProviders<T>(capability: Capability<T>, options: LoadOptions): Provider<T>[] {
|
|
let providers = (capability.providers as Provider<T>[]).filter(p => !disabledProviders.has(p.id));
|
|
|
|
if (options.providers) {
|
|
const allowed = new Set(options.providers);
|
|
providers = providers.filter(p => allowed.has(p.id));
|
|
}
|
|
if (options.excludeProviders) {
|
|
const excluded = new Set(options.excludeProviders);
|
|
providers = providers.filter(p => !excluded.has(p.id));
|
|
}
|
|
|
|
return providers;
|
|
}
|
|
|
|
/**
|
|
* Load a capability by ID.
|
|
*/
|
|
export async function loadCapability<T>(capabilityId: string, options: LoadOptions = {}): Promise<CapabilityResult<T>> {
|
|
const capability = capabilities.get(capabilityId) as Capability<T> | undefined;
|
|
if (!capability) {
|
|
throw new Error(`Unknown capability: "${capabilityId}"`);
|
|
}
|
|
|
|
const cwd = options.cwd ?? getProjectDir();
|
|
const home = os.homedir();
|
|
const repoRoot = await findRepoRoot(cwd);
|
|
const ctx: LoadContext = { cwd, home, repoRoot };
|
|
const providers = filterProviders(capability, options);
|
|
|
|
return await loadImpl(capability, providers, ctx, options);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Provider Enable/Disable API
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Initialize capability system with settings manager for persistence.
|
|
* Call this once on startup to enable persistent provider state.
|
|
*/
|
|
export function initializeWithSettings(activeSettings: Settings): void {
|
|
settings = activeSettings;
|
|
// Load disabled providers from settings
|
|
const disabled = settings.get("disabledProviders");
|
|
disabledProviders.clear();
|
|
for (const id of disabled) {
|
|
disabledProviders.add(id);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Persist current disabled providers to settings.
|
|
*/
|
|
function persistDisabledProviders(): void {
|
|
if (settings) {
|
|
settings.set("disabledProviders", Array.from(disabledProviders));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Disable a provider globally (across all capabilities).
|
|
*/
|
|
export function disableProvider(providerId: string): void {
|
|
disabledProviders.add(providerId);
|
|
persistDisabledProviders();
|
|
}
|
|
|
|
/**
|
|
* Enable a previously disabled provider.
|
|
*/
|
|
export function enableProvider(providerId: string): void {
|
|
disabledProviders.delete(providerId);
|
|
persistDisabledProviders();
|
|
}
|
|
|
|
/**
|
|
* Check if a provider is enabled.
|
|
*/
|
|
export function isProviderEnabled(providerId: string): boolean {
|
|
return !disabledProviders.has(providerId);
|
|
}
|
|
|
|
/**
|
|
* Get list of all disabled provider IDs.
|
|
*/
|
|
export function getDisabledProviders(): string[] {
|
|
return Array.from(disabledProviders);
|
|
}
|
|
|
|
/**
|
|
* Set disabled providers from a list (replaces current set).
|
|
*/
|
|
export function setDisabledProviders(providerIds: string[]): void {
|
|
disabledProviders.clear();
|
|
for (const id of providerIds) {
|
|
disabledProviders.add(id);
|
|
}
|
|
persistDisabledProviders();
|
|
}
|
|
|
|
// =============================================================================
|
|
// Introspection API
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Get a capability definition (for introspection).
|
|
*/
|
|
export function getCapability<T>(id: string): Capability<T> | undefined {
|
|
return capabilities.get(id) as Capability<T> | undefined;
|
|
}
|
|
|
|
/**
|
|
* List all registered capability IDs.
|
|
*/
|
|
export function listCapabilities(): string[] {
|
|
return Array.from(capabilities.keys());
|
|
}
|
|
|
|
/**
|
|
* Get capability info for UI display.
|
|
*/
|
|
export function getCapabilityInfo(capabilityId: string): CapabilityInfo | undefined {
|
|
const capability = capabilities.get(capabilityId);
|
|
if (!capability) return undefined;
|
|
|
|
return {
|
|
id: capability.id,
|
|
displayName: capability.displayName,
|
|
description: capability.description,
|
|
providers: capability.providers.map(p => ({
|
|
id: p.id,
|
|
displayName: p.displayName,
|
|
description: p.description,
|
|
priority: p.priority,
|
|
enabled: !disabledProviders.has(p.id),
|
|
})),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get all capabilities info for UI display.
|
|
*/
|
|
export function getAllCapabilitiesInfo(): CapabilityInfo[] {
|
|
return listCapabilities().map(id => getCapabilityInfo(id)!);
|
|
}
|
|
|
|
/**
|
|
* Get provider info for UI display.
|
|
*/
|
|
export function getProviderInfo(providerId: string): ProviderInfo | undefined {
|
|
const meta = providerMeta.get(providerId);
|
|
const caps = providerCapabilities.get(providerId);
|
|
if (!meta || !caps) return undefined;
|
|
|
|
// Find priority from first capability's provider list
|
|
let priority = 0;
|
|
for (const capId of caps) {
|
|
const cap = capabilities.get(capId);
|
|
const provider = cap?.providers.find(p => p.id === providerId);
|
|
if (provider) {
|
|
priority = provider.priority;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: providerId,
|
|
displayName: meta.displayName,
|
|
description: meta.description,
|
|
priority,
|
|
capabilities: Array.from(caps),
|
|
enabled: !disabledProviders.has(providerId),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get all providers info for UI display (deduplicated across capabilities).
|
|
*/
|
|
export function getAllProvidersInfo(): ProviderInfo[] {
|
|
const providers: ProviderInfo[] = [];
|
|
|
|
for (const providerId of providerMeta.keys()) {
|
|
const info = getProviderInfo(providerId);
|
|
if (info) {
|
|
providers.push(info);
|
|
}
|
|
}
|
|
|
|
// Sort by priority (highest first)
|
|
providers.sort((a, b) => b.priority - a.priority);
|
|
|
|
return providers;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Cache Management
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Reset all caches. Call after chdir or filesystem changes.
|
|
*/
|
|
export function reset(): void {
|
|
clearFsCache();
|
|
}
|
|
|
|
/**
|
|
* Invalidate cache for a specific path.
|
|
* @param filePath - Absolute or relative path to invalidate
|
|
*/
|
|
export function invalidate(filePath: string, cwd?: string): void {
|
|
const resolved = cwd ? path.resolve(cwd, filePath) : filePath;
|
|
invalidateFs(resolved);
|
|
}
|
|
|
|
/**
|
|
* Get cache stats for diagnostics.
|
|
*/
|
|
export function cacheStats(): { content: number; dir: number } {
|
|
return fsCacheStats();
|
|
}
|
|
|
|
// =============================================================================
|
|
// Re-exports
|
|
// =============================================================================
|
|
|
|
export type * from "./types";
|