Browse Source

feat: add preset-scoped prompt fallback and document lookup order (#164)

* feat: add preset-scoped prompt file fallback for agents

* docs: describe preset-first prompt lookup behavior
NocturnesLK 1 month ago
parent
commit
2424960c41
5 changed files with 159 additions and 35 deletions
  1. 7 0
      docs/quick-reference.md
  2. 3 4
      src/agents/index.ts
  3. 15 8
      src/config/codemap.md
  4. 91 0
      src/config/loader.test.ts
  5. 43 23
      src/config/loader.ts

+ 7 - 0
docs/quick-reference.md

@@ -419,6 +419,9 @@ OpenCode automatically formats files after they're written or edited using langu
 
 You can customize agent prompts by creating markdown files in `~/.config/opencode/oh-my-opencode-slim/`:
 
+- With no preset, prompt files are loaded directly from this directory.
+- With `preset` set (for example `test`), the plugin first checks `~/.config/opencode/oh-my-opencode-slim/{preset}/`, then falls back to the root prompt directory.
+
 | File | Purpose |
 |------|---------|
 | `{agent}.md` | Replaces the default prompt entirely |
@@ -428,6 +431,9 @@ You can customize agent prompts by creating markdown files in `~/.config/opencod
 
 ```
 ~/.config/opencode/oh-my-opencode-slim/
+  ├── test/
+  │   ├── orchestrator.md      # Preset-specific override (preferred)
+  │   └── explorer_append.md
   ├── orchestrator.md          # Custom orchestrator prompt
   ├── orchestrator_append.md   # Append to default orchestrator prompt
   ├── explorer.md
@@ -440,6 +446,7 @@ You can customize agent prompts by creating markdown files in `~/.config/opencod
 - Create `{agent}.md` to completely replace an agent's default prompt
 - Create `{agent}_append.md` to add custom instructions to the default prompt
 - Both files can exist simultaneously - the replacement takes precedence
+- When `preset` is set, `{preset}/{agent}.md` and `{preset}/{agent}_append.md` are checked first
 - If neither file exists, the default prompt is used
 
 This allows you to fine-tune agent behavior without modifying the source code.

+ 3 - 4
src/agents/index.ts

@@ -119,8 +119,7 @@ export function createAgents(config?: PluginConfig): AgentDefinition[] {
       let librarianModel: string | undefined;
       if (Array.isArray(librarianOverride)) {
         const first = librarianOverride[0];
-        librarianModel =
-          typeof first === 'string' ? first : first?.id;
+        librarianModel = typeof first === 'string' ? first : first?.id;
       } else {
         librarianModel = librarianOverride;
       }
@@ -134,7 +133,7 @@ export function createAgents(config?: PluginConfig): AgentDefinition[] {
   const protoSubAgents = (
     Object.entries(SUBAGENT_FACTORIES) as [SubagentName, AgentFactory][]
   ).map(([name, factory]) => {
-    const customPrompts = loadAgentPrompt(name);
+    const customPrompts = loadAgentPrompt(name, config?.preset);
     return factory(
       getModelForAgent(name),
       customPrompts.prompt,
@@ -158,7 +157,7 @@ export function createAgents(config?: PluginConfig): AgentDefinition[] {
   const orchestratorOverride = getAgentOverride(config, 'orchestrator');
   const orchestratorModel =
     orchestratorOverride?.model ?? DEFAULT_MODELS.orchestrator;
-  const orchestratorPrompts = loadAgentPrompt('orchestrator');
+  const orchestratorPrompts = loadAgentPrompt('orchestrator', config?.preset);
   const orchestrator = createOrchestratorAgent(
     orchestratorModel,
     orchestratorPrompts.prompt,

+ 15 - 8
src/config/codemap.md

@@ -115,13 +115,20 @@ loadPluginConfig(directory)
 ### Prompt Loading Flow
 
 ```
-loadAgentPrompt(agentName)
+loadAgentPrompt(agentName, preset?)
-├─→ Check ~/.config/opencode/oh-my-opencode-slim/{agentName}.md
-│   └─→ If exists → read as replacement prompt
+├─→ Build prompt search dirs
+│   ├─→ If preset is safe (`[a-zA-Z0-9_-]+`):
+│   │   1) ~/.config/opencode/oh-my-opencode-slim/{preset}
+│   │   2) ~/.config/opencode/oh-my-opencode-slim
+│   └─→ Otherwise:
+│       1) ~/.config/opencode/oh-my-opencode-slim
-└─→ Check ~/.config/opencode/oh-my-opencode-slim/{agentName}_append.md
-    └─→ If exists → read as append prompt
+├─→ Read first existing {agentName}.md from search dirs
+│   └─→ If found → replacement prompt
+│
+└─→ Read first existing {agentName}_append.md from search dirs
+    └─→ If found → append prompt
 ```
 
 ### MCP Resolution Flow
@@ -160,7 +167,7 @@ deepMerge(base, override)
 - `node:fs`, `node:os`, `node:path`: File system operations
 
 **Internal Dependencies**
-- None (this is a leaf module)
+- `src/cli/config-io.ts` - JSONC comment stripping utility
 
 ### Consumers
 
@@ -189,7 +196,7 @@ deepMerge(base, override)
 
 4. **Prompt Customization**
    ```typescript
