diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index dd989fe90d..438a52c000 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -90,6 +90,8 @@ import { import { resolveStreamFlag, stripMarkdownCodeFence } from "../utils/aiSdkCompat.ts"; import { generateRequestId } from "@/shared/utils/requestId"; import { normalizePayloadForLog } from "@/lib/logPayloads"; +import { injectMemory, shouldInjectMemory } from "@/lib/memory/injection"; +import { retrieveMemories } from "@/lib/memory/retrieval"; export function shouldUseNativeCodexPassthrough({ provider, @@ -680,6 +682,26 @@ export async function handleChatCore({ }); } + if (apiKeyInfo?.id && shouldInjectMemory(body as Parameters[0])) { + try { + const memories = await retrieveMemories(apiKeyInfo.id); + if (memories.length > 0) { + const injected = injectMemory( + body as Parameters[0], + memories, + provider + ); + body = injected as typeof body; + log?.debug?.("MEMORY", `Injected ${memories.length} memories for key=${apiKeyInfo.id}`); + } + } catch (memErr) { + log?.debug?.( + "MEMORY", + `Memory injection skipped: ${memErr instanceof Error ? memErr.message : String(memErr)}` + ); + } + } + // Translate request (pass reqLogger for intermediate logging) let translatedBody = body; const isClaudePassthrough = sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE; diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index c5c80444c5..9a1d003261 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -56,6 +56,8 @@ import { handleGetSessionSnapshot, handleSyncPricing, } from "./tools/advancedTools.ts"; +import { memoryTools } from "./tools/memoryTools.ts"; +import { skillTools } from "./tools/skillTools.ts"; import { normalizeQuotaResponse } from "../../src/shared/contracts/quota.ts"; // ============ Configuration ============ @@ -761,6 +763,48 @@ export function createMcpServer(): McpServer { ) ); + // ── Memory Tools ────────────────────────────── + Object.values(memoryTools).forEach((toolDef) => { + server.registerTool( + toolDef.name, + { + description: toolDef.description, + inputSchema: toolDef.inputSchema as any, + }, + withScopeEnforcement(toolDef.name, async (args) => { + try { + const parsedArgs = toolDef.inputSchema.parse(args ?? {}); + const result = await toolDef.handler(parsedArgs as any); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } + }) + ); + }); + + // ── Skill Tools ────────────────────────────── + Object.values(skillTools).forEach((toolDef) => { + server.registerTool( + toolDef.name, + { + description: toolDef.description, + inputSchema: toolDef.inputSchema as any, + }, + withScopeEnforcement(toolDef.name, async (args) => { + try { + const parsedArgs = toolDef.inputSchema.parse(args ?? {}); + const result = await toolDef.handler(parsedArgs as any); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } + }) + ); + }); + return server; } diff --git a/open-sse/mcp-server/tools/memoryTools.ts b/open-sse/mcp-server/tools/memoryTools.ts new file mode 100644 index 0000000000..f4dc703e92 --- /dev/null +++ b/open-sse/mcp-server/tools/memoryTools.ts @@ -0,0 +1,118 @@ +import { z } from "zod"; +import { retrieveMemories } from "@/lib/memory/retrieval"; +import { createMemory, deleteMemory, listMemories } from "@/lib/memory/store"; +import { MemoryType } from "@/lib/memory/types"; + +export const MemorySearchSchema = z.object({ + apiKeyId: z.string(), + query: z.string().optional(), + type: z.enum(["factual", "episodic", "procedural", "semantic"]).optional(), + maxTokens: z.number().int().positive().max(8000).optional(), + limit: z.number().int().positive().max(100).optional(), +}); + +export const MemoryAddSchema = z.object({ + apiKeyId: z.string(), + sessionId: z.string().optional(), + type: z.enum(["factual", "episodic", "procedural", "semantic"]), + key: z.string().min(1), + content: z.string().min(1), + metadata: z.record(z.string(), z.unknown()).optional(), +}); + +export const MemoryClearSchema = z.object({ + apiKeyId: z.string(), + type: z.enum(["factual", "episodic", "procedural", "semantic"]).optional(), + olderThan: z.string().optional(), +}); + +export const memoryTools = { + omniroute_memory_search: { + name: "omniroute_memory_search", + description: "Search memories by query, type, or API key with token budget enforcement", + inputSchema: MemorySearchSchema, + handler: async (args: z.infer) => { + const config = { + enabled: true, + maxTokens: args.maxTokens || 2000, + retrievalStrategy: "exact" as const, + autoSummarize: false, + persistAcrossModels: false, + retentionDays: 30, + scope: "apiKey" as const, + }; + + const memories = await retrieveMemories(args.apiKeyId, config); + + const filtered = args.type ? memories.filter((m) => m.type === args.type) : memories; + + const limited = args.limit ? filtered.slice(0, args.limit) : filtered; + + return { + success: true, + data: { + memories: limited, + count: limited.length, + totalTokens: limited.reduce((sum, m) => sum + Math.ceil(m.content.length / 4), 0), + }, + }; + }, + }, + + omniroute_memory_add: { + name: "omniroute_memory_add", + description: "Add a new memory entry", + inputSchema: MemoryAddSchema, + handler: async (args: z.infer) => { + const memory = await createMemory({ + apiKeyId: args.apiKeyId, + sessionId: args.sessionId || null, + type: args.type as MemoryType, + key: args.key, + content: args.content, + metadata: args.metadata || {}, + expiresAt: null, + }); + + return { + success: true, + data: { + memory, + message: "Memory created successfully", + }, + }; + }, + }, + + omniroute_memory_clear: { + name: "omniroute_memory_clear", + description: "Clear memories for an API key, optionally filtered by type or age", + inputSchema: MemoryClearSchema, + handler: async (args: z.infer) => { + const memories = await listMemories({ + apiKeyId: args.apiKeyId, + type: args.type as MemoryType | undefined, + }); + + let toDelete = memories; + if (args.olderThan) { + const cutoff = new Date(args.olderThan); + toDelete = memories.filter((m) => new Date(m.createdAt) < cutoff); + } + + let deletedCount = 0; + for (const memory of toDelete) { + await deleteMemory(memory.id); + deletedCount++; + } + + return { + success: true, + data: { + deletedCount, + message: `Cleared ${deletedCount} memories`, + }, + }; + }, + }, +}; diff --git a/open-sse/mcp-server/tools/skillTools.ts b/open-sse/mcp-server/tools/skillTools.ts new file mode 100644 index 0000000000..3433b646ad --- /dev/null +++ b/open-sse/mcp-server/tools/skillTools.ts @@ -0,0 +1,120 @@ +import { z } from "zod"; +import { skillRegistry } from "@/lib/skills/registry"; +import { skillExecutor } from "@/lib/skills/executor"; + +export const SkillListSchema = z.object({ + apiKeyId: z.string().optional(), + name: z.string().optional(), + enabled: z.boolean().optional(), +}); + +export const SkillEnableSchema = z.object({ + apiKeyId: z.string(), + skillId: z.string(), + enabled: z.boolean(), +}); + +export const SkillExecuteSchema = z.object({ + apiKeyId: z.string(), + skillName: z.string(), + input: z.record(z.string(), z.unknown()), + sessionId: z.string().optional(), +}); + +export const skillTools = { + omniroute_skills_list: { + name: "omniroute_skills_list", + description: "List all registered skills with optional filtering by API key or name", + inputSchema: SkillListSchema, + handler: async (args: z.infer) => { + await skillRegistry.loadFromDatabase(args.apiKeyId); + const skills = skillRegistry.list(args.apiKeyId); + + let filtered = skills; + if (args.name) { + filtered = filtered.filter((s) => s.name.includes(args.name!)); + } + if (args.enabled !== undefined) { + filtered = filtered.filter((s) => s.enabled === args.enabled); + } + + return { + skills: filtered.map((s) => ({ + id: s.id, + name: s.name, + version: s.version, + description: s.description, + enabled: s.enabled, + createdAt: s.createdAt.toISOString(), + })), + count: filtered.length, + }; + }, + }, + + omniroute_skills_enable: { + name: "omniroute_skills_enable", + description: "Enable or disable a specific skill by ID", + inputSchema: SkillEnableSchema, + handler: async (args: z.infer) => { + const skill = skillRegistry.getSkill(args.skillId, args.apiKeyId); + if (!skill) { + throw new Error(`Skill not found: ${args.skillId}`); + } + + await skillRegistry.register({ + ...skill, + enabled: args.enabled, + apiKeyId: args.apiKeyId, + }); + + return { success: true, skillId: args.skillId, enabled: args.enabled }; + }, + }, + + omniroute_skills_execute: { + name: "omniroute_skills_execute", + description: "Execute a skill with provided input and return the result", + inputSchema: SkillExecuteSchema, + handler: async (args: z.infer) => { + const execution = await skillExecutor.execute(args.skillName, args.input, { + apiKeyId: args.apiKeyId, + sessionId: args.sessionId, + }); + + return { + id: execution.id, + skillId: execution.skillId, + status: execution.status, + output: execution.output, + error: execution.errorMessage, + duration: execution.durationMs, + createdAt: execution.createdAt.toISOString(), + }; + }, + }, + + omniroute_skills_executions: { + name: "omniroute_skills_executions", + description: "List recent skill execution history", + inputSchema: z.object({ + apiKeyId: z.string().optional(), + limit: z.number().int().positive().max(100).optional(), + }), + handler: async (args: { apiKeyId?: string; limit?: number }) => { + const executions = skillExecutor.listExecutions(args.apiKeyId, args.limit || 50); + + return { + executions: executions.map((e) => ({ + id: e.id, + skillId: e.skillId, + status: e.status, + duration: e.durationMs, + error: e.errorMessage, + createdAt: e.createdAt.toISOString(), + })), + count: executions.length, + }; + }, + }, +}; diff --git a/src/app/(dashboard)/dashboard/memory/page.tsx b/src/app/(dashboard)/dashboard/memory/page.tsx new file mode 100644 index 0000000000..813b4c2a3a --- /dev/null +++ b/src/app/(dashboard)/dashboard/memory/page.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, Badge, Button, Input, Select } from "@/shared/components"; +import { useTranslations } from "next-intl"; + +interface Memory { + id: string; + apiKeyId: string; + sessionId: string | null; + type: "factual" | "episodic" | "procedural" | "semantic"; + key: string; + content: string; + metadata: Record; + createdAt: string; + updatedAt: string; + expiresAt: string | null; +} + +interface MemoryStats { + totalEntries: number; + tokensUsed: number; + hitRate: number; +} + +export default function MemoryPage() { + const t = useTranslations("memory"); + const [memories, setMemories] = useState([]); + const [stats, setStats] = useState({ + totalEntries: 0, + tokensUsed: 0, + hitRate: 0, + }); + const [filterType, setFilterType] = useState("all"); + const [searchQuery, setSearchQuery] = useState(""); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + fetchMemories(); + }, []); + + const fetchMemories = async () => { + try { + const response = await fetch("/api/memory"); + if (response.ok) { + const data = await response.json(); + setMemories(data.memories || []); + setStats(data.stats || { totalEntries: 0, tokensUsed: 0, hitRate: 0 }); + } + } catch (error) { + console.error("Failed to fetch memories:", error); + } finally { + setIsLoading(false); + } + }; + + const handleDelete = async (id: string) => { + try { + await fetch(`/api/memory/${id}`, { method: "DELETE" }); + setMemories(memories.filter((m) => m.id !== id)); + } catch (error) { + console.error("Failed to delete memory:", error); + } + }; + + const handleExport = () => { + const dataStr = JSON.stringify(memories, null, 2); + const dataBlob = new Blob([dataStr], { type: "application/json" }); + const url = URL.createObjectURL(dataBlob); + const link = document.createElement("a"); + link.href = url; + link.download = `memory-export-${new Date().toISOString()}.json`; + link.click(); + }; + + const filteredMemories = memories.filter((memory) => { + const matchesType = filterType === "all" || memory.type === filterType; + const matchesSearch = + searchQuery === "" || + memory.content.toLowerCase().includes(searchQuery.toLowerCase()) || + memory.key.toLowerCase().includes(searchQuery.toLowerCase()); + return matchesType && matchesSearch; + }); + + const getTypeColor = (type: string) => { + switch (type) { + case "factual": + return "info"; + case "episodic": + return "success"; + case "procedural": + return "warning"; + case "semantic": + return "error"; + default: + return "default"; + } + }; + + if (isLoading) { + return ( +
+
+
+ ); + } + + return ( +
+
+

Memory Management

+
+ + + +
+
+ +
+ +
+
Total Entries
+
{stats.totalEntries}
+
+
+ +
+
Tokens Used
+
{stats.tokensUsed.toLocaleString()}
+
+
+ +
+
Hit Rate
+
{(stats.hitRate * 100).toFixed(1)}%
+
+
+
+ + +
+
+

Memories

+
+ setSearchQuery(e.target.value)} + className="w-64" + /> + +
+
+ +
+ + + + + + + + + + + + {filteredMemories.map((memory) => ( + + + + + + + + ))} + +
TypeKeyContentCreatedActions
+ {memory.type} + {memory.key}{memory.content}{new Date(memory.createdAt).toLocaleDateString()} + +
+
+
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/MemorySkillsTab.tsx b/src/app/(dashboard)/dashboard/settings/components/MemorySkillsTab.tsx new file mode 100644 index 0000000000..60b218daa6 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/MemorySkillsTab.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card } from "@/shared/components"; +import { useTranslations } from "next-intl"; + +interface MemoryConfig { + enabled: boolean; + maxTokens: number; + retentionDays: number; + strategy: "recent" | "semantic" | "hybrid"; + skillsEnabled: boolean; +} + +const STRATEGIES = [ + { value: "recent", labelKey: "recent", descKey: "recentDesc" }, + { value: "semantic", labelKey: "semantic", descKey: "semanticDesc" }, + { value: "hybrid", labelKey: "hybrid", descKey: "hybridDesc" }, +]; + +export default function MemorySkillsTab() { + const [config, setConfig] = useState({ + enabled: true, + maxTokens: 2000, + retentionDays: 30, + strategy: "hybrid", + skillsEnabled: false, + }); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [status, setStatus] = useState(""); + const t = useTranslations("settings"); + + useEffect(() => { + fetch("/api/settings/memory") + .then((res) => res.json()) + .then((data) => { + setConfig(data); + setLoading(false); + }) + .catch(() => setLoading(false)); + }, []); + + const save = async (updates: Partial) => { + const newConfig = { ...config, ...updates }; + setConfig(newConfig); + setSaving(true); + setStatus(""); + try { + const res = await fetch("/api/settings/memory", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(newConfig), + }); + if (res.ok) { + setStatus("saved"); + setTimeout(() => setStatus(""), 2000); + } else { + setStatus("error"); + } + } catch { + setStatus("error"); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ( + +
+
+ +
+
+

{t("memorySkillsTitle")}

+

{t("memorySkillsDesc")}

+
+
+
{t("loading")}...
+
+ ); + } + + return ( +
+ {/* Memory Settings */} + +
+
+ +
+
+

{t("memoryTitle")}

+

{t("memoryDesc")}

+
+ {status === "saved" && ( + + check_circle{" "} + {t("saved")} + + )} +
+ + {/* Enable toggle */} +
+
+

{t("memoryEnabled")}

+

{t("memoryEnabledDesc")}

+
+ +
+ + {/* Memory config fields */} + {config.enabled && ( + <> + {/* Max tokens */} +
+
+

{t("maxTokens")}

+ + {config.maxTokens.toLocaleString()} {t("tokens")} + +
+ save({ maxTokens: parseInt(e.target.value) })} + className="w-full accent-violet-500" + /> +
+ {t("off")} + 4K + 8K + 16K +
+
+ + {/* Retention days */} +
+
+

{t("retentionDays")}

+ + {config.retentionDays} {t("days")} + +
+ save({ retentionDays: parseInt(e.target.value) })} + className="w-full accent-violet-500" + /> +
+ 1 + 30 + 60 + 90 +
+
+ + {/* Strategy selector */} +
+ {STRATEGIES.map((s) => ( + + ))} +
+ + )} +
+ + {/* Skills Settings (placeholder) */} + +
+
+ +
+
+

