mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-15 11:43:10 +03:00
* feat(skills): add Ponytail minimalism skill as external catalog entry
- Add 'external' SkillCategory + SkillArea
- Register ponytail (MIT, DietrichGebert/ponytail) in CURATED_SKILLS
- Generator: external skills carry content in custom block, no api/cli body
- Generate skills/ponytail/SKILL.md with original content preserved
- Update catalog test counts 45 -> 46
* fix(skills+memory): builtin handler fallback in executor, skip vector upsert for deleted memories
- skills: Next.js compiles SkillExecutor into multiple chunks (own singleton
each); route chunk lacked builtin handlers registered at startup via
instrumentation. execute() now falls back to builtinSkills registry, so
POST /api/skills/executions works for file_read/web_fetch/etc.
- memory: scheduleVectorUpsert is fire-and-forget and embeddings are slow;
health-check verify (create->delete test memory) left queued upserts
failing with 'memory not found' every 30s. Check existence before embedding
and skip quietly.
* fix(skills): encode tool names with @ and . for providers rejecting them
Skill tools were advertised as 'name@version' (e.g. test-fr2@1.0.0), but
DeepSeek/Groq/OpenAI reject function names not matching ^[a-zA-Z0-9_-]+$.
Names already valid are left untouched; invalid ones are reversibly encoded
as omr_skill_<base64url> and decoded in interception before registry lookup.
* fix(combos): include DB id column in combo records for dashboard links
getCombos() selected only data/sort_order/context_cache_protection, so
combos whose JSON blob lacked an id field returned id: undefined. The
dashboard then linked to /dashboard/combos/undefined and Combo Control
Center failed with 'Combo not found'. Merge the id column into parsed
rows (authoritative, only when the blob has no id).
* fix(skills): normalize flat skill schemas to object schema for Gemini/Claude
Stored skill schemas are flat property maps ({ text: { type: string } }),
which OpenAI-compatible providers tolerate but Gemini
(function_declarations[].parameters) rejects with 'Unknown name ... Cannot
find field'. Wrap bare maps into { type: 'object', properties: {...} } for
all three tool formats.
* fix(skills): warm registry cache before skill injection in chat path
injectSkills() lists the in-memory skillRegistry, which is empty after a
cold start until something calls loadFromDatabase(). The interception path
already warms the cache (#2815); the injection path did not, so skills
were silently skipped (no_enabled_skills) for the first requests after
restart. Warm the cache for the chat owner before injection.
---------
Co-authored-by: Egor <egorich-print@users.noreply.github.com>
352 lines
9.8 KiB
TypeScript
352 lines
9.8 KiB
TypeScript
import { skillRegistry } from "./registry";
|
|
import { Skill } from "./types";
|
|
import { logger } from "../../../open-sse/utils/logger.ts";
|
|
|
|
const log = logger("SKILLS_INJECTION");
|
|
|
|
interface OpenAITool {
|
|
type: string;
|
|
function: {
|
|
name: string;
|
|
description: string;
|
|
parameters: Record<string, unknown>;
|
|
};
|
|
}
|
|
|
|
interface ClaudeTool {
|
|
name: string;
|
|
description: string;
|
|
input_schema: Record<string, unknown>;
|
|
}
|
|
|
|
interface GeminiTool {
|
|
name: string;
|
|
description: string;
|
|
parameters: Record<string, unknown>;
|
|
}
|
|
|
|
// Provider tool/function names must match ^[a-zA-Z0-9_-]+$ (OpenAI, DeepSeek,
|
|
// Groq, etc.). Skill identifiers are name@version (and names may contain any
|
|
// characters), so encode identifiers that would violate the pattern into a
|
|
// reversible base64url form. decodeSkillToolName() must be applied on the way
|
|
// back in interception before resolving against the registry.
|
|
const SKILL_TOOL_NAME_PREFIX = "omr_skill_";
|
|
|
|
export function encodeSkillToolName(name: string, version: string): string {
|
|
const identifier = `${name}@${version}`;
|
|
if (/^[a-zA-Z0-9_-]+$/.test(identifier)) {
|
|
return identifier;
|
|
}
|
|
return `${SKILL_TOOL_NAME_PREFIX}${Buffer.from(identifier, "utf8").toString("base64url")}`;
|
|
}
|
|
|
|
export function decodeSkillToolName(toolName: string): string {
|
|
if (!toolName.startsWith(SKILL_TOOL_NAME_PREFIX)) {
|
|
return toolName;
|
|
}
|
|
try {
|
|
return Buffer.from(toolName.slice(SKILL_TOOL_NAME_PREFIX.length), "base64url").toString("utf8");
|
|
} catch {
|
|
return toolName;
|
|
}
|
|
}
|
|
|
|
// Skills store a flat JSON Schema record ({ "text": { "type": "string" } }),
|
|
// but Gemini (function_declarations[].parameters) and Anthropic
|
|
// (input_schema) require a full object schema with a properties wrapper.
|
|
// Normalize to { "type": "object", "properties": {...} } when the stored
|
|
// schema is a bare property map.
|
|
function normalizeInputSchema(input: Record<string, unknown>): Record<string, unknown> {
|
|
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
return input ?? {};
|
|
}
|
|
if (typeof input.type === "string") {
|
|
return input;
|
|
}
|
|
return {
|
|
type: "object",
|
|
properties: input,
|
|
};
|
|
}
|
|
|
|
function skillToOpenAI(skill: Skill): OpenAITool {
|
|
return {
|
|
type: "function",
|
|
function: {
|
|
name: encodeSkillToolName(skill.name, skill.version),
|
|
description: skill.description,
|
|
parameters: normalizeInputSchema(skill.schema.input),
|
|
},
|
|
};
|
|
}
|
|
|
|
function skillToClaude(skill: Skill): ClaudeTool {
|
|
return {
|
|
name: encodeSkillToolName(skill.name, skill.version),
|
|
description: skill.description,
|
|
input_schema: normalizeInputSchema(skill.schema.input),
|
|
};
|
|
}
|
|
|
|
function skillToGemini(skill: Skill): GeminiTool {
|
|
return {
|
|
name: encodeSkillToolName(skill.name, skill.version),
|
|
description: skill.description,
|
|
parameters: normalizeInputSchema(skill.schema.input),
|
|
};
|
|
}
|
|
|
|
export interface InjectionOptions {
|
|
provider: "openai" | "anthropic" | "google" | "other";
|
|
existingTools?: unknown[];
|
|
apiKeyId: string;
|
|
model?: string;
|
|
sourceFormat?: string;
|
|
targetFormat?: string;
|
|
backgroundReason?: string | null;
|
|
messages?: unknown[];
|
|
}
|
|
|
|
const AUTO_MIN_SCORE = 3;
|
|
const AUTO_MAX_SKILLS = 5;
|
|
const TOKEN_MIN_LEN = 3;
|
|
|
|
function toLowerText(value: unknown): string {
|
|
if (typeof value === "string") return value.toLowerCase();
|
|
return "";
|
|
}
|
|
|
|
function extractTokens(value: string): Set<string> {
|
|
const matches: string[] = value.toLowerCase().match(/[a-z0-9]+/g) ?? [];
|
|
return new Set(matches.filter((t) => t.length >= TOKEN_MIN_LEN));
|
|
}
|
|
|
|
function splitNameTokens(name: string): Set<string> {
|
|
const expandedCamel = name
|
|
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
.replace(/[._@\-/]+/g, " ")
|
|
.toLowerCase();
|
|
return extractTokens(expandedCamel);
|
|
}
|
|
|
|
function extractMessageText(messages: unknown[]): string {
|
|
const chunks: string[] = [];
|
|
for (const message of messages) {
|
|
if (!message || typeof message !== "object") continue;
|
|
const record = message as Record<string, unknown>;
|
|
const content = record.content;
|
|
|
|
if (typeof content === "string") {
|
|
chunks.push(content);
|
|
continue;
|
|
}
|
|
|
|
if (Array.isArray(content)) {
|
|
for (const item of content) {
|
|
if (typeof item === "string") {
|
|
chunks.push(item);
|
|
continue;
|
|
}
|
|
if (item && typeof item === "object") {
|
|
const itemRecord = item as Record<string, unknown>;
|
|
if (typeof itemRecord.text === "string") {
|
|
chunks.push(itemRecord.text);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return chunks.join(" ").toLowerCase();
|
|
}
|
|
|
|
function buildContextText(options: InjectionOptions): string {
|
|
const parts = [
|
|
JSON.stringify(options.existingTools || []).toLowerCase(),
|
|
toLowerText(options.model),
|
|
toLowerText(options.sourceFormat),
|
|
toLowerText(options.targetFormat),
|
|
toLowerText(options.backgroundReason),
|
|
];
|
|
|
|
if (Array.isArray(options.messages) && options.messages.length > 0) {
|
|
parts.push(extractMessageText(options.messages));
|
|
}
|
|
|
|
return parts.filter(Boolean).join(" ");
|
|
}
|
|
|
|
function scoreAutoSkill(
|
|
skill: Skill,
|
|
options: InjectionOptions,
|
|
contextText: string,
|
|
contextTokens: Set<string>,
|
|
backgroundTokens: Set<string>
|
|
): number {
|
|
const name = skill.name.toLowerCase();
|
|
const tags = (Array.isArray(skill.tags) ? skill.tags : []).map((tag) =>
|
|
String(tag).toLowerCase()
|
|
);
|
|
const description = toLowerText(skill.description);
|
|
|
|
const nameTokens = splitNameTokens(skill.name);
|
|
const descriptionTokens = extractTokens(description);
|
|
|
|
let score = 0;
|
|
|
|
if (name && contextText.includes(name)) {
|
|
score += 6;
|
|
}
|
|
|
|
for (const token of nameTokens) {
|
|
if (contextTokens.has(token)) score += 2;
|
|
}
|
|
|
|
for (const tag of tags) {
|
|
if (!tag) continue;
|
|
if (contextText.includes(tag)) {
|
|
score += 3;
|
|
}
|
|
}
|
|
|
|
for (const token of descriptionTokens) {
|
|
if (contextTokens.has(token)) score += 1;
|
|
}
|
|
|
|
if (backgroundTokens.size > 0) {
|
|
for (const token of backgroundTokens) {
|
|
if (nameTokens.has(token)) score += 2;
|
|
if (tags.some((tag) => tag.includes(token) || token.includes(tag))) score += 2;
|
|
}
|
|
}
|
|
|
|
const providerAliases: Record<InjectionOptions["provider"], string[]> = {
|
|
openai: ["openai", "gpt"],
|
|
anthropic: ["anthropic", "claude"],
|
|
google: ["google", "gemini"],
|
|
other: [],
|
|
};
|
|
const knownProviderHints = new Set(["openai", "gpt", "anthropic", "claude", "google", "gemini"]);
|
|
|
|
const skillProviderHints = tags.filter((tag) => knownProviderHints.has(tag));
|
|
if (skillProviderHints.length > 0) {
|
|
const aliases = providerAliases[options.provider];
|
|
const hasProviderMatch = skillProviderHints.some((hint) => aliases.includes(hint));
|
|
if (hasProviderMatch) {
|
|
score += 2;
|
|
} else {
|
|
score -= 2;
|
|
}
|
|
}
|
|
|
|
return score;
|
|
}
|
|
|
|
export function injectSkills(options: InjectionOptions): unknown[] {
|
|
const contextText = buildContextText(options);
|
|
const contextTokens = extractTokens(contextText);
|
|
const backgroundTokens = extractTokens(toLowerText(options.backgroundReason));
|
|
const selectedSkills = skillRegistry.list(options.apiKeyId).filter((s) => {
|
|
const mode = s.mode || (s.enabled ? "on" : "off");
|
|
if (mode === "off") return false;
|
|
return s.enabled;
|
|
});
|
|
|
|
const alwaysOnSkills = selectedSkills.filter((s) => {
|
|
const mode = s.mode || (s.enabled ? "on" : "off");
|
|
return mode === "on";
|
|
});
|
|
|
|
const autoCandidates = selectedSkills.filter((s) => {
|
|
const mode = s.mode || (s.enabled ? "on" : "off");
|
|
return mode === "auto";
|
|
});
|
|
|
|
const autoSkills = autoCandidates
|
|
.map((skill) => ({
|
|
skill,
|
|
score: scoreAutoSkill(skill, options, contextText, contextTokens, backgroundTokens),
|
|
}))
|
|
.filter((entry) => entry.score >= AUTO_MIN_SCORE)
|
|
.sort((a, b) => {
|
|
if (b.score !== a.score) return b.score - a.score;
|
|
const installA = typeof a.skill.installCount === "number" ? a.skill.installCount : 0;
|
|
const installB = typeof b.skill.installCount === "number" ? b.skill.installCount : 0;
|
|
if (installB !== installA) return installB - installA;
|
|
return a.skill.name.localeCompare(b.skill.name);
|
|
})
|
|
.slice(0, AUTO_MAX_SKILLS)
|
|
.map((entry) => entry.skill);
|
|
|
|
const skills = [...alwaysOnSkills, ...autoSkills];
|
|
|
|
if (skills.length === 0) {
|
|
log.info("skills.injection.skipped", {
|
|
apiKeyId: options.apiKeyId,
|
|
reason: "no_enabled_skills",
|
|
});
|
|
return options.existingTools || [];
|
|
}
|
|
|
|
log.info("skills.injection.injected", {
|
|
apiKeyId: options.apiKeyId,
|
|
provider: options.provider,
|
|
skillCount: skills.length,
|
|
});
|
|
|
|
const injectedTools = skills.map((skill) => {
|
|
switch (options.provider) {
|
|
case "openai":
|
|
return skillToOpenAI(skill);
|
|
case "anthropic":
|
|
return skillToClaude(skill);
|
|
case "google":
|
|
return skillToGemini(skill);
|
|
default:
|
|
return skillToOpenAI(skill);
|
|
}
|
|
});
|
|
|
|
if (options.existingTools && options.existingTools.length > 0) {
|
|
return [...injectedTools, ...options.existingTools];
|
|
}
|
|
|
|
return injectedTools;
|
|
}
|
|
|
|
export function injectSkillTools(
|
|
messages: any[],
|
|
provider: "openai" | "anthropic" | "google" | "other",
|
|
apiKeyId: string
|
|
): any[] {
|
|
const tools = injectSkills({ provider, apiKeyId });
|
|
|
|
if (tools.length === 0) {
|
|
return messages;
|
|
}
|
|
|
|
const lastMessage = messages[messages.length - 1];
|
|
|
|
if (lastMessage.role === "user" && !lastMessage.tools) {
|
|
return [...messages.slice(0, -1), { ...lastMessage, tools }];
|
|
}
|
|
|
|
return messages;
|
|
}
|
|
|
|
export function detectProvider(modelId: string): "openai" | "anthropic" | "google" | "other" {
|
|
const lower = modelId.toLowerCase();
|
|
|
|
if (lower.includes("gpt") || lower.includes("openai")) {
|
|
return "openai";
|
|
}
|
|
if (lower.includes("claude") || lower.includes("anthropic")) {
|
|
return "anthropic";
|
|
}
|
|
if (lower.includes("gemini") || lower.includes("google")) {
|
|
return "google";
|
|
}
|
|
|
|
return "other";
|
|
}
|