-   const { prompt, appendPrompt } = loadAgentPrompt(agentName);
+   const { prompt, appendPrompt } = loadAgentPrompt(agentName, config?.preset);
    ```
 
 ### Constants Usage
@@ -269,4 +276,4 @@ src/config/
 **Adding New Configuration Options**
 1. Add to `PluginConfigSchema` in `schema.ts`
 2. Update deep merge logic in `loader.ts` if nested
-3. Document in user-facing config documentation
+3. Document in user-facing config documentation

+ 91 - 0
src/config/loader.test.ts

@@ -980,6 +980,97 @@ describe('loadAgentPrompt', () => {
     }
   });
 
+  test('prefers preset prompt files over root prompts', () => {
+    const promptsDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
+    const presetDir = path.join(promptsDir, 'test');
+    fs.mkdirSync(presetDir, { recursive: true });
+
+    fs.writeFileSync(path.join(promptsDir, 'oracle.md'), 'root replacement');
+    fs.writeFileSync(path.join(presetDir, 'oracle.md'), 'preset replacement');
+    fs.writeFileSync(
+      path.join(promptsDir, 'oracle_append.md'),
+      'root append prompt',
+    );
+    fs.writeFileSync(
+      path.join(presetDir, 'oracle_append.md'),
+      'preset append prompt',
+    );
+
+    const result = loadAgentPrompt('oracle', 'test');
+    expect(result.prompt).toBe('preset replacement');
+    expect(result.appendPrompt).toBe('preset append prompt');
+  });
+
+  test('falls back to root prompt files when preset files are missing', () => {
+    const promptsDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
+    const presetDir = path.join(promptsDir, 'test');
+    fs.mkdirSync(presetDir, { recursive: true });
+
+    fs.writeFileSync(path.join(promptsDir, 'oracle.md'), 'root replacement');
+    fs.writeFileSync(
+      path.join(promptsDir, 'oracle_append.md'),
+      'root append prompt',
+    );
+
+    const result = loadAgentPrompt('oracle', 'test');
+    expect(result.prompt).toBe('root replacement');
+    expect(result.appendPrompt).toBe('root append prompt');
+  });
+
+  test('falls back independently between preset and root files', () => {
+    const promptsDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
+    const presetDir = path.join(promptsDir, 'test');
+    fs.mkdirSync(presetDir, { recursive: true });
+
+    fs.writeFileSync(path.join(presetDir, 'oracle.md'), 'preset replacement');
+    fs.writeFileSync(
+      path.join(promptsDir, 'oracle_append.md'),
+      'root append prompt',
+    );
+
+    const result = loadAgentPrompt('oracle', 'test');
+    expect(result.prompt).toBe('preset replacement');
+    expect(result.appendPrompt).toBe('root append prompt');
+  });
+
+  test('ignores unsafe preset names for prompt lookup', () => {
+    const promptsDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
+    fs.mkdirSync(promptsDir, { recursive: true });
+    fs.writeFileSync(path.join(promptsDir, 'oracle.md'), 'root replacement');
+
+    const result = loadAgentPrompt('oracle', '../test');
+    expect(result.prompt).toBe('root replacement');
+    expect(result.appendPrompt).toBeUndefined();
+  });
+
+  test('falls back to root when preset prompt file read fails', () => {
+    const promptsDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
+    const presetDir = path.join(promptsDir, 'test');
+    fs.mkdirSync(presetDir, { recursive: true });
+    const presetPromptPath = path.join(presetDir, 'oracle.md');
+    fs.writeFileSync(presetPromptPath, 'preset replacement');
+    fs.writeFileSync(path.join(promptsDir, 'oracle.md'), 'root replacement');
+
+    const consoleWarnSpy = spyOn(console, 'warn');
+    const originalReadFileSync = fs.readFileSync;
+    const readSpy = spyOn(fs, 'readFileSync').mockImplementation(
+      (p: any, o: any) => {
+        if (typeof p === 'string' && p === presetPromptPath) {
+          throw new Error('Preset read error');
+        }
+        return originalReadFileSync(p, o);
+      },
+    );
+
+    try {
+      const result = loadAgentPrompt('oracle', 'test');
+      expect(result.prompt).toBe('root replacement');
+      expect(consoleWarnSpy).toHaveBeenCalled();
+    } finally {
+      readSpy.mockRestore();
+    }
+  });
+
   test('works with XDG_CONFIG_HOME environment variable', () => {
     const customConfigHome = path.join(tempDir, 'custom-xdg');
     process.env.XDG_CONFIG_HOME = customConfigHome;

+ 43 - 23
src/config/loader.ts

@@ -194,46 +194,66 @@ export function loadPluginConfig(directory: string): PluginConfig {
 /**
  * Load custom prompt for an agent from the prompts directory.
  * Checks for {agent}.md (replaces default) and {agent}_append.md (appends to default).
+ * If preset is provided and safe for paths, it first checks {preset}/ subdirectory,
+ * then falls back to the root prompts directory.
  *
  * @param agentName - Name of the agent (e.g., "orchestrator", "explorer")
+ * @param preset - Optional preset name for preset-scoped prompt lookup
  * @returns Object with prompt and/or appendPrompt if files exist
  */
-export function loadAgentPrompt(agentName: string): {
+export function loadAgentPrompt(
+  agentName: string,
+  preset?: string,
+): {
   prompt?: string;
   appendPrompt?: string;
 } {
+  const presetDirName =
+    preset && /^[a-zA-Z0-9_-]+$/.test(preset) ? preset : undefined;
   const promptsDir = path.join(
     getUserConfigDir(),
     'opencode',
     PROMPTS_DIR_NAME,
   );
+  const promptSearchDirs = presetDirName
+    ? [path.join(promptsDir, presetDirName), promptsDir]
+    : [promptsDir];
   const result: { prompt?: string; appendPrompt?: string } = {};
 
-  // Check for replacement prompt
-  const promptPath = path.join(promptsDir, `${agentName}.md`);
-  if (fs.existsSync(promptPath)) {
-    try {
-      result.prompt = fs.readFileSync(promptPath, 'utf-8');
-    } catch (error) {
-      console.warn(
-        `[oh-my-opencode-slim] Error reading prompt file ${promptPath}:`,
-        error instanceof Error ? error.message : String(error),
-      );
+  const readFirstPrompt = (
+    fileName: string,
+    errorPrefix: string,
+  ): string | undefined => {
+    for (const dir of promptSearchDirs) {
+      const promptPath = path.join(dir, fileName);
+      if (!fs.existsSync(promptPath)) {
+        continue;
+      }
+
+      try {
+        return fs.readFileSync(promptPath, 'utf-8');
+      } catch (error) {
+        console.warn(
+          `[oh-my-opencode-slim] ${errorPrefix} ${promptPath}:`,
+          error instanceof Error ? error.message : String(error),
+        );
+      }
     }
-  }
+
+    return undefined;
+  };
+
+  // Check for replacement prompt
+  result.prompt = readFirstPrompt(
+    `${agentName}.md`,
+    'Error reading prompt file',
+  );
 
   // Check for append prompt
-  const appendPromptPath = path.join(promptsDir, `${agentName}_append.md`);
-  if (fs.existsSync(appendPromptPath)) {
-    try {
-      result.appendPrompt = fs.readFileSync(appendPromptPath, 'utf-8');
-    } catch (error) {
-      console.warn(
-        `[oh-my-opencode-slim] Error reading append prompt file ${appendPromptPath}:`,
-        error instanceof Error ? error.message : String(error),
-      );
-    }
-  }
+  result.appendPrompt = readFirstPrompt(
+    `${agentName}_append.md`,
+    'Error reading append prompt file',
+  );
 
   return result;
 }