{t("skillsTitle")}

+

{t("skillsDesc")}

+
+
+ +
+
+

{t("skillsEnabled")}

+

{t("skillsEnabledDesc")}

+
+ +
+ +

{t("skillsComingSoon")}

+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/page.tsx b/src/app/(dashboard)/dashboard/settings/page.tsx index 4da90585ef..640fcea3ea 100644 --- a/src/app/(dashboard)/dashboard/settings/page.tsx +++ b/src/app/(dashboard)/dashboard/settings/page.tsx @@ -19,6 +19,7 @@ import BackgroundDegradationTab from "./components/BackgroundDegradationTab"; import CacheStatsCard from "./components/CacheStatsCard"; import CacheSettingsTab from "./components/CacheSettingsTab"; +import MemorySkillsTab from "./components/MemorySkillsTab"; import ResilienceTab from "./components/ResilienceTab"; const tabs = [ @@ -76,9 +77,17 @@ export default function SettingsPage() { role="tabpanel" aria-label={t(tabs.find((t2) => t2.id === activeTab)?.labelKey || "general")} > - {activeTab === "general" && } + {activeTab === "general" && ( +
+ +
+ )} - {activeTab === "appearance" && } + {activeTab === "appearance" && ( +
+ +
+ )} {activeTab === "ai" && (
@@ -87,6 +96,7 @@ export default function SettingsPage() { +
)} diff --git a/src/app/(dashboard)/dashboard/skills/page.tsx b/src/app/(dashboard)/dashboard/skills/page.tsx new file mode 100644 index 0000000000..812adc5956 --- /dev/null +++ b/src/app/(dashboard)/dashboard/skills/page.tsx @@ -0,0 +1,230 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card } from "@/shared/components"; +import { useTranslations } from "next-intl"; + +interface Skill { + id: string; + name: string; + version: string; + description: string; + enabled: boolean; + createdAt: string; +} + +interface Execution { + id: string; + skillId: string; + skillName: string; + status: string; + duration: number; + createdAt: string; +} + +export default function SkillsPage() { + const [skills, setSkills] = useState([]); + const [executions, setExecutions] = useState([]); + const [loading, setLoading] = useState(true); + const [activeTab, setActiveTab] = useState<"skills" | "executions" | "sandbox">("skills"); + const t = useTranslations("skills"); + + useEffect(() => { + Promise.all([ + fetch("/api/skills").then((r) => r.json()), + fetch("/api/skills/executions").then((r) => r.json()), + ]) + .then(([skillsData, executionsData]) => { + setSkills(skillsData.skills || []); + setExecutions(executionsData.executions || []); + setLoading(false); + }) + .catch(() => setLoading(false)); + }, []); + + const toggleSkill = async (skillId: string, enabled: boolean) => { + await fetch(`/api/skills/${skillId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: !enabled }), + }); + setSkills(skills.map((s) => (s.id === skillId ? { ...s, enabled: !enabled } : s))); + }; + + if (loading) { + return ( +
+
{t("loading")}...
+
+ ); + } + + return ( +
+
+

{t("title")}

+

{t("description")}

+
+ +
+ + + +
+ + {activeTab === "skills" && ( +
+ {skills.length === 0 ? ( + +
{t("noSkills")}
+
+ ) : ( + skills.map((skill) => ( + +
+
+
+

{skill.name}

+ + v{skill.version} + +
+

{skill.description}

+
+ +
+
+ )) + )} +
+ )} + + {activeTab === "executions" && ( + +
+ + + + + + + + + + + {executions.length === 0 ? ( + + + + ) : ( + executions.map((exec) => ( + + + + + + + )) + )} + +
{t("skill")}{t("status")}{t("duration")}{t("time")}
+ {t("noExecutions")} +
{exec.skillName} + + {exec.status} + + {exec.duration}ms + {new Date(exec.createdAt).toLocaleString()} +
+
+
+ )} + + {activeTab === "sandbox" && ( +
+ +

{t("sandboxConfig")}

+
+
+
+

{t("cpuLimit")}

+

{t("cpuLimitDesc")}

+
+ 100ms +
+
+
+

{t("memoryLimit")}

+

{t("memoryLimitDesc")}

+
+ 256MB +
+
+
+

{t("timeout")}

+

{t("timeoutDesc")}

+
+ 30s +
+
+
+

{t("networkAccess")}

+

{t("networkAccessDesc")}

+
+ {t("disabled")} +
+
+
+
+ )} +
+ ); +} diff --git a/src/app/api/memory/[id]/route.ts b/src/app/api/memory/[id]/route.ts new file mode 100644 index 0000000000..4abbe5df32 --- /dev/null +++ b/src/app/api/memory/[id]/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { deleteMemory, getMemory } from "@/lib/memory/store"; + +export async function DELETE( + request: Request, + props: { params: Promise<{ id: string }> } +) { + try { + const { id } = await props.params; + const success = await deleteMemory(id); + if (!success) { + return NextResponse.json({ error: "Memory not found" }, { status: 404 }); + } + return NextResponse.json({ success: true }); + } catch (err: unknown) { + const error = err instanceof Error ? err.message : String(err); + return NextResponse.json({ error }, { status: 500 }); + } +} + +export async function GET( + request: Request, + props: { params: Promise<{ id: string }> } +) { + try { + const { id } = await props.params; + const memory = await getMemory(id); + if (!memory) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + return NextResponse.json({ memory }); + } catch (err: unknown) { + const error = err instanceof Error ? err.message : String(err); + return NextResponse.json({ error }, { status: 500 }); + } +} diff --git a/src/app/api/memory/route.ts b/src/app/api/memory/route.ts new file mode 100644 index 0000000000..9c77cb42d0 --- /dev/null +++ b/src/app/api/memory/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { listMemories, createMemory } from "@/lib/memory/store"; + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const apiKeyId = searchParams.get("apiKeyId") || undefined; + const type = searchParams.get("type") as any || undefined; + const sessionId = searchParams.get("sessionId") || undefined; + const limitParams = searchParams.get("limit"); + const offsetParams = searchParams.get("offset"); + + const memories = await listMemories({ + apiKeyId, + type, + sessionId, + limit: limitParams ? parseInt(limitParams, 10) : undefined, + offset: offsetParams ? parseInt(offsetParams, 10) : undefined, + }); + return NextResponse.json({ memories }); + } catch (err: unknown) { + const error = err instanceof Error ? err.message : String(err); + return NextResponse.json({ error }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const body = await request.json(); + const memoryId = await createMemory(body); + return NextResponse.json({ success: true, id: memoryId }); + } catch (err: unknown) { + const error = err instanceof Error ? err.message : String(err); + return NextResponse.json({ error }, { status: 400 }); + } +} diff --git a/src/app/api/skills/[id]/route.ts b/src/app/api/skills/[id]/route.ts new file mode 100644 index 0000000000..0386143d7d --- /dev/null +++ b/src/app/api/skills/[id]/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; +import { getDbInstance } from "@/lib/db/core"; +import { skillRegistry } from "@/lib/skills/registry"; + +export async function PUT( + request: Request, + props: { params: Promise<{ id: string }> } +) { + try { + const { id } = await props.params; + const body = await request.json(); + + if (typeof body.enabled !== "boolean") { + return NextResponse.json({ error: "Invalid payload, missing enabled boolean" }, { status: 400 }); + } + + const db = getDbInstance(); + db.prepare("UPDATE skills SET enabled = ? WHERE id = ?").run( + body.enabled ? 1 : 0, + id + ); + + await skillRegistry.loadFromDatabase(); + + return NextResponse.json({ success: true, enabled: body.enabled }); + } catch (err: unknown) { + const error = err instanceof Error ? err.message : String(err); + return NextResponse.json({ error }, { status: 500 }); + } +} diff --git a/src/app/api/skills/executions/route.ts b/src/app/api/skills/executions/route.ts new file mode 100644 index 0000000000..098fa5dcc6 --- /dev/null +++ b/src/app/api/skills/executions/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server"; +import { skillExecutor } from "@/lib/skills/executor"; + +export async function GET() { + try { + const executions = skillExecutor.listExecutions(); + return NextResponse.json({ executions }); + } catch (err: unknown) { + const error = err instanceof Error ? err.message : String(err); + return NextResponse.json({ error }, { status: 500 }); + } +} diff --git a/src/app/api/skills/route.ts b/src/app/api/skills/route.ts new file mode 100644 index 0000000000..ca2a8e1580 --- /dev/null +++ b/src/app/api/skills/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server"; +import { skillRegistry } from "@/lib/skills/registry"; + +export async function GET() { + try { + await skillRegistry.loadFromDatabase(); + const skills = skillRegistry.list(); + return NextResponse.json({ skills }); + } catch (err: unknown) { + const error = err instanceof Error ? err.message : String(err); + return NextResponse.json({ error }, { status: 500 }); + } +} diff --git a/src/lib/benchmarks.ts b/src/lib/benchmarks.ts new file mode 100644 index 0000000000..03caeba9d3 --- /dev/null +++ b/src/lib/benchmarks.ts @@ -0,0 +1,33 @@ +export interface BenchmarkResult { + name: string; + duration: number; + opsPerSecond: number; + memory: number; + success: boolean; +} + +export async function runBenchmarks(): Promise { + const results: BenchmarkResult[] = []; + + results.push({ + name: "memory_retrieval", + duration: 0, + opsPerSecond: 0, + memory: 0, + success: true, + }); + + results.push({ + name: "skill_execution", + duration: 0, + opsPerSecond: 0, + memory: 0, + success: true, + }); + + return results; +} + +export function formatBenchmarkReport(results: BenchmarkResult[]): string { + return results.map((r) => `${r.name}: ${r.opsPerSecond.toFixed(2)} ops/s`).join("\n"); +} diff --git a/src/lib/db/migrations/015_create_memories.sql b/src/lib/db/migrations/015_create_memories.sql new file mode 100644 index 0000000000..e98b094391 --- /dev/null +++ b/src/lib/db/migrations/015_create_memories.sql @@ -0,0 +1,22 @@ +-- 014_create_memories.sql +-- Memories table for persistent context storage. +-- Stores structured conversation memories with support for different memory types. + +CREATE TABLE IF NOT EXISTS memories ( + id TEXT PRIMARY KEY, + api_key_id TEXT NOT NULL, + session_id TEXT, + type TEXT NOT NULL CHECK(type IN ('factual', 'episodic', 'procedural', 'semantic')), + key TEXT, + content TEXT NOT NULL, + metadata TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT +); + +-- Indexes for performance optimization +CREATE INDEX IF NOT EXISTS idx_memories_api_key ON memories(api_key_id); +CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id); +CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(type); +CREATE INDEX IF NOT EXISTS idx_memories_expires ON memories(expires_at); diff --git a/src/lib/db/migrations/016_create_skills.sql b/src/lib/db/migrations/016_create_skills.sql new file mode 100644 index 0000000000..f765efb284 --- /dev/null +++ b/src/lib/db/migrations/016_create_skills.sql @@ -0,0 +1,37 @@ +-- 015_create_skills.sql +-- Skills table for tool/function capability injection. +-- Stores skill definitions with schemas and execution tracking. + +CREATE TABLE IF NOT EXISTS skills ( + id TEXT PRIMARY KEY, + api_key_id TEXT NOT NULL, + name TEXT NOT NULL, + version TEXT NOT NULL DEFAULT '1.0.0', + description TEXT, + schema TEXT NOT NULL, + handler TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS skill_executions ( + id TEXT PRIMARY KEY, + skill_id TEXT NOT NULL, + api_key_id TEXT NOT NULL, + session_id TEXT, + input TEXT NOT NULL, + output TEXT, + status TEXT NOT NULL CHECK(status IN ('pending', 'running', 'success', 'error', 'timeout')), + error_message TEXT, + duration_ms INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_skills_api_key ON skills(api_key_id); +CREATE INDEX IF NOT EXISTS idx_skills_name ON skills(name); +CREATE INDEX IF NOT EXISTS idx_skill_executions_skill ON skill_executions(skill_id); +CREATE INDEX IF NOT EXISTS idx_skill_executions_api_key ON skill_executions(api_key_id); +CREATE INDEX IF NOT EXISTS idx_skill_executions_status ON skill_executions(status); +CREATE INDEX IF NOT EXISTS idx_skill_executions_created ON skill_executions(created_at); \ No newline at end of file diff --git a/src/lib/memory/__tests__/injection.test.ts b/src/lib/memory/__tests__/injection.test.ts new file mode 100644 index 0000000000..716f40ddd9 --- /dev/null +++ b/src/lib/memory/__tests__/injection.test.ts @@ -0,0 +1,206 @@ +import { describe, test, expect } from "vitest"; +import { + injectMemory, + shouldInjectMemory, + formatMemoryContext, + providerSupportsSystemMessage, + ChatRequest, +} from "../injection"; +import { Memory, MemoryType } from "../types"; + +function makeMemory(content: string, overrides: Partial = {}): Memory { + return { + id: "mem-1", + apiKeyId: "key-1", + sessionId: "sess-1", + type: MemoryType.FACTUAL, + key: "test-key", + content, + metadata: {}, + createdAt: new Date(), + updatedAt: new Date(), + expiresAt: null, + ...overrides, + }; +} + +function makeRequest(overrides: Partial = {}): ChatRequest { + return { + model: "gpt-4", + messages: [{ role: "user", content: "Hello" }], + ...overrides, + }; +} + +describe("formatMemoryContext", () => { + test("returns empty string for empty array", () => { + expect(formatMemoryContext([])).toBe(""); + }); + + test("single memory is formatted with 'Memory context:' prefix", () => { + const result = formatMemoryContext([makeMemory("User prefers dark mode")]); + expect(result).toBe("Memory context: User prefers dark mode"); + }); + + test("multiple memories are joined with newline", () => { + const memories = [makeMemory("fact one"), makeMemory("fact two")]; + const result = formatMemoryContext(memories); + expect(result).toBe("Memory context: fact one\nfact two"); + }); + + test("trims whitespace from individual memory content", () => { + const result = formatMemoryContext([makeMemory(" padded content ")]); + expect(result).toBe("Memory context: padded content"); + }); + + test("filters out blank memories", () => { + const memories = [makeMemory("real content"), makeMemory(" ")]; + const result = formatMemoryContext(memories); + expect(result).toBe("Memory context: real content"); + }); +}); + +describe("providerSupportsSystemMessage", () => { + test("returns true for null/undefined provider", () => { + expect(providerSupportsSystemMessage(null)).toBe(true); + expect(providerSupportsSystemMessage(undefined)).toBe(true); + }); + + test("returns true for standard providers", () => { + expect(providerSupportsSystemMessage("openai")).toBe(true); + expect(providerSupportsSystemMessage("anthropic")).toBe(true); + expect(providerSupportsSystemMessage("deepseek")).toBe(true); + expect(providerSupportsSystemMessage("google")).toBe(true); + }); + + test("returns false for o1 family providers", () => { + expect(providerSupportsSystemMessage("o1")).toBe(false); + expect(providerSupportsSystemMessage("o1-mini")).toBe(false); + expect(providerSupportsSystemMessage("o1-preview")).toBe(false); + }); + + test("comparison is case-insensitive", () => { + expect(providerSupportsSystemMessage("O1")).toBe(false); + expect(providerSupportsSystemMessage("O1-MINI")).toBe(false); + }); +}); + +describe("injectMemory — system message injection", () => { + test("injects memory as system message when provider supports it", () => { + const request = makeRequest(); + const memories = [makeMemory("User prefers concise answers")]; + const result = injectMemory(request, memories, "openai"); + + expect(result.messages[0].role).toBe("system"); + expect(result.messages[0].content).toBe("Memory context: User prefers concise answers"); + expect(result.messages[1]).toEqual({ role: "user", content: "Hello" }); + }); + + test("preserves existing messages after injected system message", () => { + const request = makeRequest({ + messages: [ + { role: "system", content: "You are helpful" }, + { role: "user", content: "Hello" }, + ], + }); + const memories = [makeMemory("User is an expert developer")]; + const result = injectMemory(request, memories, "anthropic"); + + expect(result.messages).toHaveLength(3); + expect(result.messages[0].role).toBe("system"); + expect(result.messages[0].content).toContain("Memory context:"); + expect(result.messages[1]).toEqual({ role: "system", content: "You are helpful" }); + expect(result.messages[2]).toEqual({ role: "user", content: "Hello" }); + }); + + test("does not mutate the original request", () => { + const request = makeRequest(); + const originalMessages = [...request.messages]; + const memories = [makeMemory("Some fact")]; + injectMemory(request, memories, "openai"); + + expect(request.messages).toEqual(originalMessages); + }); + + test("preserves all other request fields", () => { + const request = makeRequest({ temperature: 0.7, max_tokens: 256, stream: true }); + const memories = [makeMemory("fact")]; + const result = injectMemory(request, memories, "openai"); + + expect(result.temperature).toBe(0.7); + expect(result.max_tokens).toBe(256); + expect(result.stream).toBe(true); + expect(result.model).toBe("gpt-4"); + }); +}); + +describe("injectMemory — message prefix fallback", () => { + test("injects memory as first user message for o1 provider", () => { + const request = makeRequest(); + const memories = [makeMemory("User context detail")]; + const result = injectMemory(request, memories, "o1"); + + expect(result.messages[0].role).toBe("user"); + expect(result.messages[0].content).toBe("Memory context: User context detail"); + expect(result.messages[1]).toEqual({ role: "user", content: "Hello" }); + }); + + test("injects memory as first user message for o1-mini", () => { + const request = makeRequest(); + const memories = [makeMemory("Preference")]; + const result = injectMemory(request, memories, "o1-mini"); + + expect(result.messages[0].role).toBe("user"); + expect(result.messages[0].content).toContain("Memory context:"); + }); +}); + +describe("injectMemory — edge cases", () => { + test("returns original request when memories array is empty", () => { + const request = makeRequest(); + const result = injectMemory(request, [], "openai"); + expect(result).toBe(request); + }); + + test("returns original request when memories is null-ish", () => { + const request = makeRequest(); + const result = injectMemory(request, null as unknown as Memory[], "openai"); + expect(result).toBe(request); + }); + + test("handles request with empty messages array", () => { + const request = makeRequest({ messages: [] }); + const memories = [makeMemory("fact")]; + const result = injectMemory(request, memories, "openai"); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0].role).toBe("system"); + }); + + test("handles multiple memories combined into single injection", () => { + const request = makeRequest(); + const memories = [makeMemory("fact A"), makeMemory("fact B"), makeMemory("fact C")]; + const result = injectMemory(request, memories, "openai"); + + expect(result.messages[0].role).toBe("system"); + expect(result.messages[0].content).toBe("Memory context: fact A\nfact B\nfact C"); + expect(result.messages).toHaveLength(2); + }); +}); + +describe("shouldInjectMemory", () => { + test("returns true when messages are present and enabled not set", () => { + const request = makeRequest(); + expect(shouldInjectMemory(request)).toBe(true); + }); + + test("returns false when config.enabled is false", () => { + const request = makeRequest(); + expect(shouldInjectMemory(request, { enabled: false })).toBe(false); + }); + + test("returns false when messages array is empty", () => { + const request = makeRequest({ messages: [] }); + expect(shouldInjectMemory(request)).toBe(false); + }); +}); diff --git a/src/lib/memory/__tests__/schemas.test.ts b/src/lib/memory/__tests__/schemas.test.ts new file mode 100644 index 0000000000..ca0d908df7 --- /dev/null +++ b/src/lib/memory/__tests__/schemas.test.ts @@ -0,0 +1,44 @@ +import { MemoryConfigSchema, MemoryCreateInputSchema, MemoryUpdateInputSchema } from "../schemas"; +import { z } from "zod"; + +describe("Memory Schemas", () => { + const validConfig = { + enabled: true, + maxTokens: 2048, + retrievalStrategy: "semantic", + autoSummarize: true, + persistAcrossModels: true, + retentionDays: 30, + scope: "apiKey", + }; + + const validCreateInput = { + type: "factual", + key: "user_preference", + content: "Dark mode enabled", + metadata: { source: "settings" }, + }; + + const validUpdateInput = { + content: "Updated content", + metadata: { updatedAt: new Date() }, + }; + + test("MemoryConfigSchema validation", () => { + expect(MemoryConfigSchema.parse(validConfig)).toBeDefined(); + const invalidConfig = { ...validConfig, maxTokens: -1 }; + expect(() => MemoryConfigSchema.parse(invalidConfig)).toThrow(); + }); + + test("MemoryCreateInputSchema validation", () => { + expect(MemoryCreateInputSchema.parse(validCreateInput)).toBeDefined(); + const invalidCreate = { ...validCreateInput, key: "" }; + expect(() => MemoryCreateInputSchema.parse(invalidCreate)).toThrow(); + }); + + test("MemoryUpdateInputSchema validation", () => { + expect(MemoryUpdateInputSchema.parse(validUpdateInput)).toBeDefined(); + const invalidUpdate = { key: "test" }; + expect(() => MemoryUpdateInputSchema.parse(invalidUpdate)).toThrow(); + }); +}); diff --git a/src/lib/memory/cache.ts b/src/lib/memory/cache.ts new file mode 100644 index 0000000000..af993a515b --- /dev/null +++ b/src/lib/memory/cache.ts @@ -0,0 +1,64 @@ +import { getDbInstance } from "../db/core"; + +interface MemoryCache { + key: string; + value: any; + timestamp: number; + ttl: number; +} + +class MemoryCachingLayer { + private cache: Map = new Map(); + private maxSize: number = 1000; + private defaultTtl: number = 300000; + + async get(key: string): Promise { + const entry = this.cache.get(key); + if (!entry) return null; + + if (Date.now() - entry.timestamp > entry.ttl) { + this.cache.delete(key); + return null; + } + + return entry.value; + } + + async set(key: string, value: any, ttl?: number): Promise { + if (this.cache.size >= this.maxSize) { + const oldest = Array.from(this.cache.entries()).sort( + (a, b) => a[1].timestamp - b[1].timestamp + )[0]; + this.cache.delete(oldest[0]); + } + + this.cache.set(key, { + key, + value, + timestamp: Date.now(), + ttl: ttl || this.defaultTtl, + }); + } + + async invalidate(pattern: string): Promise { + const regex = new RegExp(pattern); + for (const key of this.cache.keys()) { + if (regex.test(key)) { + this.cache.delete(key); + } + } + } + + async clear(): Promise { + this.cache.clear(); + } + + stats() { + return { + size: this.cache.size, + maxSize: this.maxSize, + }; + } +} + +export const memoryCache = new MemoryCachingLayer(); diff --git a/src/lib/memory/extraction.ts b/src/lib/memory/extraction.ts new file mode 100644 index 0000000000..b73de6af33 --- /dev/null +++ b/src/lib/memory/extraction.ts @@ -0,0 +1,180 @@ +/** + * Fact extraction from LLM responses. + * Parses text for user preferences, decisions, and patterns. + * Stores extracted facts asynchronously (non-blocking). + */ + +import { createMemory } from "./store"; +import { MemoryType } from "./types"; + +// ─── Pattern Definitions ──────────────────────────────────────────────────── + +/** Patterns indicating user preferences */ +const PREFERENCE_PATTERNS: RegExp[] = [ + /\bI\s+(?:really\s+)?prefer\s+(.+?)(?:\.|,|$)/gi, + /\bI\s+(?:really\s+)?like\s+(.+?)(?:\.|,|$)/gi, + /\bmy\s+(?:favorite|favourite)\s+(?:is|are)\s+(.+?)(?:\.|,|$)/gi, + /\bI\s+(?:don'?t|do\s+not)\s+like\s+(.+?)(?:\.|,|$)/gi, + /\bI\s+(?:hate|dislike|avoid)\s+(.+?)(?:\.|,|$)/gi, + /\bI\s+enjoy\s+(.+?)(?:\.|,|$)/gi, + /\bI\s+love\s+(.+?)(?:\.|,|$)/gi, +]; + +/** Patterns indicating user decisions */ +const DECISION_PATTERNS: RegExp[] = [ + /\bI'?(?:ll|will)\s+use\s+(.+?)(?:\.|,|$)/gi, + /\bI\s+chose\s+(.+?)(?:\.|,|$)/gi, + /\bI\s+(?:have\s+)?decided\s+(?:to\s+)?(.+?)(?:\.|,|$)/gi, + /\bI'?m\s+going\s+(?:to\s+)?(?:use|with|adopt)\s+(.+?)(?:\.|,|$)/gi, + /\bI\s+selected\s+(.+?)(?:\.|,|$)/gi, + /\bI\s+picked\s+(.+?)(?:\.|,|$)/gi, + /\bI\s+went\s+with\s+(.+?)(?:\.|,|$)/gi, +]; + +/** Patterns indicating user behavioral patterns */ +const PATTERN_PATTERNS: RegExp[] = [ + /\bI\s+usually\s+(.+?)(?:\.|,|$)/gi, + /\bI\s+always\s+(.+?)(?:\.|,|$)/gi, + /\bI\s+never\s+(.+?)(?:\.|,|$)/gi, + /\bI\s+typically\s+(.+?)(?:\.|,|$)/gi, + /\bI\s+tend\s+to\s+(.+?)(?:\.|,|$)/gi, + /\bI\s+(?:often|frequently|regularly)\s+(.+?)(?:\.|,|$)/gi, +]; + +// Maximum length for extracted content +const MAX_FACT_LENGTH = 500; +// Minimum content length to avoid noise +const MIN_FACT_LENGTH = 3; + +// ─── Types ────────────────────────────────────────────────────────────────── + +export interface ExtractedFact { + key: string; + content: string; + type: MemoryType; + category: "preference" | "decision" | "pattern"; +} + +// ─── Extraction Logic ──────────────────────────────────────────────────────── + +/** + * Sanitize a matched string: trim, collapse whitespace, cap length + */ +function sanitizeMatch(raw: string): string { + return raw.trim().replace(/\s+/g, " ").slice(0, MAX_FACT_LENGTH); +} + +/** + * Generate a stable key for a fact (category + first 40 chars of content) + */ +function factKey(category: string, content: string): string { + const slug = content + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .slice(0, 40) + .replace(/_+$/, ""); + return `${category}:${slug}`; +} + +/** + * Run a set of patterns against text and collect extracted facts. + * Deduplicates by key within the batch. + */ +function runPatterns( + text: string, + patterns: RegExp[], + category: "preference" | "decision" | "pattern", + memoryType: MemoryType, + seen: Set +): ExtractedFact[] { + const facts: ExtractedFact[] = []; + + for (const pattern of patterns) { + // Reset lastIndex for global regex + pattern.lastIndex = 0; + + let match: RegExpExecArray | null; + while ((match = pattern.exec(text)) !== null) { + const raw = match[1]; + if (!raw) continue; + + const content = sanitizeMatch(raw); + if (content.length < MIN_FACT_LENGTH) continue; + + const key = factKey(category, content); + if (seen.has(key)) continue; + seen.add(key); + + facts.push({ key, content, type: memoryType, category }); + } + + // Reset again after use + pattern.lastIndex = 0; + } + + return facts; +} + +/** + * Extract facts from a text string. + * Returns structured fact objects without storing them. + * Safe to call from tests without a DB. + */ +export function extractFactsFromText(text: string): ExtractedFact[] { + if (!text || typeof text !== "string") return []; + + const seen = new Set(); + const facts: ExtractedFact[] = []; + + // Preferences → factual memory + facts.push(...runPatterns(text, PREFERENCE_PATTERNS, "preference", MemoryType.FACTUAL, seen)); + + // Decisions → episodic memory (tied to a moment in time) + facts.push(...runPatterns(text, DECISION_PATTERNS, "decision", MemoryType.EPISODIC, seen)); + + // Patterns → factual memory (persistent behavioral facts) + facts.push(...runPatterns(text, PATTERN_PATTERNS, "pattern", MemoryType.FACTUAL, seen)); + + return facts; +} + +/** + * Extract facts from an LLM response and store them asynchronously. + * Non-blocking: fires-and-forgets via setImmediate. + * Does NOT extract from tool call results (tool_calls check). + * + * @param response - The LLM response text to parse + * @param apiKeyId - API key owning this memory + * @param sessionId - Session context for the memory + */ +export function extractFacts(response: string, apiKeyId: string, sessionId: string): void { + if (!response || !apiKeyId || !sessionId) return; + + // Non-blocking: schedule after current event loop tick + setImmediate(() => { + const facts = extractFactsFromText(response); + if (facts.length === 0) return; + + // Store each fact, swallow errors to never block the response pipeline + for (const fact of facts) { + createMemory({ + apiKeyId, + sessionId, + type: fact.type, + key: fact.key, + content: fact.content, + metadata: { + category: fact.category, + extractedAt: new Date().toISOString(), + source: "llm_response", + }, + expiresAt: null, + }).catch((err) => { + // Silent: extraction must never affect response delivery + if (process.env.NODE_ENV !== "test") { + console.warn("[memory:extraction] Failed to store fact:", err?.message); + } + }); + } + }); +} diff --git a/src/lib/memory/injection.ts b/src/lib/memory/injection.ts new file mode 100644 index 0000000000..ff5f40798f --- /dev/null +++ b/src/lib/memory/injection.ts @@ -0,0 +1,104 @@ +/** + * Memory Injection — prepend retrieved memories into the request message list. + * + * Injection strategy: + * 1. If the provider supports system messages (most providers), inject as a + * leading system message so it takes effect without disrupting user turns. + * 2. Otherwise (fallback for providers that reject system role), inject as the + * first user message prefixed with the memory context label. + * + * Format: "Memory context: " + */ + +import { Memory } from "./types"; + +export interface ChatMessage { + role: "system" | "user" | "assistant"; + content: string; + name?: string; +} + +export interface ChatRequest { + model: string; + messages: ChatMessage[]; + system?: string; + temperature?: number; + max_tokens?: number; + stream?: boolean; + [key: string]: unknown; +} + +/** + * Providers known NOT to support a top-level system-role message. + * These receive memories injected as the first user message instead. + */ +const PROVIDERS_WITHOUT_SYSTEM_MESSAGE = new Set(["o1", "o1-mini", "o1-preview"]); + +/** + * Returns true when the given provider accepts a system-role message. + * Falls back to true for unknown/null providers (safe default). + */ +export function providerSupportsSystemMessage(provider: string | null | undefined): boolean { + if (!provider) return true; + const normalized = provider.toLowerCase().trim(); + return !PROVIDERS_WITHOUT_SYSTEM_MESSAGE.has(normalized); +} + +/** + * Format memories into a single labeled context string. + * Format: "Memory context: \n..." + */ +export function formatMemoryContext(memories: Memory[]): string { + if (!memories || memories.length === 0) return ""; + + const content = memories + .map((m) => m.content.trim()) + .filter(Boolean) + .join("\n"); + + return content ? `Memory context: ${content}` : ""; +} + +/** + * Inject retrieved memories into the request message array. + * + * @param request - The chat completion request body + * @param memories - Memories retrieved for the current API key / session + * @param provider - Provider identifier used to choose injection strategy + * @returns A new request body with memories prepended to messages + */ +export function injectMemory( + request: ChatRequest, + memories: Memory[], + provider: string | null | undefined +): ChatRequest { + if (!memories || memories.length === 0) { + return request; + } + + const memoryText = formatMemoryContext(memories); + if (!memoryText) return request; + + const messages: ChatMessage[] = Array.isArray(request.messages) ? [...request.messages] : []; + + if (providerSupportsSystemMessage(provider)) { + // Strategy 1: inject as a leading system message. + // Prepending before any existing system messages keeps memory context + // accessible without overriding the caller's own system instructions. + const memorySystemMessage: ChatMessage = { role: "system", content: memoryText }; + return { ...request, messages: [memorySystemMessage, ...messages] }; + } else { + // Strategy 2 (fallback): inject as the first user message. + // Used for providers like o1-mini that reject the system role. + const memoryUserMessage: ChatMessage = { role: "user", content: memoryText }; + return { ...request, messages: [memoryUserMessage, ...messages] }; + } +} + +/** + * Returns true when memory injection should be attempted for this request. + */ +export function shouldInjectMemory(request: ChatRequest, config?: { enabled?: boolean }): boolean { + if (config?.enabled === false) return false; + return Array.isArray(request.messages) && request.messages.length > 0; +} diff --git a/src/lib/memory/retrieval.ts b/src/lib/memory/retrieval.ts new file mode 100644 index 0000000000..ce5ab7df51 --- /dev/null +++ b/src/lib/memory/retrieval.ts @@ -0,0 +1,99 @@ +import { getDbInstance } from "../db/core"; +import { Memory, MemoryConfig, MemoryType } from "./types"; +import { MemoryConfigSchema } from "./schemas"; + +/** + * Simple token estimation function (roughly 1 token per 4 characters) + */ +export function estimateTokens(text: string): number { + if (!text || typeof text !== "string") return 0; + return Math.ceil(text.length / 4); +} + +/** + * Retrieve memories with token budget enforcement + */ +export async function retrieveMemories( + apiKeyId: string, + config: Partial = {} +): Promise { + // Validate and normalize config + const normalizedConfig = MemoryConfigSchema.parse({ + enabled: true, + maxTokens: 2000, + retrievalStrategy: "recent", + autoSummarize: false, + persistAcrossModels: false, + retentionDays: 30, + scope: "apiKey", + ...config, + }); + + const maxTokens = Math.min(Math.max(normalizedConfig.maxTokens, 100), 8000); + const strategy = normalizedConfig.retrievalStrategy; + + const db = getDbInstance(); + const memories: Memory[] = []; + let totalTokens = 0; + + // Build base query + let query = "SELECT * FROM memory WHERE apiKeyId = ?"; + const params: any[] = [apiKeyId]; + + // Add ordering based on strategy + switch (strategy) { + case "semantic": + // For now, semantic search is same as exact (FTS5 not implemented yet) + query += " ORDER BY createdAt DESC"; + break; + case "hybrid": + // Hybrid is same as exact for now + query += " ORDER BY createdAt DESC"; + break; + case "exact": + default: + query += " ORDER BY createdAt DESC"; + } + + // Add limit for performance + query += " LIMIT 100"; + + // Execute query + const stmt = db.prepare(query); + const rows = stmt.all(...params); + + // Process memories until budget exceeded + for (const row of rows) { + const memory: Memory = { + id: String((row as any).id), + apiKeyId: String((row as any).apiKeyId), + sessionId: String((row as any).sessionId), + type: (row as any).type as MemoryType, + key: String((row as any).key), + content: String((row as any).content), + metadata: JSON.parse(String((row as any).metadata)), + createdAt: new Date(String((row as any).createdAt)), + updatedAt: new Date(String((row as any).updatedAt)), + expiresAt: (row as any).expiresAt ? new Date(String((row as any).expiresAt)) : null, + }; + + // Estimate tokens for this memory + const memoryTokens = estimateTokens(memory.content); + + // Check if adding this memory would exceed budget + if (totalTokens + memoryTokens > maxTokens) { + // If we haven't added any memories yet, add this one anyway + if (memories.length === 0) { + memories.push(memory); + totalTokens += memoryTokens; + } + break; + } + + // Add memory to results + memories.push(memory); + totalTokens += memoryTokens; + } + + return memories; +} diff --git a/src/lib/memory/schemas.ts b/src/lib/memory/schemas.ts new file mode 100644 index 0000000000..0a1fb7bf03 --- /dev/null +++ b/src/lib/memory/schemas.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; +import { MemoryType } from "./types"; + +/** + * MemoryConfig schema - validates memory system configuration settings + */ +export const MemoryConfigSchema = z.object({ + enabled: z.boolean(), + maxTokens: z.number().int().positive(), + retrievalStrategy: z.enum(["exact", "semantic", "hybrid"]).optional(), + autoSummarize: z.boolean(), + persistAcrossModels: z.boolean(), + retentionDays: z.number().int().positive(), + scope: z.enum(["session", "apiKey", "global"]).optional(), +}); + +/** + * MemoryCreateInput schema - validates input for creating new memories + */ +export const MemoryCreateInputSchema = z + .object({ + type: z.nativeEnum(MemoryType), + key: z.string().min(1), + content: z.string().min(1), + metadata: z.record(z.unknown()).optional(), + }) + .strict(); + +/** + * MemoryUpdateInput schema - validates input for partially updating existing memories + */ +export const MemoryUpdateInputSchema = z + .object({ + type: z.nativeEnum(MemoryType).optional(), + key: z.string().min(1).optional(), + content: z.string().min(1).optional(), + metadata: z.record(z.unknown()).optional(), + }) + .strict(); + +/** + * Exported schema types for TypeScript references + */ +export type MemoryConfig = z.infer; +export type MemoryCreateInput = z.infer; +export type MemoryUpdateInput = z.infer; diff --git a/src/lib/memory/store.ts b/src/lib/memory/store.ts new file mode 100644 index 0000000000..6e93042dc7 --- /dev/null +++ b/src/lib/memory/store.ts @@ -0,0 +1,339 @@ +/** + * Memory store - CRUD operations with prepared statements and caching + */ + +import { getDbInstance, rowToCamel } from "../db/core"; +import { toRecord } from "../db/apiKeys"; +import { Memory, MemoryType } from "./types"; +import { CacheEntry } from "../db/apiKeys"; + +// Memory cache configuration +const MEMORY_CACHE_TTL = 300_000; // 5 minutes +const MEMORY_MAX_CACHE_SIZE = 10_000; + +// Cache for recently accessed memories +const _memoryCache = new Map>(); + +// Helper function to safely parse JSON strings +function parseJSON(value: unknown): Record { + if (!value || typeof value !== "string" || value.trim() === "") { + return {}; + } + try { + const parsed = JSON.parse(value); + return typeof parsed === "object" && parsed !== null ? parsed : {}; + } catch { + return {}; + } +} + +// Cache invalidation strategy +function invalidateMemoryCache(key: string) { + _memoryCache.delete(key); +} + +/** + * Memory cache management with size control + */ +function evictIfNeeded(cache: Map) { + if (cache.size > MEMORY_MAX_CACHE_SIZE) { + // Remove oldest entries first + const keysArray = Array.from(cache.keys()); + const entriesToRemove = Math.floor(cache.size * 0.2); + for (let i = 0; i < entriesToRemove; i++) { + cache.delete(keysArray[i]); + } + } +} + +/** + * Get or compile regex for wildcard pattern + */ +function getWildcardRegex(pattern: string): RegExp { + // This function is copied from apiKeys.ts pattern + let regex = _regexCache.get(pattern); + if (!regex) { + const regexStr = pattern.replace(/\*/g, ".*"); + regex = new RegExp(`^${regexStr}$`); + _regexCache.set(pattern, regex); + // Prevent unbounded growth + if (_regexCache.size > 100) { + const firstKey = _regexCache.keys().next().value; + if (firstKey) _regexCache.delete(firstKey); + } + } + return regex; +} + +// Compiled regex cache for wildcard patterns +const _regexCache = new Map(); + +// Cache for memory validation (similar to apiKeys) +const _memoryValidationCache = new Map(); +const MEMORY_VALIDATION_CACHE_TTL = 60 * 1000; // 1 minute TTL + +/** + * Check if memory exists with caching + */ +async function memoryExists(id: string): Promise { + if (!id || typeof id !== "string") return false; + + const now = Date.now(); + + // Check cache first + const cached = _memoryValidationCache.get(id); + if (cached && now - cached.timestamp < MEMORY_VALIDATION_CACHE_TTL) { + return cached.exists; + } + + const db = getDbInstance(); + const stmt = db.prepare("SELECT 1 FROM memory WHERE id = ?"); + const row = stmt.get(id); + const exists = !!row; + + // Cache the result to prevent cache pollution + if (exists) { + _memoryValidationCache.set(id, { exists: true, timestamp: now }); + } + + return exists; +} + +/** + * Create a new memory entry + */ +export async function createMemory( + memory: Omit +): Promise { + const db = getDbInstance(); + const id = crypto.randomUUID(); + const now = new Date().toISOString(); + + const stmt = db.prepare( + "INSERT INTO memory (id, apiKeyId, sessionId, type, key, content, metadata, createdAt, updatedAt, expiresAt) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + ); + + stmt.run( + id, + memory.apiKeyId, + memory.sessionId, + memory.type, + memory.key, + memory.content, + JSON.stringify(memory.metadata), + now, + now, + memory.expiresAt?.toISOString() ?? null + ); + + const createdMemory: Memory = { + id, + apiKeyId: memory.apiKeyId, + sessionId: memory.sessionId, + type: memory.type, + key: memory.key, + content: memory.content, + metadata: memory.metadata, + createdAt: new Date(now), + updatedAt: new Date(now), + expiresAt: memory.expiresAt ?? null, + }; + + // Cache the newly created memory + invalidateMemoryCache(id); + evictIfNeeded(_memoryCache); + _memoryCache.set(id, { value: createdMemory, timestamp: Date.now() }); + + return createdMemory; +} + +/** + * Get a memory by ID + */ +export async function getMemory(id: string): Promise { + if (!id || typeof id !== "string") return null; + + // Check cache first + const cached = _memoryCache.get(id); + if (cached && Date.now() - cached.timestamp < MEMORY_CACHE_TTL) { + return cached.value; + } + + const db = getDbInstance(); + const stmt = db.prepare("SELECT * FROM memory WHERE id = ?"); + const row = stmt.get(id); + + if (!row) { + // Cache negative result briefly to prevent repeated DB hits + evictIfNeeded(_memoryCache); + _memoryCache.set(id, { value: null, timestamp: Date.now() }); + return null; + } + + const memory: Memory = { + id: String(row.id), + apiKeyId: String(row.apiKeyId), + sessionId: String(row.sessionId), + type: row.type as MemoryType, + key: String(row.key), + content: String(row.content), + metadata: parseJSON(row.metadata), + createdAt: new Date(String(row.createdAt)), + updatedAt: new Date(String(row.updatedAt)), + expiresAt: row.expiresAt ? new Date(String(row.expiresAt)) : null, + }; + + // Cache the result + evictIfNeeded(_memoryCache); + _memoryCache.set(id, { value: memory, timestamp: Date.now() }); + + return memory; +} + +/** + * Update a memory entry + */ +export async function updateMemory( + id: string, + updates: Partial> +): Promise { + if (!id || typeof id !== "string") return false; + + const db = getDbInstance(); + const now = new Date().toISOString(); + + // Build dynamic update query + const fields: string[] = []; + const values: any[] = []; + + if (updates.type !== undefined) { + fields.push("type = ?"); + values.push(updates.type); + } + if (updates.key !== undefined) { + fields.push("key = ?"); + values.push(updates.key); + } + if (updates.content !== undefined) { + fields.push("content = ?"); + values.push(updates.content); + } + if (updates.metadata !== undefined) { + fields.push("metadata = ?"); + values.push(JSON.stringify(updates.metadata)); + } + if (updates.expiresAt !== undefined) { + fields.push("expiresAt = ?"); + values.push(updates.expiresAt?.toISOString() ?? null); + } + + // Always update the updatedAt timestamp + fields.push("updatedAt = ?"); + values.push(now); + + if (fields.length === 0) { + return false; // No updates to apply + } + + values.push(id); // For WHERE clause + + const stmt = db.prepare(`UPDATE memory SET ${fields.join(", ")} WHERE id = ?`); + + const result = stmt.run(...values); + + if (result.changes === 0) { + return false; + } + + // Invalidate cache for this memory + invalidateMemoryCache(id); + + return true; +} + +/** + * Delete a memory by ID + */ +export async function deleteMemory(id: string): Promise { + if (!id || typeof id !== "string") return false; + + const db = getDbInstance(); + const stmt = db.prepare("DELETE FROM memory WHERE id = ?"); + const result = stmt.run(id); + + if (result.changes === 0) { + return false; + } + + // Invalidate cache for this memory + invalidateMemoryCache(id); + + return true; +} + +/** + * List memories with optional filtering + */ +export async function listMemories(filters: { + apiKeyId?: string; + type?: MemoryType; + sessionId?: string; + limit?: number; + offset?: number; +}): Promise { + const db = getDbInstance(); + + // Build dynamic query + let query = "SELECT * FROM memory"; + const params: any[] = []; + const whereClauses: string[] = []; + + if (filters.apiKeyId) { + whereClauses.push("apiKeyId = ?"); + params.push(filters.apiKeyId); + } + + if (filters.type) { + whereClauses.push("type = ?"); + params.push(filters.type); + } + + if (filters.sessionId) { + whereClauses.push("sessionId = ?"); + params.push(filters.sessionId); + } + + if (whereClauses.length > 0) { + query += " WHERE " + whereClauses.join(" AND "); + } + + // Add ordering and pagination + query += " ORDER BY createdAt DESC"; + + if (filters.limit !== undefined) { + query += " LIMIT ?"; + params.push(filters.limit); + } + + if (filters.offset !== undefined) { + query += " OFFSET ?"; + params.push(filters.offset); + } + + const stmt = db.prepare(query); + const rows = stmt.all(...params); + + return rows.map((row) => ({ + id: String(row.id), + apiKeyId: String(row.apiKeyId), + sessionId: String(row.sessionId), + type: row.type as MemoryType, + key: String(row.key), + content: String(row.content), + metadata: parseJSON(row.metadata), + createdAt: new Date(String(row.createdAt)), + updatedAt: new Date(String(row.updatedAt)), + expiresAt: row.expiresAt ? new Date(String(row.expiresAt)) : null, + })); +} diff --git a/src/lib/memory/summarization.ts b/src/lib/memory/summarization.ts new file mode 100644 index 0000000000..78277cb1d5 --- /dev/null +++ b/src/lib/memory/summarization.ts @@ -0,0 +1,99 @@ +import { Memory, MemoryType } from "./types"; +import { getDbInstance } from "../db/core"; + +export interface SummarizationResult { + originalCount: number; + summarizedCount: number; + tokensSaved: number; +} + +export async function summarizeMemories( + apiKeyId: string, + sessionId?: string, + maxTokens: number = 4000 +): Promise { + const db = getDbInstance(); + + const whereClause = sessionId + ? "WHERE api_key_id = ? AND session_id = ?" + : "WHERE api_key_id = ?"; + const params = sessionId ? [apiKeyId, sessionId] : [apiKeyId]; + + const memories = db + .prepare(`SELECT * FROM memories ${whereClause} ORDER BY created_at DESC`) + .all(...params) as any[]; + + if (memories.length === 0) { + return { originalCount: 0, summarizedCount: 0, tokensSaved: 0 }; + } + + let totalTokens = 0; + const toSummarize: Memory[] = []; + const toKeep: Memory[] = []; + + for (const mem of memories) { + const tokens = estimateTokens(mem.content); + if (totalTokens + tokens <= maxTokens) { + toKeep.push({ + id: mem.id, + apiKeyId: mem.api_key_id, + sessionId: mem.session_id, + type: mem.type as MemoryType, + key: mem.key, + content: mem.content, + metadata: mem.metadata ? JSON.parse(mem.metadata) : {}, + createdAt: new Date(mem.created_at), + updatedAt: new Date(mem.updated_at), + expiresAt: mem.expires_at ? new Date(mem.expires_at) : null, + }); + totalTokens += tokens; + } else { + toSummarize.push({ + id: mem.id, + apiKeyId: mem.api_key_id, + sessionId: mem.session_id, + type: mem.type as MemoryType, + key: mem.key, + content: mem.content, + metadata: mem.metadata ? JSON.parse(mem.metadata) : {}, + createdAt: new Date(mem.created_at), + updatedAt: new Date(mem.updated_at), + expiresAt: mem.expires_at ? new Date(mem.expires_at) : null, + }); + } + } + + const summarizedCount = toSummarize.length; + let tokensSaved = 0; + + for (const mem of toSummarize) { + const summary = generateSummary(mem.content); + const oldTokens = estimateTokens(mem.content); + const newTokens = estimateTokens(summary); + tokensSaved += oldTokens - newTokens; + + db.prepare("UPDATE memories SET content = ?, updated_at = ? WHERE id = ?").run( + summary, + new Date().toISOString(), + mem.id + ); + } + + return { + originalCount: memories.length, + summarizedCount, + tokensSaved, + }; +} + +function estimateTokens(text: string): number { + return Math.ceil(text.length / 4); +} + +function generateSummary(content: string): string { + const sentences = content.split(/[.!?]+/).filter((s) => s.trim().length > 0); + if (sentences.length <= 3) { + return content; + } + return sentences.slice(0, 3).join(". ") + "."; +} diff --git a/src/lib/memory/types.ts b/src/lib/memory/types.ts new file mode 100644 index 0000000000..f5f744f571 --- /dev/null +++ b/src/lib/memory/types.ts @@ -0,0 +1,41 @@ +// Memory system type definitions for OmniRoute +// These types support the memory management system for AI agents + +/** + * Memory types for AI agent memory management system + */ +export enum MemoryType { + FACTUAL = "factual", + EPISODIC = "episodic", + PROCEDURAL = "procedural", + SEMANTIC = "semantic", +} + +/** + * Memory interface representing individual memory entries + */ +export interface Memory { + id: string; + apiKeyId: string; + sessionId: string; + type: MemoryType; + key: string; + content: string; + metadata: Record; + createdAt: Date; + updatedAt: Date; + expiresAt: Date | null; +} + +/** + * Memory configuration interface for memory system settings + */ +export interface MemoryConfig { + enabled: boolean; + maxTokens: number; + retrievalStrategy: "exact" | "semantic" | "hybrid"; + autoSummarize: boolean; + persistAcrossModels: boolean; + retentionDays: number; + scope: "session" | "apiKey" | "global"; +} diff --git a/src/lib/skills/__tests__/integration.test.ts b/src/lib/skills/__tests__/integration.test.ts new file mode 100644 index 0000000000..8d66aa444f --- /dev/null +++ b/src/lib/skills/__tests__/integration.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { retrieveMemories } from "../../memory/retrieval"; +import { createMemory, deleteMemory } from "../../memory/store"; +import { injectSkills } from "../injection"; +import { skillRegistry } from "../registry"; +import { skillExecutor } from "../executor"; + +describe("Memory + Skills Integration", () => { + const apiKeyId = "test-api-key"; + + it("should retrieve and inject memories", async () => { + await createMemory({ + apiKeyId, + type: "factual" as any, + key: "test-key", + content: "Test memory content", + }); + + const config = { + enabled: true, + maxTokens: 2000, + retrievalStrategy: "exact" as const, + autoSummarize: false, + persistAcrossModels: false, + retentionDays: 30, + scope: "apiKey" as const, + }; + + const memories = await retrieveMemories(apiKeyId, config); + expect(memories).toBeDefined(); + expect(Array.isArray(memories)).toBe(true); + }); + + it("should register and list skills", async () => { + const skill = await skillRegistry.register({ + name: "test-skill", + version: "1.0.0", + description: "Test skill", + schema: { input: {}, output: {} }, + handler: "echo", + apiKeyId, + }); + + const skills = skillRegistry.list(apiKeyId); + expect(skills.length).toBeGreaterThan(0); + }); +}); diff --git a/src/lib/skills/a2a.ts b/src/lib/skills/a2a.ts new file mode 100644 index 0000000000..9cf9e25496 --- /dev/null +++ b/src/lib/skills/a2a.ts @@ -0,0 +1,34 @@ +export const a2aMemorySkill = { + name: "memory_aware_routing", + version: "1.0.0", + description: "A2A skill for memory-aware request routing", + schema: { + input: { + type: "object", + properties: { + task: { type: "string" }, + contextRequired: { type: "boolean" }, + }, + required: ["task"], + }, + output: { + type: "object", + properties: { + recommendedProvider: { type: "string" }, + reason: { type: "string" }, + }, + }, + }, + handler: async (input: any, context: any) => { + const { task, contextRequired = false } = input; + return { + recommendedProvider: "auto", + reason: "Memory-aware routing requires memories to be loaded", + contextUsed: contextRequired, + }; + }, +}; + +export function registerA2ASkill(registry: any): void { + registry.registerHandler("memory_aware_routing", a2aMemorySkill.handler); +} diff --git a/src/lib/skills/builtin/browser.ts b/src/lib/skills/builtin/browser.ts new file mode 100644 index 0000000000..57b73cee33 --- /dev/null +++ b/src/lib/skills/builtin/browser.ts @@ -0,0 +1,35 @@ +import { SkillHandler } from "../types"; + +export const browserSkill: SkillHandler = async (input, context) => { + const { action, ...params } = input as { + action: "navigate" | "click" | "type" | "screenshot" | "extract"; + url?: string; + selector?: string; + text?: string; + }; + + switch (action) { + case "navigate": + return { success: true, action: "navigate", url: params.url, stub: true }; + case "click": + return { success: true, action: "click", selector: params.selector, stub: true }; + case "type": + return { + success: true, + action: "type", + selector: params.selector, + text: params.text, + stub: true, + }; + case "screenshot": + return { success: true, action: "screenshot", stub: true }; + case "extract": + return { success: true, action: "extract", selector: params.selector, data: {}, stub: true }; + default: + throw new Error(`Unknown action: ${action}`); + } +}; + +export function registerBrowserSkill(executor: any): void { + executor.registerHandler("browser", browserSkill); +} diff --git a/src/lib/skills/builtins.ts b/src/lib/skills/builtins.ts new file mode 100644 index 0000000000..969846a80f --- /dev/null +++ b/src/lib/skills/builtins.ts @@ -0,0 +1,68 @@ +import { SkillHandler } from "./types"; + +export const builtinSkills: Record = { + file_read: async (input, context) => { + const { path } = input as { path: string }; + if (!path || typeof path !== "string") { + throw new Error("Missing required field: path"); + } + return { success: true, path, content: "[File read stub]", context: context.apiKeyId }; + }, + + file_write: async (input, context) => { + const { path, content } = input as { path: string; content: string }; + if (!path || !content) { + throw new Error("Missing required fields: path, content"); + } + return { success: true, path, bytesWritten: content.length, context: context.apiKeyId }; + }, + + http_request: async (input, context) => { + const { url, method = "GET" } = input as { url: string; method?: string }; + if (!url) { + throw new Error("Missing required field: url"); + } + return { success: true, url, method, status: 200, context: context.apiKeyId }; + }, + + web_search: async (input, context) => { + const { query, limit = 10 } = input as { query: string; limit?: number }; + if (!query) { + throw new Error("Missing required field: query"); + } + return { + success: true, + query, + results: [{ title: "Stub result", url: "https://example.com", snippet: "Stub" }], + context: context.apiKeyId, + }; + }, + + eval_code: async (input, context) => { + const { code, language = "javascript" } = input as { code: string; language?: string }; + if (!code) { + throw new Error("Missing required field: code"); + } + return { success: true, language, output: "[Code execution stub]", context: context.apiKeyId }; + }, + + execute_command: async (input, context) => { + const { command, args = [] } = input as { command: string; args?: string[] }; + if (!command) { + throw new Error("Missing required field: command"); + } + return { + success: true, + command, + args, + output: "[Command execution stub]", + context: context.apiKeyId, + }; + }, +}; + +export function registerBuiltinSkills(executor: any): void { + for (const [name, handler] of Object.entries(builtinSkills)) { + executor.registerHandler(name, handler); + } +} diff --git a/src/lib/skills/custom.ts b/src/lib/skills/custom.ts new file mode 100644 index 0000000000..87ce1d65f6 --- /dev/null +++ b/src/lib/skills/custom.ts @@ -0,0 +1,41 @@ +import { skillRegistry } from "./registry"; +import { SkillCreateInputSchema } from "./schemas"; + +export const CustomSkillSchema = SkillCreateInputSchema; + +export async function registerCustomSkill(data: { + name: string; + version?: string; + description?: string; + schema: { input: Record; output: Record }; + handler: string; + apiKeyId: string; + enabled?: boolean; +}): Promise { + const parsed = SkillCreateInputSchema.parse(data); + return skillRegistry.register({ + ...parsed, + apiKeyId: data.apiKeyId, + }); +} + +export function validateCustomSkill(data: unknown): { valid: boolean; errors?: string[] } { + const result = CustomSkillSchema.safeParse(data); + if (result.success) { + return { valid: true }; + } + return { + valid: false, + errors: result.error.issues.map((e: any) => `${e.path.join(".")}: ${e.message}`), + }; +} + +export function listCustomSkills(apiKeyId: string): any[] { + return skillRegistry.list(apiKeyId); +} + +export async function deleteCustomSkill(skillId: string, apiKeyId: string): Promise { + const skill = skillRegistry.getSkill(skillId, apiKeyId); + if (!skill) return false; + return skillRegistry.unregister(skill.name, skill.version, apiKeyId); +} diff --git a/src/lib/skills/executor.ts b/src/lib/skills/executor.ts new file mode 100644 index 0000000000..ace6a0110f --- /dev/null +++ b/src/lib/skills/executor.ts @@ -0,0 +1,167 @@ +import { skillRegistry } from "./registry"; +import { SkillExecution, SkillStatus, SkillHandler } from "./types"; +import { getDbInstance } from "../db/core"; +import { randomUUID } from "crypto"; + +class SkillExecutor { + private static instance: SkillExecutor; + private handlers: Map = new Map(); + private timeout: number = 30000; + private maxRetries: number = 3; + + private constructor() {} + + static getInstance(): SkillExecutor { + if (!SkillExecutor.instance) { + SkillExecutor.instance = new SkillExecutor(); + } + return SkillExecutor.instance; + } + + registerHandler(name: string, handler: SkillHandler): void { + this.handlers.set(name, handler); + } + + setTimeout(ms: number): void { + this.timeout = ms; + } + + setMaxRetries(count: number): void { + this.maxRetries = count; + } + + async execute( + skillName: string, + input: Record, + context: { apiKeyId: string; sessionId?: string } + ): Promise { + const skill = skillRegistry.getSkill(skillName, context.apiKeyId); + if (!skill) { + throw new Error(`Skill not found: ${skillName}`); + } + + if (!skill.enabled) { + throw new Error(`Skill is disabled: ${skillName}`); + } + + const db = getDbInstance(); + const executionId = randomUUID(); + const startTime = Date.now(); + + try { + db.prepare( + `INSERT INTO skill_executions (id, skill_id, api_key_id, session_id, input, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run( + executionId, + skill.id, + context.apiKeyId, + context.sessionId || null, + JSON.stringify(input), + SkillStatus.RUNNING, + new Date().toISOString() + ); + + const handler = this.handlers.get(skill.handler); + if (!handler) { + throw new Error(`Handler not found: ${skill.handler}`); + } + + let output: Record | null = null; + let errorMessage: string | null = null; + let status = SkillStatus.SUCCESS; + + try { + const result = await this.executeWithTimeout( + handler(input, { apiKeyId: context.apiKeyId, sessionId: context.sessionId || "" }) + ); + output = result; + } catch (err) { + errorMessage = err instanceof Error ? err.message : String(err); + status = SkillStatus.ERROR; + } + + const durationMs = Date.now() - startTime; + + db.prepare( + `UPDATE skill_executions SET output = ?, status = ?, error_message = ?, duration_ms = ? WHERE id = ?` + ).run(output ? JSON.stringify(output) : null, status, errorMessage, durationMs, executionId); + + return { + id: executionId, + skillId: skill.id, + apiKeyId: context.apiKeyId, + sessionId: context.sessionId || "", + input, + output, + status, + errorMessage, + durationMs, + createdAt: new Date(), + }; + } catch (err) { + const durationMs = Date.now() - startTime; + const errorMessage = err instanceof Error ? err.message : String(err); + + db.prepare( + `UPDATE skill_executions SET status = ?, error_message = ?, duration_ms = ? WHERE id = ?` + ).run(SkillStatus.ERROR, errorMessage, durationMs, executionId); + + throw err; + } + } + + private async executeWithTimeout(promise: Promise): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error("Skill execution timed out")), this.timeout) + ), + ]); + } + + getExecution(executionId: string): SkillExecution | undefined { + const db = getDbInstance(); + const row = db.prepare("SELECT * FROM skill_executions WHERE id = ?").get(executionId) as any; + if (!row) return undefined; + + return { + id: row.id, + skillId: row.skill_id, + apiKeyId: row.api_key_id, + sessionId: row.session_id || "", + input: JSON.parse(row.input), + output: row.output ? JSON.parse(row.output) : null, + status: row.status as SkillStatus, + errorMessage: row.error_message, + durationMs: row.duration_ms, + createdAt: new Date(row.created_at), + }; + } + + listExecutions(apiKeyId?: string, limit: number = 50): SkillExecution[] { + const db = getDbInstance(); + const rows = apiKeyId + ? db + .prepare( + "SELECT * FROM skill_executions WHERE api_key_id = ? ORDER BY created_at DESC LIMIT ?" + ) + .all(apiKeyId, limit) + : db.prepare("SELECT * FROM skill_executions ORDER BY created_at DESC LIMIT ?").all(limit); + + return (rows as any[]).map((row) => ({ + id: row.id, + skillId: row.skill_id, + apiKeyId: row.api_key_id, + sessionId: row.session_id || "", + input: JSON.parse(row.input), + output: row.output ? JSON.parse(row.output) : null, + status: row.status as SkillStatus, + errorMessage: row.error_message, + durationMs: row.duration_ms, + createdAt: new Date(row.created_at), + })); + } +} + +export const skillExecutor = SkillExecutor.getInstance(); diff --git a/src/lib/skills/hybrid.ts b/src/lib/skills/hybrid.ts new file mode 100644 index 0000000000..c0833143ec --- /dev/null +++ b/src/lib/skills/hybrid.ts @@ -0,0 +1,67 @@ +export type ExecutionMode = "direct" | "sandbox" | "hybrid"; + +export interface HybridConfig { + defaultMode: ExecutionMode; + autoUpgrade: boolean; + maxDirectDuration: number; +} + +const defaultHybridConfig: HybridConfig = { + defaultMode: "direct", + autoUpgrade: true, + maxDirectDuration: 5000, +}; + +export class HybridExecutor { + private config: HybridConfig; + private directExecutor: any; + private sandboxRunner: any; + + constructor(config: Partial = {}) { + this.config = { ...defaultHybridConfig, ...config }; + } + + setConfig(config: Partial): void { + this.config = { ...this.config, ...config }; + } + + async execute(skillName: string, input: any, context: any): Promise { + const startTime = Date.now(); + const estimatedDuration = input.estimatedDuration || 0; + + if (this.shouldUseSandbox(estimatedDuration)) { + return this.executeInSandbox(skillName, input, context); + } + + try { + return await this.executeDirect(skillName, input, context); + } catch (err) { + if (this.config.autoUpgrade && this.isRetryable(err)) { + return this.executeInSandbox(skillName, input, context); + } + throw err; + } + } + + private shouldUseSandbox(estimatedDuration: number): boolean { + if (this.config.defaultMode === "sandbox") return true; + if (this.config.defaultMode === "direct") return false; + return estimatedDuration > this.config.maxDirectDuration; + } + + private async executeDirect(skillName: string, input: any, context: any): Promise { + return { mode: "direct", result: {} }; + } + + private async executeInSandbox(skillName: string, input: any, context: any): Promise { + return { mode: "sandbox", result: {} }; + } + + private isRetryable(err: any): boolean { + if (err?.message?.includes("timeout")) return true; + if (err?.message?.includes("memory")) return true; + return false; + } +} + +export const hybridExecutor = new HybridExecutor(); diff --git a/src/lib/skills/injection.ts b/src/lib/skills/injection.ts new file mode 100644 index 0000000000..134a1079b9 --- /dev/null +++ b/src/lib/skills/injection.ts @@ -0,0 +1,119 @@ +import { skillRegistry } from "./registry"; +import { Skill } from "./types"; + +interface OpenAITool { + type: string; + function: { + name: string; + description: string; + parameters: Record; + }; +} + +interface ClaudeTool { + name: string; + description: string; + input_schema: Record; +} + +interface GeminiTool { + name: string; + description: string; + parameters: Record; +} + +function skillToOpenAI(skill: Skill): OpenAITool { + return { + type: "function", + function: { + name: `${skill.name}@${skill.version}`, + description: skill.description, + parameters: skill.schema.input, + }, + }; +} + +function skillToClaude(skill: Skill): ClaudeTool { + return { + name: `${skill.name}@${skill.version}`, + description: skill.description, + input_schema: skill.schema.input, + }; +} + +function skillToGemini(skill: Skill): GeminiTool { + return { + name: `${skill.name}@${skill.version}`, + description: skill.description, + parameters: skill.schema.input, + }; +} + +export interface InjectionOptions { + provider: "openai" | "anthropic" | "google" | "other"; + existingTools?: unknown[]; + apiKeyId: string; +} + +export function injectSkills(options: InjectionOptions): unknown[] { + const skills = skillRegistry.list(options.apiKeyId).filter((s) => s.enabled); + + if (skills.length === 0) { + return options.existingTools || []; + } + + 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"; +} diff --git a/src/lib/skills/interception.ts b/src/lib/skills/interception.ts new file mode 100644 index 0000000000..510d9c7d12 --- /dev/null +++ b/src/lib/skills/interception.ts @@ -0,0 +1,135 @@ +import { skillExecutor } from "./executor"; +import { detectProvider } from "./injection"; + +interface ToolCall { + id: string; + name: string; + arguments: Record; +} + +interface ExecutionContext { + apiKeyId: string; + sessionId: string; + requestId: string; +} + +export async function interceptToolCalls( + toolCalls: ToolCall[], + context: ExecutionContext +): Promise<{ id: string; result: unknown }[]> { + const results = await Promise.all( + toolCalls.map(async (call) => { + try { + const [name, version] = call.name.includes("@") + ? call.name.split("@") + : [call.name, "latest"]; + + const skillName = version === "latest" ? name : `${name}@${version}`; + + const execution = await skillExecutor.execute(skillName, call.arguments, { + apiKeyId: context.apiKeyId, + sessionId: context.sessionId, + }); + + return { + id: call.id, + result: execution.output, + }; + } catch (err) { + return { + id: call.id, + result: { error: err instanceof Error ? err.message : String(err) }, + }; + } + }) + ); + + return results; +} + +export function extractToolCalls(response: any, modelId: string): ToolCall[] { + const provider = detectProvider(modelId); + + switch (provider) { + case "openai": + return (response.tool_calls || []).map((tc: any) => ({ + id: tc.id || `call_${Date.now()}`, + name: tc.function?.name || "", + arguments: parseArguments(tc.function?.arguments || "{}"), + })); + + case "anthropic": + return (response.content || []) + .filter((c: any) => c.type === "tool_use") + .map((tc: any) => ({ + id: tc.id, + name: tc.name, + arguments: tc.input || {}, + })); + + case "google": + return (response.functionCalls || []).map((fc: any) => ({ + id: `call_${Date.now()}_${Math.random().toString(36).slice(2)}`, + name: fc.name, + arguments: fc.args || {}, + })); + + default: + return []; + } +} + +function parseArguments(args: string | Record): Record { + if (typeof args === "object") { + return args; + } + + try { + return JSON.parse(args); + } catch { + return {}; + } +} + +export async function handleToolCallExecution( + response: any, + modelId: string, + context: ExecutionContext +): Promise { + const toolCalls = extractToolCalls(response, modelId); + + if (toolCalls.length === 0) { + return response; + } + + const results = await interceptToolCalls(toolCalls, context); + + const provider = detectProvider(modelId); + + switch (provider) { + case "openai": + return { + ...response, + tool_results: results.map((r) => ({ + tool_call_id: r.id, + output: JSON.stringify(r.result), + })), + }; + + case "anthropic": + return { + ...response, + content: [ + ...response.content, + ...results.map((r) => ({ + type: "tool_result", + tool_use_id: r.id, + content: JSON.stringify(r.result), + })), + ], + }; + + default: + return response; + } +} diff --git a/src/lib/skills/registry.ts b/src/lib/skills/registry.ts new file mode 100644 index 0000000000..f8988a1005 --- /dev/null +++ b/src/lib/skills/registry.ts @@ -0,0 +1,211 @@ +import { Skill, SkillSchema } from "./types"; +import { SkillCreateInputSchema } from "./schemas"; +import { getDbInstance } from "../db/core"; +import { randomUUID } from "crypto"; + +class SkillRegistry { + private static instance: SkillRegistry; + private registeredSkills: Map = new Map(); + private versionCache: Map> = new Map(); + + private constructor() {} + + static getInstance(): SkillRegistry { + if (!SkillRegistry.instance) { + SkillRegistry.instance = new SkillRegistry(); + } + return SkillRegistry.instance; + } + + async register(skillData: { + name: string; + version?: string; + description?: string; + schema: SkillSchema; + handler: string; + enabled?: boolean; + apiKeyId: string; + }): Promise { + const parsed = SkillCreateInputSchema.parse(skillData); + const db = getDbInstance(); + const id = randomUUID(); + const now = new Date(); + + db.prepare( + `INSERT INTO skills (id, api_key_id, name, version, description, schema, handler, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + id, + skillData.apiKeyId, + parsed.name, + parsed.version, + parsed.description || null, + JSON.stringify(parsed.schema), + parsed.handler, + parsed.enabled ? 1 : 0, + now.toISOString(), + now.toISOString() + ); + + const skill: Skill = { + id, + apiKeyId: skillData.apiKeyId, + name: parsed.name, + version: parsed.version, + description: parsed.description || "", + schema: parsed.schema, + handler: parsed.handler, + enabled: parsed.enabled, + createdAt: now, + updatedAt: now, + }; + + this.registeredSkills.set(`${parsed.name}@${parsed.version}`, skill); + this.updateVersionCache(skill); + + return skill; + } + + async unregister(name: string, version?: string, apiKeyId?: string): Promise { + const db = getDbInstance(); + + if (version) { + const key = `${name}@${version}`; + const skill = this.registeredSkills.get(key); + if (skill && (!apiKeyId || skill.apiKeyId === apiKeyId)) { + db.prepare("DELETE FROM skills WHERE id = ?").run(skill.id); + this.registeredSkills.delete(key); + this.clearVersionCache(name); + return true; + } + } else { + const deleted = db + .prepare("DELETE FROM skills WHERE name = ? AND (? IS NULL OR api_key_id = ?)") + .run(name, apiKeyId || null, apiKeyId || null); + + if (deleted.changes > 0) { + const keysToDelete = Array.from(this.registeredSkills.keys()).filter((k) => + k.startsWith(`${name}@`) + ); + keysToDelete.forEach((k) => this.registeredSkills.delete(k)); + this.clearVersionCache(name); + return true; + } + } + + return false; + } + + list(apiKeyId?: string): Skill[] { + if (apiKeyId) { + return Array.from(this.registeredSkills.values()).filter((s) => s.apiKeyId === apiKeyId); + } + return Array.from(this.registeredSkills.values()); + } + + getSkill(name: string, apiKeyId?: string): Skill | undefined { + return this.registeredSkills.get(name); + } + + getSkillVersions(name: string): Skill[] { + const cached = this.versionCache.get(name); + if (!cached) return []; + return Array.from(cached.values()).sort((a, b) => this.compareVersions(b.version, a.version)); + } + + resolveVersion(name: string, constraint: string, apiKeyId?: string): Skill | undefined { + const versions = this.getSkillVersions(name); + if (versions.length === 0) return undefined; + + const operator = constraint.charAt(0); + const version = constraint.slice(1); + + switch (operator) { + case "^": + return versions.find((s) => this.satisfies(s.version, version, "^")); + case "~": + return versions.find((s) => this.satisfies(s.version, version, "~")); + case ">": + case ">=": + case "<": + case "<=": + case "==": + return versions.find((s) => this.satisfies(s.version, version, operator)); + default: + return versions.find((s) => s.version === constraint); + } + } + + private satisfies(version: string, base: string, operator: string): boolean { + const [baseMajor, baseMinor, basePatch] = base.split(".").map(Number); + const [verMajor, verMinor, verPatch] = version.split(".").map(Number); + + switch (operator) { + case "^": + return ( + verMajor === baseMajor && + (verMinor > baseMinor || (verMinor === baseMinor && verPatch >= basePatch)) + ); + case "~": + return verMajor === baseMajor && verMinor === baseMinor && verPatch >= basePatch; + case ">": + return this.compareVersions(version, base) > 0; + case ">=": + return this.compareVersions(version, base) >= 0; + case "<": + return this.compareVersions(version, base) < 0; + case "<=": + return this.compareVersions(version, base) <= 0; + case "==": + return version === base; + default: + return version === base; + } + } + + private compareVersions(a: string, b: string): number { + const [aMajor, aMinor, aPatch] = a.split(".").map(Number); + const [bMajor, bMinor, bPatch] = b.split(".").map(Number); + + if (aMajor !== bMajor) return aMajor - bMajor; + if (aMinor !== bMinor) return aMinor - bMinor; + return aPatch - bPatch; + } + + private updateVersionCache(skill: Skill): void { + if (!this.versionCache.has(skill.name)) { + this.versionCache.set(skill.name, new Map()); + } + this.versionCache.get(skill.name)!.set(skill.version, skill); + } + + private clearVersionCache(name: string): void { + this.versionCache.delete(name); + } + + async loadFromDatabase(apiKeyId?: string): Promise { + const db = getDbInstance(); + const rows = apiKeyId + ? db.prepare("SELECT * FROM skills WHERE api_key_id = ?").all(apiKeyId) + : db.prepare("SELECT * FROM skills").all(); + + for (const row of rows as any[]) { + const skill: Skill = { + id: row.id, + apiKeyId: row.api_key_id, + name: row.name, + version: row.version, + description: row.description || "", + schema: JSON.parse(row.schema), + handler: row.handler, + enabled: row.enabled === 1, + createdAt: new Date(row.created_at), + updatedAt: new Date(row.updated_at), + }; + this.registeredSkills.set(`${skill.name}@${skill.version}`, skill); + this.updateVersionCache(skill); + } + } +} + +export const skillRegistry = SkillRegistry.getInstance(); diff --git a/src/lib/skills/sandbox.ts b/src/lib/skills/sandbox.ts new file mode 100644 index 0000000000..3abefb8924 --- /dev/null +++ b/src/lib/skills/sandbox.ts @@ -0,0 +1,160 @@ +import { spawn, ChildProcess } from "child_process"; +import { randomUUID } from "crypto"; + +interface SandboxConfig { + cpuLimit: number; + memoryLimit: number; + timeout: number; + networkEnabled: boolean; + readOnly: boolean; +} + +interface SandboxResult { + id: string; + exitCode: number | null; + stdout: string; + stderr: string; + duration: number; + killed: boolean; +} + +const DEFAULT_CONFIG: SandboxConfig = { + cpuLimit: 100, + memoryLimit: 256, + timeout: 30000, + networkEnabled: false, + readOnly: true, +}; + +class SandboxRunner { + private static instance: SandboxRunner; + private runningContainers: Map = new Map(); + private config: SandboxConfig; + + private constructor(config: Partial = {}) { + this.config = { ...DEFAULT_CONFIG, ...config }; + } + + static getInstance(config?: Partial): SandboxRunner { + if (!SandboxRunner.instance) { + SandboxRunner.instance = new SandboxRunner(config); + } + return SandboxRunner.instance; + } + + setConfig(config: Partial): void { + this.config = { ...this.config, ...config }; + } + + async run( + image: string, + command: string[], + env: Record = {} + ): Promise { + const sandboxId = randomUUID(); + const startTime = Date.now(); + + const dockerArgs = [ + "run", + "--rm", + "--name", + `omniroute-sandbox-${sandboxId}`, + "--cpus", + `${this.config.cpuLimit / 1000}`, + "--memory", + `${this.config.memoryLimit}m`, + "--network", + this.config.networkEnabled ? "bridge" : "none", + "--read-only", + this.config.readOnly.toString(), + "--cap-add", + "SYS_TIME", + "--pids-limit", + "100", + image, + ...command, + ]; + + return new Promise((resolve) => { + const proc = spawn("docker", dockerArgs, { + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + }); + + this.runningContainers.set(sandboxId, proc); + + let stdout = ""; + let stderr = ""; + + proc.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + + proc.stderr?.on("data", (data) => { + stderr += data.toString(); + }); + + const timeoutId = setTimeout(() => { + this.kill(sandboxId); + }, this.config.timeout); + + proc.on("close", (code) => { + clearTimeout(timeoutId); + this.runningContainers.delete(sandboxId); + + resolve({ + id: sandboxId, + exitCode: code, + stdout, + stderr, + duration: Date.now() - startTime, + killed: code === null, + }); + }); + + proc.on("error", (err) => { + clearTimeout(timeoutId); + this.runningContainers.delete(sandboxId); + + resolve({ + id: sandboxId, + exitCode: -1, + stdout, + stderr: err.message, + duration: Date.now() - startTime, + killed: false, + }); + }); + }); + } + + kill(sandboxId: string): boolean { + const proc = this.runningContainers.get(sandboxId); + if (proc) { + proc.kill("SIGTERM"); + this.runningContainers.delete(sandboxId); + spawn("docker", ["kill", `omniroute-sandbox-${sandboxId}`], { stdio: "ignore" }); + return true; + } + return false; + } + + killAll(): void { + for (const [id, proc] of this.runningContainers) { + proc.kill("SIGTERM"); + spawn("docker", ["kill", `omniroute-sandbox-${id}`], { stdio: "ignore" }); + } + this.runningContainers.clear(); + } + + isRunning(sandboxId: string): boolean { + return this.runningContainers.has(sandboxId); + } + + getRunningCount(): number { + return this.runningContainers.size; + } +} + +export const sandboxRunner = SandboxRunner.getInstance(); +export type { SandboxConfig, SandboxResult }; diff --git a/src/lib/skills/schemas.ts b/src/lib/skills/schemas.ts new file mode 100644 index 0000000000..6c4621beb9 --- /dev/null +++ b/src/lib/skills/schemas.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; +import { SkillStatus, SkillMode } from "./types"; + +export const SkillSchema = z.object({ + input: z.record(z.string(), z.unknown()), + output: z.record(z.string(), z.unknown()), +}); + +export const SkillCreateInputSchema = z + .object({ + name: z.string().min(1).max(100), + version: z + .string() + .regex(/^\d+\.\d+\.\d+$/) + .default("1.0.0"), + description: z.string().max(500).optional(), + schema: SkillSchema, + handler: z.string().min(1), + enabled: z.boolean().default(true), + }) + .strict(); + +export const SkillUpdateInputSchema = z + .object({ + name: z.string().min(1).max(100).optional(), + version: z + .string() + .regex(/^\d+\.\d+\.\d+$/) + .optional(), + description: z.string().max(500).optional(), + schema: SkillSchema.optional(), + handler: z.string().min(1).optional(), + enabled: z.boolean().optional(), + }) + .strict(); + +export const SkillConfigSchema = z.object({ + enabled: z.boolean(), + mode: z.nativeEnum(SkillMode), + allowedSkills: z.array(z.string()), + timeout: z.number().int().positive().default(30000), + maxRetries: z.number().int().min(0).default(3), +}); + +export type SkillCreateInput = z.infer; +export type SkillUpdateInput = z.infer; +export type SkillConfig = z.infer; diff --git a/src/lib/skills/types.ts b/src/lib/skills/types.ts new file mode 100644 index 0000000000..9bc6e6225d --- /dev/null +++ b/src/lib/skills/types.ts @@ -0,0 +1,57 @@ +export enum SkillStatus { + PENDING = "pending", + RUNNING = "running", + SUCCESS = "success", + ERROR = "error", + TIMEOUT = "timeout", +} + +export enum SkillMode { + AUTO = "auto", + MANUAL = "manual", + HYBRID = "hybrid", +} + +export interface SkillSchema { + input: Record; + output: Record; +} + +export interface Skill { + id: string; + apiKeyId: string; + name: string; + version: string; + description: string; + schema: SkillSchema; + handler: string; + enabled: boolean; + createdAt: Date; + updatedAt: Date; +} + +export interface SkillExecution { + id: string; + skillId: string; + apiKeyId: string; + sessionId: string; + input: Record; + output: Record | null; + status: SkillStatus; + errorMessage: string | null; + durationMs: number | null; + createdAt: Date; +} + +export interface SkillConfig { + enabled: boolean; + mode: SkillMode; + allowedSkills: string[]; + timeout: number; + maxRetries: number; +} + +export type SkillHandler = ( + input: Record, + context: { apiKeyId: string; sessionId: string } +) => Promise>; diff --git a/tests/unit/memory-extraction.test.mjs b/tests/unit/memory-extraction.test.mjs new file mode 100644 index 0000000000..9355d5fadf --- /dev/null +++ b/tests/unit/memory-extraction.test.mjs @@ -0,0 +1,169 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { extractFactsFromText, extractFacts } = await import("../../src/lib/memory/extraction.ts"); + +// ─── extractFactsFromText: Preferences ───────────────────────────────────── + +test("extractFactsFromText: detects 'I prefer' preference", () => { + const facts = extractFactsFromText("I prefer dark mode in my editor."); + const pref = facts.find((f) => f.category === "preference"); + assert.ok(pref, "Should extract a preference fact"); + assert.ok(pref.content.toLowerCase().includes("dark mode")); + assert.equal(pref.type, "factual"); +}); + +test("extractFactsFromText: detects 'I like' preference", () => { + const facts = extractFactsFromText("I like TypeScript over JavaScript."); + const pref = facts.find((f) => f.category === "preference"); + assert.ok(pref); + assert.ok(pref.content.toLowerCase().includes("typescript")); +}); + +test("extractFactsFromText: detects 'my favorite is' preference", () => { + const facts = extractFactsFromText("My favorite is VS Code for editing."); + const pref = facts.find((f) => f.category === "preference"); + assert.ok(pref); + assert.ok(pref.content.toLowerCase().includes("vs code")); +}); + +test("extractFactsFromText: detects negative preference (I don't like)", () => { + const facts = extractFactsFromText("I don't like JavaScript callbacks."); + const pref = facts.find((f) => f.category === "preference"); + assert.ok(pref); + assert.ok(pref.content.toLowerCase().includes("javascript callbacks")); +}); + +// ─── extractFactsFromText: Decisions ───────────────────────────────────────── + +test("extractFactsFromText: detects 'I'll use' decision", () => { + const facts = extractFactsFromText("I'll use PostgreSQL for this project."); + const dec = facts.find((f) => f.category === "decision"); + assert.ok(dec, "Should extract a decision fact"); + assert.ok(dec.content.toLowerCase().includes("postgresql")); + assert.equal(dec.type, "episodic"); +}); + +test("extractFactsFromText: detects 'I chose' decision", () => { + const facts = extractFactsFromText("I chose React for the frontend."); + const dec = facts.find((f) => f.category === "decision"); + assert.ok(dec); + assert.ok(dec.content.toLowerCase().includes("react")); +}); + +test("extractFactsFromText: detects 'I decided to' decision", () => { + const facts = extractFactsFromText("I decided to migrate to Docker."); + const dec = facts.find((f) => f.category === "decision"); + assert.ok(dec); + assert.ok(dec.content.toLowerCase().includes("migrate to docker")); +}); + +test("extractFactsFromText: detects 'I went with' decision", () => { + const facts = extractFactsFromText("I went with Tailwind for styling."); + const dec = facts.find((f) => f.category === "decision"); + assert.ok(dec); + assert.ok(dec.content.toLowerCase().includes("tailwind")); +}); + +// ─── extractFactsFromText: Patterns ───────────────────────────────────────── + +test("extractFactsFromText: detects 'I usually' pattern", () => { + const facts = extractFactsFromText("I usually start with tests first."); + const pat = facts.find((f) => f.category === "pattern"); + assert.ok(pat, "Should extract a pattern fact"); + assert.ok(pat.content.toLowerCase().includes("start with tests")); + assert.equal(pat.type, "factual"); +}); + +test("extractFactsFromText: detects 'I always' pattern", () => { + const facts = extractFactsFromText("I always use ESLint in my projects."); + const pat = facts.find((f) => f.category === "pattern"); + assert.ok(pat); + assert.ok(pat.content.toLowerCase().includes("eslint")); +}); + +test("extractFactsFromText: detects 'I never' pattern", () => { + const facts = extractFactsFromText("I never commit directly to main."); + const pat = facts.find((f) => f.category === "pattern"); + assert.ok(pat); + assert.ok(pat.content.toLowerCase().includes("commit directly to main")); +}); + +test("extractFactsFromText: detects 'I tend to' pattern", () => { + const facts = extractFactsFromText("I tend to use functional components."); + const pat = facts.find((f) => f.category === "pattern"); + assert.ok(pat); + assert.ok(pat.content.toLowerCase().includes("functional components")); +}); + +// ─── extractFactsFromText: Multiple facts ─────────────────────────────────── + +test("extractFactsFromText: extracts multiple facts from one response", () => { + const text = + "I prefer TypeScript. I'll use Next.js for this project. I usually write tests first."; + const facts = extractFactsFromText(text); + assert.ok(facts.length >= 3, `Expected at least 3 facts, got ${facts.length}`); + + const categories = facts.map((f) => f.category); + assert.ok(categories.includes("preference")); + assert.ok(categories.includes("decision")); + assert.ok(categories.includes("pattern")); +}); + +test("extractFactsFromText: deduplicates identical patterns", () => { + const text = "I prefer vim. I prefer vim."; + const facts = extractFactsFromText(text); + const prefs = facts.filter((f) => f.category === "preference" && f.content.includes("vim")); + assert.equal(prefs.length, 1, "Duplicate facts should be deduplicated"); +}); + +// ─── extractFactsFromText: Edge cases ─────────────────────────────────────── + +test("extractFactsFromText: returns empty array for empty string", () => { + assert.deepEqual(extractFactsFromText(""), []); +}); + +test("extractFactsFromText: returns empty array for null", () => { + assert.deepEqual(extractFactsFromText(null), []); +}); + +test("extractFactsFromText: returns empty array for unrelated text", () => { + const facts = extractFactsFromText("The sky is blue. Water is wet. 2 + 2 = 4."); + assert.deepEqual(facts, []); +}); + +test("extractFactsFromText: produces stable keys", () => { + const facts = extractFactsFromText("I prefer dark mode."); + assert.ok(facts.length > 0); + assert.ok( + facts[0].key.startsWith("preference:"), + `Key should start with category: ${facts[0].key}` + ); +}); + +test("extractFactsFromText: truncates very long matches", () => { + const longContent = "a".repeat(600); + const facts = extractFactsFromText(`I prefer ${longContent}.`); + if (facts.length > 0) { + assert.ok(facts[0].content.length <= 500, "Content should be capped at 500 chars"); + } +}); + +// ─── extractFacts: non-blocking behavior ─────────────────────────────────── + +test("extractFacts: returns immediately (non-blocking)", () => { + let called = false; + const start = Date.now(); + + extractFacts("I prefer dark mode.", "key-123", "session-456"); + + const elapsed = Date.now() - start; + assert.ok(elapsed < 50, `extractFacts should return in <50ms, took ${elapsed}ms`); +}); + +test("extractFacts: does not throw on empty inputs", () => { + assert.doesNotThrow(() => extractFacts("", "key-123", "session-456")); + assert.doesNotThrow(() => extractFacts("I prefer vim.", "", "session-456")); + assert.doesNotThrow(() => extractFacts("I prefer vim.", "key-123", "")); + assert.doesNotThrow(() => extractFacts(null, "key-123", "session-456")); +});