Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<user-instructions>` block prepended to the agent's system prompt.

### Environment Variables

Expand Down
4 changes: 2 additions & 2 deletions src/config/index.ts
Original file line number Diff line number Diff line change
@@ -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";
6 changes: 4 additions & 2 deletions src/config/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -111,6 +111,7 @@ async function load(configDir?: string): Promise<OcttoConfig | null> {
export interface CustomConfig {
agents: Record<AGENTS, AgentConfig>;
port: number;
fragments: Fragments;
}

/**
Expand All @@ -130,5 +131,6 @@ export async function loadCustomConfig(agents: Record<AGENTS, AgentConfig>, conf
return {
agents: mergedAgents,
port: resolvePort(config?.port),
fragments: config?.fragments,
};
}
4 changes: 4 additions & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof AgentOverrideSchema>;
export type Fragments = v.InferOutput<typeof FragmentsSchema>;
export type OcttoConfig = v.InferOutput<typeof OcttoConfigSchema>;
171 changes: 171 additions & 0 deletions src/hooks/fragment-injector.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]> | 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 `<user-instructions>\n${bulletPoints}\n</user-instructions>\n\n`;
}

/**
* Merge global and project fragments.
* Global fragments come first, project fragments append.
*/
export function mergeFragments(global: FragmentsRecord, project: FragmentsRecord): Record<string, string[]> {
const result: Record<string, string[]> = {};

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<Record<string, string[]> | 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<string, string[]> | 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<Record<string, string[]>> {
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<string, string[]>, agentName: string): string {
return formatFragmentsBlock(fragments[agentName]);
}
11 changes: 11 additions & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// src/hooks/index.ts
export {
createFragmentInjector,
type FragmentInjectorContext,
formatFragmentsBlock,
getAgentSystemPromptPrefix,
levenshteinDistance,
loadProjectFragments,
mergeFragments,
warnUnknownAgents,
} from "./fragment-injector";
20 changes: 18 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Set<string>>();
const tools = createOcttoTools(sessions, client);
Expand All @@ -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 };
},

Expand Down
Loading