From 3b8d0a892141a950eeb255120c540b0ccf8ff8e9 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 1 Feb 2026 02:20:23 +0100 Subject: [PATCH] feat(coding-agent): add resources_discover hook --- AGENTS.md | 1 + packages/coding-agent/CHANGELOG.md | 1 + .../examples/extensions/README.md | 6 + .../extensions/dynamic-resources/SKILL.md | 8 + .../extensions/dynamic-resources/dynamic.json | 79 +++++++ .../extensions/dynamic-resources/dynamic.md | 5 + .../extensions/dynamic-resources/index.ts | 15 ++ .../examples/sdk/12-full-control.ts | 1 + .../coding-agent/src/core/agent-session.ts | 59 ++++- .../coding-agent/src/core/extensions/index.ts | 3 + .../src/core/extensions/runner.ts | 50 ++++ .../coding-agent/src/core/extensions/types.ts | 20 ++ .../coding-agent/src/core/resource-loader.ts | 222 ++++++++++++++---- .../src/modes/interactive/interactive-mode.ts | 3 + .../coding-agent/test/resource-loader.test.ts | 65 +++++ packages/coding-agent/test/sdk-skills.test.ts | 2 + packages/coding-agent/test/utilities.ts | 1 + packages/mom/src/agent.ts | 1 + 18 files changed, 489 insertions(+), 53 deletions(-) create mode 100644 packages/coding-agent/examples/extensions/dynamic-resources/SKILL.md create mode 100644 packages/coding-agent/examples/extensions/dynamic-resources/dynamic.json create mode 100644 packages/coding-agent/examples/extensions/dynamic-resources/dynamic.md create mode 100644 packages/coding-agent/examples/extensions/dynamic-resources/index.ts diff --git a/AGENTS.md b/AGENTS.md index 7ec327d5..a469045c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,7 @@ read README.md, then ask which module(s) to work on. Based on the answer, read t ## Commands - After code changes (not documentation changes): `npm run check` (get full output, no tail). Fix all errors, warnings, and infos before committing. +- Note: `npm run check` does not run tests. - NEVER run: `npm run dev`, `npm run build`, `npm test` - Only run specific tests if user instructs: `npm test -- test/specific.test.ts` - When writing tests, run them, identify issues in either the test or implementation, and iterate until fixed. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b0b60fec..6bf12546 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -11,6 +11,7 @@ - Added OAuth `modifyModels` hook support for extension-registered providers at registration time. ([#940](https://github.com/badlogic/pi-mono/pull/940) by [@4h9fbZ](https://github.com/4h9fbZ)) - Added Qwen thinking format support for OpenAI-compatible completions via `enable_thinking`. ([#940](https://github.com/badlogic/pi-mono/pull/940) by [@4h9fbZ](https://github.com/4h9fbZ)) - Added sticky column tracking for vertical cursor navigation so the editor restores the preferred column when moving across short lines. ([#1120](https://github.com/badlogic/pi-mono/pull/1120) by [@Perlence](https://github.com/Perlence)) +- Added `resources_discover` extension hook to supply additional skills, prompts, and themes on startup and reload. ### Fixed diff --git a/packages/coding-agent/examples/extensions/README.md b/packages/coding-agent/examples/extensions/README.md index 86ccf7db..65de402a 100644 --- a/packages/coding-agent/examples/extensions/README.md +++ b/packages/coding-agent/examples/extensions/README.md @@ -88,6 +88,12 @@ cp permission-gate.ts ~/.pi/agent/extensions/ |-----------|-------------| | `mac-system-theme.ts` | Syncs pi theme with macOS dark/light mode | +### Resources + +| Extension | Description | +|-----------|-------------| +| `dynamic-resources/` | Loads skills, prompts, and themes using `resources_discover` | + ### Messages & Communication | Extension | Description | diff --git a/packages/coding-agent/examples/extensions/dynamic-resources/SKILL.md b/packages/coding-agent/examples/extensions/dynamic-resources/SKILL.md new file mode 100644 index 00000000..66162e15 --- /dev/null +++ b/packages/coding-agent/examples/extensions/dynamic-resources/SKILL.md @@ -0,0 +1,8 @@ +--- +name: dynamic-resources +description: Example skill loaded from resources_discover +--- + +# Dynamic Resources Skill + +This skill is provided by the dynamic-resources extension. diff --git a/packages/coding-agent/examples/extensions/dynamic-resources/dynamic.json b/packages/coding-agent/examples/extensions/dynamic-resources/dynamic.json new file mode 100644 index 00000000..73b8db3b --- /dev/null +++ b/packages/coding-agent/examples/extensions/dynamic-resources/dynamic.json @@ -0,0 +1,79 @@ +{ + "$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json", + "name": "dynamic-resources", + "vars": { + "cyan": "#00d7ff", + "blue": "#5f87ff", + "green": "#b5bd68", + "red": "#cc6666", + "yellow": "#ffff00", + "gray": "#808080", + "dimGray": "#666666", + "darkGray": "#505050", + "accent": "#8abeb7", + "selectedBg": "#3a3a4a", + "userMsgBg": "#343541", + "toolPendingBg": "#282832", + "toolSuccessBg": "#283228", + "toolErrorBg": "#3c2828", + "customMsgBg": "#2d2838" + }, + "colors": { + "accent": "accent", + "border": "blue", + "borderAccent": "cyan", + "borderMuted": "darkGray", + "success": "green", + "error": "red", + "warning": "yellow", + "muted": "gray", + "dim": "dimGray", + "text": "", + "thinkingText": "gray", + "selectedBg": "selectedBg", + "userMessageBg": "userMsgBg", + "userMessageText": "", + "customMessageBg": "customMsgBg", + "customMessageText": "", + "customMessageLabel": "#9575cd", + "toolPendingBg": "toolPendingBg", + "toolSuccessBg": "toolSuccessBg", + "toolErrorBg": "toolErrorBg", + "toolTitle": "", + "toolOutput": "gray", + "mdHeading": "#f0c674", + "mdLink": "#81a2be", + "mdLinkUrl": "dimGray", + "mdCode": "accent", + "mdCodeBlock": "green", + "mdCodeBlockBorder": "gray", + "mdQuote": "gray", + "mdQuoteBorder": "gray", + "mdHr": "gray", + "mdListBullet": "accent", + "toolDiffAdded": "green", + "toolDiffRemoved": "red", + "toolDiffContext": "gray", + "syntaxComment": "#6A9955", + "syntaxKeyword": "#569CD6", + "syntaxFunction": "#DCDCAA", + "syntaxVariable": "#9CDCFE", + "syntaxString": "#CE9178", + "syntaxNumber": "#B5CEA8", + "syntaxType": "#4EC9B0", + "syntaxOperator": "#D4D4D4", + "syntaxPunctuation": "#D4D4D4", + "thinkingOff": "darkGray", + "thinkingMinimal": "#6e6e6e", + "thinkingLow": "#5f87af", + "thinkingMedium": "#81a2be", + "thinkingHigh": "#b294bb", + "thinkingXhigh": "#d183e8", + "bashMode": "green" + }, + "export": { + "pageBg": "#18181e", + "cardBg": "#1e1e24", + "infoBg": "#3c3728" + } +} diff --git a/packages/coding-agent/examples/extensions/dynamic-resources/dynamic.md b/packages/coding-agent/examples/extensions/dynamic-resources/dynamic.md new file mode 100644 index 00000000..da85f71c --- /dev/null +++ b/packages/coding-agent/examples/extensions/dynamic-resources/dynamic.md @@ -0,0 +1,5 @@ +--- +description: Example prompt template loaded from resources_discover +--- + +Summarize the current repository structure and mention any build or test commands. diff --git a/packages/coding-agent/examples/extensions/dynamic-resources/index.ts b/packages/coding-agent/examples/extensions/dynamic-resources/index.ts new file mode 100644 index 00000000..684ad5b0 --- /dev/null +++ b/packages/coding-agent/examples/extensions/dynamic-resources/index.ts @@ -0,0 +1,15 @@ +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +const baseDir = dirname(fileURLToPath(import.meta.url)); + +export default function (pi: ExtensionAPI) { + pi.on("resources_discover", () => { + return { + skillPaths: [join(baseDir, "SKILL.md")], + promptPaths: [join(baseDir, "dynamic.md")], + themePaths: [join(baseDir, "dynamic.json")], + }; + }); +} diff --git a/packages/coding-agent/examples/sdk/12-full-control.ts b/packages/coding-agent/examples/sdk/12-full-control.ts index 66823c83..0e9941a4 100644 --- a/packages/coding-agent/examples/sdk/12-full-control.ts +++ b/packages/coding-agent/examples/sdk/12-full-control.ts @@ -54,6 +54,7 @@ const resourceLoader: ResourceLoader = { Available: read, bash. Be concise.`, getAppendSystemPrompt: () => [], getPathMetadata: () => new Map(), + extendResources: () => {}, reload: async () => {}, }; diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index d01e44ce..e0acf8c9 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -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 { + 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"); } } diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index b842dd53..eb788072 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -83,6 +83,9 @@ export type { // Commands RegisteredCommand, RegisteredTool, + // Events - Resources + ResourcesDiscoverEvent, + ResourcesDiscoverResult, SendMessageHandler, SendUserMessageHandler, SessionBeforeCompactEvent, diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 7553058b..791c85ae 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -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 { const ctx = this.createContext(); diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index ad6c73dc..0ef7f614 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -329,6 +329,24 @@ export interface ToolDefinition, 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): void; on(event: "session_start", handler: ExtensionHandler): void; on( event: "session_before_switch", diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index d1517681..ff7925a9 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -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; + extendResources(paths: ResourceExtensionPaths): void; reload(): Promise; } @@ -187,6 +194,9 @@ export class DefaultResourceLoader implements ResourceLoader { private systemPrompt?: string; private appendSystemPrompt: string[]; private pathMetadata: Map; + 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 { 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(); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 73f47da9..d364dbbd 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -1066,6 +1066,9 @@ export class InteractiveMode { }, }); + setRegisteredThemes(this.session.resourceLoader.getThemes().themes); + this.rebuildAutocomplete(); + const extensionRunner = this.session.extensionRunner; if (!extensionRunner) { this.showLoadedResources({ extensionPaths: [], force: false }); diff --git a/packages/coding-agent/test/resource-loader.test.ts b/packages/coding-agent/test/resource-loader.test.ts index 567073c8..f4109e8a 100644 --- a/packages/coding-agent/test/resource-loader.test.ts +++ b/packages/coding-agent/test/resource-loader.test.ts @@ -168,6 +168,71 @@ Content`, }); }); + describe("extendResources", () => { + it("should load skills and prompts with extension metadata", async () => { + const extraSkillDir = join(tempDir, "extra-skills", "extra-skill"); + mkdirSync(extraSkillDir, { recursive: true }); + const skillPath = join(extraSkillDir, "SKILL.md"); + writeFileSync( + skillPath, + `--- +name: extra-skill +description: Extra skill +--- +Extra content`, + ); + + const extraPromptDir = join(tempDir, "extra-prompts"); + mkdirSync(extraPromptDir, { recursive: true }); + const promptPath = join(extraPromptDir, "extra.md"); + writeFileSync( + promptPath, + `--- +description: Extra prompt +--- +Extra prompt content`, + ); + + const loader = new DefaultResourceLoader({ cwd, agentDir }); + await loader.reload(); + + loader.extendResources({ + skillPaths: [ + { + path: extraSkillDir, + metadata: { + source: "extension:extra", + scope: "temporary", + origin: "top-level", + baseDir: extraSkillDir, + }, + }, + ], + promptPaths: [ + { + path: promptPath, + metadata: { + source: "extension:extra", + scope: "temporary", + origin: "top-level", + baseDir: extraPromptDir, + }, + }, + ], + }); + + const { skills } = loader.getSkills(); + expect(skills.some((skill) => skill.name === "extra-skill")).toBe(true); + + const { prompts } = loader.getPrompts(); + expect(prompts.some((prompt) => prompt.name === "extra")).toBe(true); + + const metadata = loader.getPathMetadata(); + expect(metadata.get(skillPath)?.source).toBe("extension:extra"); + expect(metadata.get(promptPath)?.source).toBe("extension:extra"); + }); + }); + describe("noSkills option", () => { it("should skip skill discovery when noSkills is true", async () => { const skillsDir = join(agentDir, "skills"); diff --git a/packages/coding-agent/test/sdk-skills.test.ts b/packages/coding-agent/test/sdk-skills.test.ts index 48c96cdd..fa38cb74 100644 --- a/packages/coding-agent/test/sdk-skills.test.ts +++ b/packages/coding-agent/test/sdk-skills.test.ts @@ -59,6 +59,7 @@ This is a test skill. getSystemPrompt: () => undefined, getAppendSystemPrompt: () => [], getPathMetadata: () => new Map(), + extendResources: () => {}, reload: async () => {}, }; @@ -92,6 +93,7 @@ This is a test skill. getSystemPrompt: () => undefined, getAppendSystemPrompt: () => [], getPathMetadata: () => new Map(), + extendResources: () => {}, reload: async () => {}, }; diff --git a/packages/coding-agent/test/utilities.ts b/packages/coding-agent/test/utilities.ts index 6e4e60b4..ce28b07b 100644 --- a/packages/coding-agent/test/utilities.ts +++ b/packages/coding-agent/test/utilities.ts @@ -184,6 +184,7 @@ export function createTestResourceLoader(): ResourceLoader { getSystemPrompt: () => undefined, getAppendSystemPrompt: () => [], getPathMetadata: () => new Map(), + extendResources: () => {}, reload: async () => {}, }; } diff --git a/packages/mom/src/agent.ts b/packages/mom/src/agent.ts index 2733dbd5..19feb0b0 100644 --- a/packages/mom/src/agent.ts +++ b/packages/mom/src/agent.ts @@ -459,6 +459,7 @@ function createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDi getSystemPrompt: () => systemPrompt, getAppendSystemPrompt: () => [], getPathMetadata: () => new Map(), + extendResources: () => {}, reload: async () => {}, };