← octto
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import * as v from "valibot";
import { AGENTS, isAgentName } from "@/agents";
type FragmentsRecord = Record<string, string[]> | undefined;
const VALID_AGENT_NAMES = Object.values(AGENTS);
const MAX_SUGGESTION_DISTANCE = 3;
const ProjectFragmentsSchema = v.record(v.string(), v.array(v.string()));
export interface FragmentInjectorContext {
projectDir: 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 merged: Record<string, string[]> = {};
for (const [agent, frags] of Object.entries(global ?? {})) {
merged[agent] = [...frags];
}
for (const [agent, frags] of Object.entries(project ?? {})) {
merged[agent] = [...(merged[agent] ?? []), ...frags];
}
return merged;
}
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: unknown = JSON.parse(content);
const fragmentsValidation = v.safeParse(ProjectFragmentsSchema, parsed);
if (!fragmentsValidation.success) {
console.warn(`[octto] Invalid fragments.json schema in ${fragmentsPath}`);
return undefined;
}
return fragmentsValidation.output;
} catch (_error: unknown) {
/* missing fragments.json is expected for projects without custom fragments */
return undefined;
}
}
/* Used for suggesting similar agent names on typos */
/* eslint-disable max-depth, sonarjs/cognitive-complexity */
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, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1);
}
}
}
return matrix[b.length][a.length];
}
/* eslint-enable max-depth, sonarjs/cognitive-complexity */
export function warnUnknownAgents(fragments: Record<string, string[]> | undefined): void {
if (!fragments) return;
for (const agentName of Object.keys(fragments)) {
if (isAgentName(agentName)) {
continue;
}
const closest = findClosestAgent(agentName);
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 async function createFragmentInjector(
ctx: FragmentInjectorContext,
globalFragments: FragmentsRecord,
): Promise<Record<string, string[]>> {
const projectFragments = await loadProjectFragments(ctx.projectDir);
const merged = mergeFragments(globalFragments, projectFragments);
warnUnknownAgents(globalFragments);
warnUnknownAgents(projectFragments);
return merged;
}
export function getAgentSystemPromptPrefix(fragments: Record<string, string[]>, agentName: string): string {
return formatFragmentsBlock(fragments[agentName]);
}
function findClosestAgent(name: string): string | undefined {
let closest: string | undefined;
let minDistance = Infinity;
for (const validName of VALID_AGENT_NAMES) {
const distance = levenshteinDistance(name, validName);
if (distance < minDistance && distance <= MAX_SUGGESTION_DISTANCE) {
minDistance = distance;
closest = validName;
}
}
return closest;
}