mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 01:03:49 +00:00
feat(coding-agent): add resources_discover hook
This commit is contained in:
parent
6b6030d549
commit
3b8d0a8921
18 changed files with 489 additions and 53 deletions
|
|
@ -14,7 +14,7 @@
|
|||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import type {
|
||||
Agent,
|
||||
AgentEvent,
|
||||
|
|
@ -64,7 +64,7 @@ import {
|
|||
import type { BashExecutionMessage, CustomMessage } from "./messages.js";
|
||||
import type { ModelRegistry } from "./model-registry.js";
|
||||
import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.js";
|
||||
import type { ResourceLoader } from "./resource-loader.js";
|
||||
import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.js";
|
||||
import type { BranchSummaryEntry, CompactionEntry, SessionManager } from "./session-manager.js";
|
||||
import type { SettingsManager } from "./settings-manager.js";
|
||||
import { buildSystemPrompt } from "./system-prompt.js";
|
||||
|
|
@ -1690,9 +1690,63 @@ export class AgentSession {
|
|||
if (this._extensionRunner) {
|
||||
this._applyExtensionBindings(this._extensionRunner);
|
||||
await this._extensionRunner.emit({ type: "session_start" });
|
||||
await this.extendResourcesFromExtensions("startup");
|
||||
}
|
||||
}
|
||||
|
||||
private async extendResourcesFromExtensions(reason: "startup" | "reload"): Promise<void> {
|
||||
if (!this._extensionRunner?.hasHandlers("resources_discover")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { skillPaths, promptPaths, themePaths } = await this._extensionRunner.emitResourcesDiscover(
|
||||
this._cwd,
|
||||
reason,
|
||||
);
|
||||
|
||||
if (skillPaths.length === 0 && promptPaths.length === 0 && themePaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const extensionPaths: ResourceExtensionPaths = {
|
||||
skillPaths: this.buildExtensionResourcePaths(skillPaths),
|
||||
promptPaths: this.buildExtensionResourcePaths(promptPaths),
|
||||
themePaths: this.buildExtensionResourcePaths(themePaths),
|
||||
};
|
||||
|
||||
this._resourceLoader.extendResources(extensionPaths);
|
||||
this._baseSystemPrompt = this._rebuildSystemPrompt(this.getActiveToolNames());
|
||||
this.agent.setSystemPrompt(this._baseSystemPrompt);
|
||||
}
|
||||
|
||||
private buildExtensionResourcePaths(entries: Array<{ path: string; extensionPath: string }>): Array<{
|
||||
path: string;
|
||||
metadata: { source: string; scope: "temporary"; origin: "top-level"; baseDir?: string };
|
||||
}> {
|
||||
return entries.map((entry) => {
|
||||
const source = this.getExtensionSourceLabel(entry.extensionPath);
|
||||
const baseDir = entry.extensionPath.startsWith("<") ? undefined : dirname(entry.extensionPath);
|
||||
return {
|
||||
path: entry.path,
|
||||
metadata: {
|
||||
source,
|
||||
scope: "temporary",
|
||||
origin: "top-level",
|
||||
baseDir,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private getExtensionSourceLabel(extensionPath: string): string {
|
||||
if (extensionPath.startsWith("<")) {
|
||||
return `extension:${extensionPath.replace(/[<>]/g, "")}`;
|
||||
}
|
||||
const base = basename(extensionPath);
|
||||
const name = base.replace(/\.(ts|js)$/, "");
|
||||
return `extension:${name}`;
|
||||
}
|
||||
|
||||
private _applyExtensionBindings(runner: ExtensionRunner): void {
|
||||
runner.setUIContext(this._extensionUIContext);
|
||||
runner.bindCommandContext(this._extensionCommandContextActions);
|
||||
|
|
@ -1882,6 +1936,7 @@ export class AgentSession {
|
|||
this._extensionErrorListener;
|
||||
if (this._extensionRunner && hasBindings) {
|
||||
await this._extensionRunner.emit({ type: "session_start" });
|
||||
await this.extendResourcesFromExtensions("reload");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -83,6 +83,9 @@ export type {
|
|||
// Commands
|
||||
RegisteredCommand,
|
||||
RegisteredTool,
|
||||
// Events - Resources
|
||||
ResourcesDiscoverEvent,
|
||||
ResourcesDiscoverResult,
|
||||
SendMessageHandler,
|
||||
SendUserMessageHandler,
|
||||
SessionBeforeCompactEvent,
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ import type {
|
|||
MessageRenderer,
|
||||
RegisteredCommand,
|
||||
RegisteredTool,
|
||||
ResourcesDiscoverEvent,
|
||||
ResourcesDiscoverResult,
|
||||
SessionBeforeCompactResult,
|
||||
SessionBeforeTreeResult,
|
||||
ToolCallEvent,
|
||||
|
|
@ -629,6 +631,54 @@ export class ExtensionRunner {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
async emitResourcesDiscover(
|
||||
cwd: string,
|
||||
reason: ResourcesDiscoverEvent["reason"],
|
||||
): Promise<{
|
||||
skillPaths: Array<{ path: string; extensionPath: string }>;
|
||||
promptPaths: Array<{ path: string; extensionPath: string }>;
|
||||
themePaths: Array<{ path: string; extensionPath: string }>;
|
||||
}> {
|
||||
const ctx = this.createContext();
|
||||
const skillPaths: Array<{ path: string; extensionPath: string }> = [];
|
||||
const promptPaths: Array<{ path: string; extensionPath: string }> = [];
|
||||
const themePaths: Array<{ path: string; extensionPath: string }> = [];
|
||||
|
||||
for (const ext of this.extensions) {
|
||||
const handlers = ext.handlers.get("resources_discover");
|
||||
if (!handlers || handlers.length === 0) continue;
|
||||
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
const event: ResourcesDiscoverEvent = { type: "resources_discover", cwd, reason };
|
||||
const handlerResult = await handler(event, ctx);
|
||||
const result = handlerResult as ResourcesDiscoverResult | undefined;
|
||||
|
||||
if (result?.skillPaths?.length) {
|
||||
skillPaths.push(...result.skillPaths.map((path) => ({ path, extensionPath: ext.path })));
|
||||
}
|
||||
if (result?.promptPaths?.length) {
|
||||
promptPaths.push(...result.promptPaths.map((path) => ({ path, extensionPath: ext.path })));
|
||||
}
|
||||
if (result?.themePaths?.length) {
|
||||
themePaths.push(...result.themePaths.map((path) => ({ path, extensionPath: ext.path })));
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const stack = err instanceof Error ? err.stack : undefined;
|
||||
this.emitError({
|
||||
extensionPath: ext.path,
|
||||
event: "resources_discover",
|
||||
error: message,
|
||||
stack,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { skillPaths, promptPaths, themePaths };
|
||||
}
|
||||
|
||||
/** Emit input event. Transforms chain, "handled" short-circuits. */
|
||||
async emitInput(text: string, images: ImageContent[] | undefined, source: InputSource): Promise<InputEventResult> {
|
||||
const ctx = this.createContext();
|
||||
|
|
|
|||
|
|
@ -329,6 +329,24 @@ export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = un
|
|||
renderResult?: (result: AgentToolResult<TDetails>, options: ToolRenderResultOptions, theme: Theme) => Component;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Resource Events
|
||||
// ============================================================================
|
||||
|
||||
/** Fired after session_start to allow extensions to provide additional resource paths. */
|
||||
export interface ResourcesDiscoverEvent {
|
||||
type: "resources_discover";
|
||||
cwd: string;
|
||||
reason: "startup" | "reload";
|
||||
}
|
||||
|
||||
/** Result from resources_discover event handler */
|
||||
export interface ResourcesDiscoverResult {
|
||||
skillPaths?: string[];
|
||||
promptPaths?: string[];
|
||||
themePaths?: string[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Session Events
|
||||
// ============================================================================
|
||||
|
|
@ -621,6 +639,7 @@ export function isLsToolResult(e: ToolResultEvent): e is LsToolResultEvent {
|
|||
|
||||
/** Union of all event types */
|
||||
export type ExtensionEvent =
|
||||
| ResourcesDiscoverEvent
|
||||
| SessionEvent
|
||||
| ContextEvent
|
||||
| BeforeAgentStartEvent
|
||||
|
|
@ -736,6 +755,7 @@ export interface ExtensionAPI {
|
|||
// Event Subscription
|
||||
// =========================================================================
|
||||
|
||||
on(event: "resources_discover", handler: ExtensionHandler<ResourcesDiscoverEvent, ResourcesDiscoverResult>): void;
|
||||
on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): void;
|
||||
on(
|
||||
event: "session_before_switch",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@ import { SettingsManager } from "./settings-manager.js";
|
|||
import type { Skill } from "./skills.js";
|
||||
import { loadSkills } from "./skills.js";
|
||||
|
||||
export interface ResourceExtensionPaths {
|
||||
skillPaths?: Array<{ path: string; metadata: PathMetadata }>;
|
||||
promptPaths?: Array<{ path: string; metadata: PathMetadata }>;
|
||||
themePaths?: Array<{ path: string; metadata: PathMetadata }>;
|
||||
}
|
||||
|
||||
export interface ResourceLoader {
|
||||
getExtensions(): LoadExtensionsResult;
|
||||
getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] };
|
||||
|
|
@ -27,6 +33,7 @@ export interface ResourceLoader {
|
|||
getSystemPrompt(): string | undefined;
|
||||
getAppendSystemPrompt(): string[];
|
||||
getPathMetadata(): Map<string, PathMetadata>;
|
||||
extendResources(paths: ResourceExtensionPaths): void;
|
||||
reload(): Promise<void>;
|
||||
}
|
||||
|
||||
|
|
@ -187,6 +194,9 @@ export class DefaultResourceLoader implements ResourceLoader {
|
|||
private systemPrompt?: string;
|
||||
private appendSystemPrompt: string[];
|
||||
private pathMetadata: Map<string, PathMetadata>;
|
||||
private lastSkillPaths: string[];
|
||||
private lastPromptPaths: string[];
|
||||
private lastThemePaths: string[];
|
||||
|
||||
constructor(options: DefaultResourceLoaderOptions) {
|
||||
this.cwd = options.cwd ?? process.cwd();
|
||||
|
|
@ -227,6 +237,9 @@ export class DefaultResourceLoader implements ResourceLoader {
|
|||
this.agentsFiles = [];
|
||||
this.appendSystemPrompt = [];
|
||||
this.pathMetadata = new Map();
|
||||
this.lastSkillPaths = [];
|
||||
this.lastPromptPaths = [];
|
||||
this.lastThemePaths = [];
|
||||
}
|
||||
|
||||
getExtensions(): LoadExtensionsResult {
|
||||
|
|
@ -261,6 +274,36 @@ export class DefaultResourceLoader implements ResourceLoader {
|
|||
return this.pathMetadata;
|
||||
}
|
||||
|
||||
extendResources(paths: ResourceExtensionPaths): void {
|
||||
const skillPaths = this.normalizeExtensionPaths(paths.skillPaths ?? []);
|
||||
const promptPaths = this.normalizeExtensionPaths(paths.promptPaths ?? []);
|
||||
const themePaths = this.normalizeExtensionPaths(paths.themePaths ?? []);
|
||||
|
||||
if (skillPaths.length > 0) {
|
||||
this.lastSkillPaths = this.mergePaths(
|
||||
this.lastSkillPaths,
|
||||
skillPaths.map((entry) => entry.path),
|
||||
);
|
||||
this.updateSkillsFromPaths(this.lastSkillPaths, skillPaths);
|
||||
}
|
||||
|
||||
if (promptPaths.length > 0) {
|
||||
this.lastPromptPaths = this.mergePaths(
|
||||
this.lastPromptPaths,
|
||||
promptPaths.map((entry) => entry.path),
|
||||
);
|
||||
this.updatePromptsFromPaths(this.lastPromptPaths, promptPaths);
|
||||
}
|
||||
|
||||
if (themePaths.length > 0) {
|
||||
this.lastThemePaths = this.mergePaths(
|
||||
this.lastThemePaths,
|
||||
themePaths.map((entry) => entry.path),
|
||||
);
|
||||
this.updateThemesFromPaths(this.lastThemePaths, themePaths);
|
||||
}
|
||||
}
|
||||
|
||||
async reload(): Promise<void> {
|
||||
const resolvedPaths = await this.packageManager.resolve();
|
||||
const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, {
|
||||
|
|
@ -356,67 +399,22 @@ export class DefaultResourceLoader implements ResourceLoader {
|
|||
? this.mergePaths(cliEnabledSkills, this.additionalSkillPaths)
|
||||
: this.mergePaths([...enabledSkills, ...cliEnabledSkills], this.additionalSkillPaths);
|
||||
|
||||
let skillsResult: { skills: Skill[]; diagnostics: ResourceDiagnostic[] };
|
||||
if (this.noSkills && skillPaths.length === 0) {
|
||||
skillsResult = { skills: [], diagnostics: [] };
|
||||
} else {
|
||||
skillsResult = loadSkills({
|
||||
cwd: this.cwd,
|
||||
agentDir: this.agentDir,
|
||||
skillPaths,
|
||||
includeDefaults: false,
|
||||
});
|
||||
}
|
||||
const resolvedSkills = this.skillsOverride ? this.skillsOverride(skillsResult) : skillsResult;
|
||||
this.skills = resolvedSkills.skills;
|
||||
this.skillDiagnostics = resolvedSkills.diagnostics;
|
||||
for (const skill of this.skills) {
|
||||
this.addDefaultMetadataForPath(skill.filePath);
|
||||
}
|
||||
this.lastSkillPaths = skillPaths;
|
||||
this.updateSkillsFromPaths(skillPaths);
|
||||
|
||||
const promptPaths = this.noPromptTemplates
|
||||
? this.mergePaths(cliEnabledPrompts, this.additionalPromptTemplatePaths)
|
||||
: this.mergePaths([...enabledPrompts, ...cliEnabledPrompts], this.additionalPromptTemplatePaths);
|
||||
|
||||
let promptsResult: { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] };
|
||||
if (this.noPromptTemplates && promptPaths.length === 0) {
|
||||
promptsResult = { prompts: [], diagnostics: [] };
|
||||
} else {
|
||||
const allPrompts = loadPromptTemplates({
|
||||
cwd: this.cwd,
|
||||
agentDir: this.agentDir,
|
||||
promptPaths,
|
||||
includeDefaults: false,
|
||||
});
|
||||
promptsResult = this.dedupePrompts(allPrompts);
|
||||
}
|
||||
const resolvedPrompts = this.promptsOverride ? this.promptsOverride(promptsResult) : promptsResult;
|
||||
this.prompts = resolvedPrompts.prompts;
|
||||
this.promptDiagnostics = resolvedPrompts.diagnostics;
|
||||
for (const prompt of this.prompts) {
|
||||
this.addDefaultMetadataForPath(prompt.filePath);
|
||||
}
|
||||
this.lastPromptPaths = promptPaths;
|
||||
this.updatePromptsFromPaths(promptPaths);
|
||||
|
||||
const themePaths = this.noThemes
|
||||
? this.mergePaths(cliEnabledThemes, this.additionalThemePaths)
|
||||
: this.mergePaths([...enabledThemes, ...cliEnabledThemes], this.additionalThemePaths);
|
||||
|
||||
let themesResult: { themes: Theme[]; diagnostics: ResourceDiagnostic[] };
|
||||
if (this.noThemes && themePaths.length === 0) {
|
||||
themesResult = { themes: [], diagnostics: [] };
|
||||
} else {
|
||||
const loaded = this.loadThemes(themePaths, false);
|
||||
const deduped = this.dedupeThemes(loaded.themes);
|
||||
themesResult = { themes: deduped.themes, diagnostics: [...loaded.diagnostics, ...deduped.diagnostics] };
|
||||
}
|
||||
const resolvedThemes = this.themesOverride ? this.themesOverride(themesResult) : themesResult;
|
||||
this.themes = resolvedThemes.themes;
|
||||
this.themeDiagnostics = resolvedThemes.diagnostics;
|
||||
for (const theme of this.themes) {
|
||||
if (theme.sourcePath) {
|
||||
this.addDefaultMetadataForPath(theme.sourcePath);
|
||||
}
|
||||
}
|
||||
this.lastThemePaths = themePaths;
|
||||
this.updateThemesFromPaths(themePaths);
|
||||
|
||||
for (const extension of this.extensionsResult.extensions) {
|
||||
this.addDefaultMetadataForPath(extension.path);
|
||||
|
|
@ -440,6 +438,128 @@ export class DefaultResourceLoader implements ResourceLoader {
|
|||
: baseAppend;
|
||||
}
|
||||
|
||||
private normalizeExtensionPaths(
|
||||
entries: Array<{ path: string; metadata: PathMetadata }>,
|
||||
): Array<{ path: string; metadata: PathMetadata }> {
|
||||
return entries.map((entry) => ({
|
||||
path: this.resolveResourcePath(entry.path),
|
||||
metadata: entry.metadata,
|
||||
}));
|
||||
}
|
||||
|
||||
private updateSkillsFromPaths(
|
||||
skillPaths: string[],
|
||||
extensionPaths: Array<{ path: string; metadata: PathMetadata }> = [],
|
||||
): void {
|
||||
let skillsResult: { skills: Skill[]; diagnostics: ResourceDiagnostic[] };
|
||||
if (this.noSkills && skillPaths.length === 0) {
|
||||
skillsResult = { skills: [], diagnostics: [] };
|
||||
} else {
|
||||
skillsResult = loadSkills({
|
||||
cwd: this.cwd,
|
||||
agentDir: this.agentDir,
|
||||
skillPaths,
|
||||
includeDefaults: false,
|
||||
});
|
||||
}
|
||||
const resolvedSkills = this.skillsOverride ? this.skillsOverride(skillsResult) : skillsResult;
|
||||
this.skills = resolvedSkills.skills;
|
||||
this.skillDiagnostics = resolvedSkills.diagnostics;
|
||||
this.applyExtensionMetadata(
|
||||
extensionPaths,
|
||||
this.skills.map((skill) => skill.filePath),
|
||||
);
|
||||
for (const skill of this.skills) {
|
||||
this.addDefaultMetadataForPath(skill.filePath);
|
||||
}
|
||||
}
|
||||
|
||||
private updatePromptsFromPaths(
|
||||
promptPaths: string[],
|
||||
extensionPaths: Array<{ path: string; metadata: PathMetadata }> = [],
|
||||
): void {
|
||||
let promptsResult: { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] };
|
||||
if (this.noPromptTemplates && promptPaths.length === 0) {
|
||||
promptsResult = { prompts: [], diagnostics: [] };
|
||||
} else {
|
||||
const allPrompts = loadPromptTemplates({
|
||||
cwd: this.cwd,
|
||||
agentDir: this.agentDir,
|
||||
promptPaths,
|
||||
includeDefaults: false,
|
||||
});
|
||||
promptsResult = this.dedupePrompts(allPrompts);
|
||||
}
|
||||
const resolvedPrompts = this.promptsOverride ? this.promptsOverride(promptsResult) : promptsResult;
|
||||
this.prompts = resolvedPrompts.prompts;
|
||||
this.promptDiagnostics = resolvedPrompts.diagnostics;
|
||||
this.applyExtensionMetadata(
|
||||
extensionPaths,
|
||||
this.prompts.map((prompt) => prompt.filePath),
|
||||
);
|
||||
for (const prompt of this.prompts) {
|
||||
this.addDefaultMetadataForPath(prompt.filePath);
|
||||
}
|
||||
}
|
||||
|
||||
private updateThemesFromPaths(
|
||||
themePaths: string[],
|
||||
extensionPaths: Array<{ path: string; metadata: PathMetadata }> = [],
|
||||
): void {
|
||||
let themesResult: { themes: Theme[]; diagnostics: ResourceDiagnostic[] };
|
||||
if (this.noThemes && themePaths.length === 0) {
|
||||
themesResult = { themes: [], diagnostics: [] };
|
||||
} else {
|
||||
const loaded = this.loadThemes(themePaths, false);
|
||||
const deduped = this.dedupeThemes(loaded.themes);
|
||||
themesResult = { themes: deduped.themes, diagnostics: [...loaded.diagnostics, ...deduped.diagnostics] };
|
||||
}
|
||||
const resolvedThemes = this.themesOverride ? this.themesOverride(themesResult) : themesResult;
|
||||
this.themes = resolvedThemes.themes;
|
||||
this.themeDiagnostics = resolvedThemes.diagnostics;
|
||||
const themePathsWithSource = this.themes.flatMap((theme) => (theme.sourcePath ? [theme.sourcePath] : []));
|
||||
this.applyExtensionMetadata(extensionPaths, themePathsWithSource);
|
||||
for (const theme of this.themes) {
|
||||
if (theme.sourcePath) {
|
||||
this.addDefaultMetadataForPath(theme.sourcePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private applyExtensionMetadata(
|
||||
extensionPaths: Array<{ path: string; metadata: PathMetadata }>,
|
||||
resourcePaths: string[],
|
||||
): void {
|
||||
if (extensionPaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = extensionPaths.map((entry) => ({
|
||||
path: resolve(entry.path),
|
||||
metadata: entry.metadata,
|
||||
}));
|
||||
|
||||
for (const entry of normalized) {
|
||||
if (!this.pathMetadata.has(entry.path)) {
|
||||
this.pathMetadata.set(entry.path, entry.metadata);
|
||||
}
|
||||
}
|
||||
|
||||
for (const resourcePath of resourcePaths) {
|
||||
const normalizedResourcePath = resolve(resourcePath);
|
||||
if (this.pathMetadata.has(normalizedResourcePath) || this.pathMetadata.has(resourcePath)) {
|
||||
continue;
|
||||
}
|
||||
const match = normalized.find(
|
||||
(entry) =>
|
||||
normalizedResourcePath === entry.path || normalizedResourcePath.startsWith(`${entry.path}${sep}`),
|
||||
);
|
||||
if (match) {
|
||||
this.pathMetadata.set(normalizedResourcePath, match.metadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private mergePaths(primary: string[], additional: string[]): string[] {
|
||||
const merged: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue