diff --git a/README.md b/README.md index d6c9829..672f90f 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,34 @@ Optional `~/.config/opencode/octto.json`: |--------|------|---------|-------------| | `port` | number | `0` (random) | Fixed port for the browser UI server | | `agents` | object | - | Override agent models/settings | +| `fragments` | object | - | Custom instructions injected into agent prompts | + +### Fragments + +Inject custom instructions into agent prompts. Useful for customizing agent behavior per-project or globally. + +**Global config** (`~/.config/opencode/octto.json`): + +```json +{ + "fragments": { + "octto": ["Always suggest 3 implementation approaches"], + "probe": ["Include emoji in every question"], + "bootstrapper": ["Focus on technical feasibility"] + } +} +``` + +**Project config** (`.octto/fragments.json` in your project root): + +```json +{ + "octto": ["This project uses React - focus on component patterns"], + "probe": ["Ask about testing strategy for each feature"] +} +``` + +Fragments are merged: global fragments load first, project fragments append. Each fragment becomes a bullet point in a `` block prepended to the agent's system prompt. ### Environment Variables diff --git a/src/config/index.ts b/src/config/index.ts index cfe72a9..59ac9ff 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,3 +1,3 @@ -export type { AgentOverride, CustomConfig, OcttoConfig } from "./loader"; +export type { AgentOverride, CustomConfig, Fragments, OcttoConfig } from "./loader"; export { loadCustomConfig, resolvePort } from "./loader"; -export { AgentOverrideSchema, OcttoConfigSchema, PortSchema } from "./schema"; +export { AgentOverrideSchema, FragmentsSchema, OcttoConfigSchema, PortSchema } from "./schema"; diff --git a/src/config/loader.ts b/src/config/loader.ts index a54a535..00f3f6b 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -7,9 +7,9 @@ import * as v from "valibot"; import { AGENTS } from "@/agents"; -import { AgentOverrideSchema, type OcttoConfig, OcttoConfigSchema } from "./schema"; +import { AgentOverrideSchema, type Fragments, type OcttoConfig, OcttoConfigSchema } from "./schema"; -export type { AgentOverride, OcttoConfig } from "./schema"; +export type { AgentOverride, Fragments, OcttoConfig } from "./schema"; const OCTTO_PORT_ENV = "OCTTO_PORT"; const DEFAULT_PORT = 0; @@ -111,6 +111,7 @@ async function load(configDir?: string): Promise { export interface CustomConfig { agents: Record; port: number; + fragments: Fragments; } /** @@ -130,5 +131,6 @@ export async function loadCustomConfig(agents: Record, conf return { agents: mergedAgents, port: resolvePort(config?.port), + fragments: config?.fragments, }; } diff --git a/src/config/schema.ts b/src/config/schema.ts index 50fa503..c5094f2 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -12,10 +12,14 @@ export const AgentOverrideSchema = v.partial( export const PortSchema = v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(65535)); +export const FragmentsSchema = v.optional(v.record(v.enum(AGENTS), v.array(v.string()))); + export const OcttoConfigSchema = v.object({ agents: v.optional(v.record(v.enum(AGENTS), AgentOverrideSchema)), port: v.optional(PortSchema), + fragments: FragmentsSchema, }); export type AgentOverride = v.InferOutput; +export type Fragments = v.InferOutput; export type OcttoConfig = v.InferOutput; diff --git a/src/hooks/fragment-injector.ts b/src/hooks/fragment-injector.ts new file mode 100644 index 0000000..80932d0 --- /dev/null +++ b/src/hooks/fragment-injector.ts @@ -0,0 +1,171 @@ +// src/hooks/fragment-injector.ts +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import * as v from "valibot"; + +import { AGENTS } from "@/agents"; + +type FragmentsRecord = Record | undefined; + +const VALID_AGENT_NAMES = Object.values(AGENTS); + +const ProjectFragmentsSchema = v.record(v.string(), v.array(v.string())); + +/** + * Format fragments array as an XML block to prepend to agent prompts. + */ +export function formatFragmentsBlock(fragments: string[] | undefined): string { + if (!fragments || fragments.length === 0) { + return ""; + } + + const bulletPoints = fragments.map((f) => `- ${f}`).join("\n"); + return `\n${bulletPoints}\n\n\n`; +} + +/** + * Merge global and project fragments. + * Global fragments come first, project fragments append. + */ +export function mergeFragments(global: FragmentsRecord, project: FragmentsRecord): Record { + const result: Record = {}; + + if (global) { + for (const [agent, frags] of Object.entries(global)) { + result[agent] = [...frags]; + } + } + + if (project) { + for (const [agent, frags] of Object.entries(project)) { + if (result[agent]) { + result[agent].push(...frags); + } else { + result[agent] = [...frags]; + } + } + } + + return result; +} + +/** + * Load project-level fragments from .octto/fragments.json + */ +export async function loadProjectFragments(projectDir: string): Promise | undefined> { + const fragmentsPath = join(projectDir, ".octto", "fragments.json"); + + try { + const content = await readFile(fragmentsPath, "utf-8"); + const parsed = JSON.parse(content); + + const result = v.safeParse(ProjectFragmentsSchema, parsed); + if (!result.success) { + console.warn(`[octto] Invalid fragments.json schema in ${fragmentsPath}`); + return undefined; + } + + return result.output; + } catch { + return undefined; + } +} + +/** + * Calculate Levenshtein distance between two strings. + * Used for suggesting similar agent names for typos. + */ +export function levenshteinDistance(a: string, b: string): number { + if (a.length === 0) return b.length; + if (b.length === 0) return a.length; + + const matrix: number[][] = []; + + for (let i = 0; i <= b.length; i++) { + matrix[i] = [i]; + } + + for (let j = 0; j <= a.length; j++) { + matrix[0][j] = j; + } + + for (let i = 1; i <= b.length; i++) { + for (let j = 1; j <= a.length; j++) { + if (b.charAt(i - 1) === a.charAt(j - 1)) { + matrix[i][j] = matrix[i - 1][j - 1]; + } else { + matrix[i][j] = Math.min( + matrix[i - 1][j - 1] + 1, // substitution + matrix[i][j - 1] + 1, // insertion + matrix[i - 1][j] + 1, // deletion + ); + } + } + } + + return matrix[b.length][a.length]; +} + +/** + * Warn about unknown agent names in fragments config. + * Suggests similar valid agent names for likely typos. + */ +export function warnUnknownAgents(fragments: Record | undefined): void { + if (!fragments) return; + + for (const agentName of Object.keys(fragments)) { + if (VALID_AGENT_NAMES.includes(agentName as AGENTS)) { + continue; + } + + // Find closest valid agent name + let closest: string | undefined; + let minDistance = Infinity; + + for (const validName of VALID_AGENT_NAMES) { + const distance = levenshteinDistance(agentName, validName); + if (distance < minDistance && distance <= 3) { + minDistance = distance; + closest = validName; + } + } + + let message = `[octto] Unknown agent "${agentName}" in fragments config.`; + if (closest) { + message += ` Did you mean "${closest}"?`; + } + message += ` Valid agents: ${VALID_AGENT_NAMES.join(", ")}`; + + console.warn(message); + } +} + +export interface FragmentInjectorContext { + projectDir: string; +} + +/** + * Create a fragment injector that can modify agent system prompts. + * Returns merged fragments from global config and project config. + */ +export async function createFragmentInjector( + ctx: FragmentInjectorContext, + globalFragments: FragmentsRecord, +): Promise> { + const projectFragments = await loadProjectFragments(ctx.projectDir); + const merged = mergeFragments(globalFragments, projectFragments); + + // Warn about unknown agents in both global and project fragments + warnUnknownAgents(globalFragments); + warnUnknownAgents(projectFragments); + + return merged; +} + +/** + * Get the system prompt prefix for a specific agent. + */ +export function getAgentSystemPromptPrefix(fragments: Record, agentName: string): string { + return formatFragmentsBlock(fragments[agentName]); +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..73fbd71 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,11 @@ +// src/hooks/index.ts +export { + createFragmentInjector, + type FragmentInjectorContext, + formatFragmentsBlock, + getAgentSystemPromptPrefix, + levenshteinDistance, + loadProjectFragments, + mergeFragments, + warnUnknownAgents, +} from "./fragment-injector"; diff --git a/src/index.ts b/src/index.ts index 6f50334..71c9e39 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,13 +2,28 @@ import type { Plugin } from "@opencode-ai/plugin"; -import { agents } from "@/agents"; +import { AGENTS, agents } from "@/agents"; import { loadCustomConfig } from "@/config"; +import { createFragmentInjector, getAgentSystemPromptPrefix, warnUnknownAgents } from "@/hooks"; import { createSessionStore } from "@/session"; import { createOcttoTools } from "@/tools"; -const Octto: Plugin = async ({ client }) => { +const Octto: Plugin = async ({ client, directory }) => { const customConfig = await loadCustomConfig(agents); + + // Load and merge fragments from global config and project config + const fragments = await createFragmentInjector({ projectDir: directory }, customConfig.fragments); + + // Inject fragments into agent prompts at the source + for (const agentName of Object.values(AGENTS)) { + const prefix = getAgentSystemPromptPrefix(fragments, agentName); + if (prefix && customConfig.agents[agentName]?.prompt) { + customConfig.agents[agentName].prompt = prefix + customConfig.agents[agentName].prompt; + } + } + + // Warn about unknown agent names in global config at startup + warnUnknownAgents(customConfig.fragments); const sessions = createSessionStore({ port: customConfig.port }); const tracked = new Map>(); const tools = createOcttoTools(sessions, client); @@ -32,6 +47,7 @@ const Octto: Plugin = async ({ client }) => { tool: tools, config: async (config) => { + // Apply agent overrides from custom config (fragments already injected at plugin load) config.agent = { ...config.agent, ...customConfig.agents }; }, diff --git a/tests/config/schema.test.ts b/tests/config/schema.test.ts index 31928ea..3991fe5 100644 --- a/tests/config/schema.test.ts +++ b/tests/config/schema.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "bun:test"; import * as v from "valibot"; -import { OcttoConfigSchema } from "../../src/config/schema"; +import { FragmentsSchema, OcttoConfigSchema } from "../../src/config/schema"; describe("OcttoConfigSchema", () => { describe("port field", () => { @@ -54,4 +54,86 @@ describe("OcttoConfigSchema", () => { } }); }); + + describe("fragments field", () => { + it("should accept valid fragments for known agents", () => { + const result = v.safeParse(OcttoConfigSchema, { + fragments: { + octto: ["instruction 1", "instruction 2"], + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output.fragments?.octto).toEqual(["instruction 1", "instruction 2"]); + } + }); + + it("should accept fragments for multiple agents", () => { + const result = v.safeParse(OcttoConfigSchema, { + fragments: { + octto: ["octto instruction"], + bootstrapper: ["bootstrapper instruction"], + probe: ["probe instruction"], + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output.fragments?.octto).toEqual(["octto instruction"]); + expect(result.output.fragments?.bootstrapper).toEqual(["bootstrapper instruction"]); + expect(result.output.fragments?.probe).toEqual(["probe instruction"]); + } + }); + + it("should accept empty fragments array", () => { + const result = v.safeParse(OcttoConfigSchema, { + fragments: { + octto: [], + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output.fragments?.octto).toEqual([]); + } + }); + + it("should allow config without fragments (optional)", () => { + const result = v.safeParse(OcttoConfigSchema, {}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.output.fragments).toBeUndefined(); + } + }); + + it("should reject unknown agent names in fragments", () => { + const result = v.safeParse(OcttoConfigSchema, { + fragments: { + unknown_agent: ["instruction"], + }, + }); + expect(result.success).toBe(false); + }); + + it("should reject non-string values in fragments array", () => { + const result = v.safeParse(OcttoConfigSchema, { + fragments: { + octto: [123, "valid"], + }, + }); + expect(result.success).toBe(false); + }); + }); +}); + +describe("FragmentsSchema", () => { + it("should be optional", () => { + const result = v.safeParse(FragmentsSchema, undefined); + expect(result.success).toBe(true); + }); + + it("should accept valid fragment record", () => { + const result = v.safeParse(FragmentsSchema, { + octto: ["instruction"], + }); + expect(result.success).toBe(true); + }); }); diff --git a/tests/hooks/fragment-injector.test.ts b/tests/hooks/fragment-injector.test.ts new file mode 100644 index 0000000..34ff5b0 --- /dev/null +++ b/tests/hooks/fragment-injector.test.ts @@ -0,0 +1,208 @@ +// tests/hooks/fragment-injector.test.ts +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { AGENTS } from "../../src/agents"; +import { + formatFragmentsBlock, + levenshteinDistance, + loadProjectFragments, + mergeFragments, + warnUnknownAgents, +} from "../../src/hooks/fragment-injector"; + +describe("formatFragmentsBlock", () => { + it("should return empty string for undefined fragments", () => { + expect(formatFragmentsBlock(undefined)).toBe(""); + }); + + it("should return empty string for empty array", () => { + expect(formatFragmentsBlock([])).toBe(""); + }); + + it("should format single fragment as XML block", () => { + const result = formatFragmentsBlock(["Custom instruction"]); + expect(result).toBe("\n- Custom instruction\n\n\n"); + }); + + it("should format multiple fragments as XML block with bullet points", () => { + const result = formatFragmentsBlock(["Instruction 1", "Instruction 2", "Instruction 3"]); + expect(result).toBe( + "\n- Instruction 1\n- Instruction 2\n- Instruction 3\n\n\n", + ); + }); +}); + +describe("mergeFragments", () => { + it("should return empty object when both are undefined", () => { + expect(mergeFragments(undefined, undefined)).toEqual({}); + }); + + it("should return global fragments when project is undefined", () => { + const global = { octto: ["global instruction"] }; + expect(mergeFragments(global, undefined)).toEqual({ octto: ["global instruction"] }); + }); + + it("should return project fragments when global is undefined", () => { + const project = { octto: ["project instruction"] }; + expect(mergeFragments(undefined, project)).toEqual({ octto: ["project instruction"] }); + }); + + it("should merge fragments for same agent (global first, project appended)", () => { + const global = { octto: ["global instruction"] }; + const project = { octto: ["project instruction"] }; + expect(mergeFragments(global, project)).toEqual({ + octto: ["global instruction", "project instruction"], + }); + }); + + it("should keep fragments for different agents separate", () => { + const global = { octto: ["octto instruction"] }; + const project = { bootstrapper: ["bootstrapper instruction"] }; + expect(mergeFragments(global, project)).toEqual({ + octto: ["octto instruction"], + bootstrapper: ["bootstrapper instruction"], + }); + }); + + it("should merge complex multi-agent fragments", () => { + const global = { + octto: ["octto global 1", "octto global 2"], + probe: ["probe global"], + }; + const project = { + octto: ["octto project"], + bootstrapper: ["bootstrapper project"], + }; + expect(mergeFragments(global, project)).toEqual({ + octto: ["octto global 1", "octto global 2", "octto project"], + probe: ["probe global"], + bootstrapper: ["bootstrapper project"], + }); + }); +}); + +describe("loadProjectFragments", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "octto-test-")); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true }); + }); + + it("should return undefined when .octto directory does not exist", async () => { + const result = await loadProjectFragments(tempDir); + expect(result).toBeUndefined(); + }); + + it("should return undefined when fragments.json does not exist", async () => { + await mkdir(join(tempDir, ".octto")); + const result = await loadProjectFragments(tempDir); + expect(result).toBeUndefined(); + }); + + it("should load valid fragments.json", async () => { + await mkdir(join(tempDir, ".octto")); + await writeFile(join(tempDir, ".octto", "fragments.json"), JSON.stringify({ octto: ["project instruction"] })); + + const result = await loadProjectFragments(tempDir); + expect(result).toEqual({ octto: ["project instruction"] }); + }); + + it("should return undefined for invalid JSON", async () => { + await mkdir(join(tempDir, ".octto")); + await writeFile(join(tempDir, ".octto", "fragments.json"), "not valid json"); + + const result = await loadProjectFragments(tempDir); + expect(result).toBeUndefined(); + }); + + it("should return undefined for invalid schema (wrong type)", async () => { + await mkdir(join(tempDir, ".octto")); + await writeFile(join(tempDir, ".octto", "fragments.json"), JSON.stringify({ octto: "not an array" })); + + const result = await loadProjectFragments(tempDir); + expect(result).toBeUndefined(); + }); +}); + +describe("levenshteinDistance", () => { + it("should return 0 for identical strings", () => { + expect(levenshteinDistance("octto", "octto")).toBe(0); + }); + + it("should return length of b for empty a", () => { + expect(levenshteinDistance("", "octto")).toBe(5); + }); + + it("should return length of a for empty b", () => { + expect(levenshteinDistance("octto", "")).toBe(5); + }); + + it("should calculate single character substitution", () => { + expect(levenshteinDistance("octto", "octta")).toBe(1); + }); + + it("should calculate single character insertion", () => { + expect(levenshteinDistance("octto", "octtoo")).toBe(1); + }); + + it("should calculate single character deletion", () => { + expect(levenshteinDistance("octto", "octt")).toBe(1); + }); + + it("should handle typical typos", () => { + expect(levenshteinDistance("octto", "octo")).toBe(1); // missing t + expect(levenshteinDistance("bootstrapper", "bootsrapper")).toBe(1); // typo + }); +}); + +describe("warnUnknownAgents", () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + consoleSpy = spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it("should not warn for valid agent names", () => { + warnUnknownAgents({ octto: ["instruction"], bootstrapper: ["instruction"] }); + expect(consoleSpy).not.toHaveBeenCalled(); + }); + + it("should not warn for undefined fragments", () => { + warnUnknownAgents(undefined); + expect(consoleSpy).not.toHaveBeenCalled(); + }); + + it("should not warn for empty fragments", () => { + warnUnknownAgents({}); + expect(consoleSpy).not.toHaveBeenCalled(); + }); + + it("should warn for unknown agent name", () => { + warnUnknownAgents({ unknown_agent: ["instruction"] } as any); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('[octto] Unknown agent "unknown_agent" in fragments'), + ); + }); + + it("should suggest similar agent name for typos", () => { + warnUnknownAgents({ octo: ["instruction"] } as any); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Did you mean "octto"?')); + }); + + it("should suggest multiple similar names when applicable", () => { + // "prob" is close to "probe" + warnUnknownAgents({ prob: ["instruction"] } as any); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Did you mean "probe"?')); + }); +});