mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
Merge branch 'feat/memory-skill-injection-v2' into release/v3.4.2
# Conflicts: # src/app/(dashboard)/dashboard/settings/page.tsx
This commit is contained in:
@@ -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<typeof shouldInjectMemory>[0])) {
|
||||
try {
|
||||
const memories = await retrieveMemories(apiKeyInfo.id);
|
||||
if (memories.length > 0) {
|
||||
const injected = injectMemory(
|
||||
body as Parameters<typeof injectMemory>[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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
118
open-sse/mcp-server/tools/memoryTools.ts
Normal file
118
open-sse/mcp-server/tools/memoryTools.ts
Normal file
@@ -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<typeof MemorySearchSchema>) => {
|
||||
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<typeof MemoryAddSchema>) => {
|
||||
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<typeof MemoryClearSchema>) => {
|
||||
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`,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
120
open-sse/mcp-server/tools/skillTools.ts
Normal file
120
open-sse/mcp-server/tools/skillTools.ts
Normal file
@@ -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<typeof SkillListSchema>) => {
|
||||
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<typeof SkillEnableSchema>) => {
|
||||
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<typeof SkillExecuteSchema>) => {
|
||||
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,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
197
src/app/(dashboard)/dashboard/memory/page.tsx
Normal file
197
src/app/(dashboard)/dashboard/memory/page.tsx
Normal file
@@ -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<string, unknown>;
|
||||
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<Memory[]>([]);
|
||||
const [stats, setStats] = useState<MemoryStats>({
|
||||
totalEntries: 0,
|
||||
tokensUsed: 0,
|
||||
hitRate: 0,
|
||||
});
|
||||
const [filterType, setFilterType] = useState<string>("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 (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Memory Management</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleExport}>
|
||||
Export
|
||||
</Button>
|
||||
<Button variant="outline">Import</Button>
|
||||
<Button>Add Memory</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<div className="text-sm text-gray-500">Total Entries</div>
|
||||
<div className="text-2xl font-bold">{stats.totalEntries}</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<div className="text-sm text-gray-500">Tokens Used</div>
|
||||
<div className="text-2xl font-bold">{stats.tokensUsed.toLocaleString()}</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<div className="text-sm text-gray-500">Hit Rate</div>
|
||||
<div className="text-2xl font-bold">{(stats.hitRate * 100).toFixed(1)}%</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Memories</h2>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Search memories..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-64"
|
||||
/>
|
||||
<Select value={filterType} onChange={(e) => setFilterType(e.target.value)}>
|
||||
<option value="all">All Types</option>
|
||||
<option value="factual">Factual</option>
|
||||
<option value="episodic">Episodic</option>
|
||||
<option value="procedural">Procedural</option>
|
||||
<option value="semantic">Semantic</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-2 px-4">Type</th>
|
||||
<th className="text-left py-2 px-4">Key</th>
|
||||
<th className="text-left py-2 px-4">Content</th>
|
||||
<th className="text-left py-2 px-4">Created</th>
|
||||
<th className="text-left py-2 px-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredMemories.map((memory) => (
|
||||
<tr key={memory.id} className="border-b">
|
||||
<td className="py-2 px-4">
|
||||
<Badge variant={getTypeColor(memory.type) as any}>{memory.type}</Badge>
|
||||
</td>
|
||||
<td className="py-2 px-4 font-medium">{memory.key}</td>
|
||||
<td className="py-2 px-4 max-w-md truncate">{memory.content}</td>
|
||||
<td className="py-2 px-4">{new Date(memory.createdAt).toLocaleDateString()}</td>
|
||||
<td className="py-2 px-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDelete(memory.id)}>
|
||||
Delete
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<MemoryConfig>({
|
||||
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<MemoryConfig>) => {
|
||||
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 (
|
||||
<Card>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-violet-500/10 text-violet-500">
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
psychology
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{t("memorySkillsTitle")}</h3>
|
||||
<p className="text-sm text-text-muted">{t("memorySkillsDesc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-sm text-text-muted">{t("loading")}...</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Memory Settings */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div className="p-2 rounded-lg bg-violet-500/10 text-violet-500">
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
memory
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{t("memoryTitle")}</h3>
|
||||
<p className="text-sm text-text-muted">{t("memoryDesc")}</p>
|
||||
</div>
|
||||
{status === "saved" && (
|
||||
<span className="ml-auto text-xs font-medium text-emerald-500 flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[14px]">check_circle</span>{" "}
|
||||
{t("saved")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Enable toggle */}
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-surface/30 border border-border/30 mb-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t("memoryEnabled")}</p>
|
||||
<p className="text-xs text-text-muted mt-0.5">{t("memoryEnabledDesc")}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => save({ enabled: !config.enabled })}
|
||||
disabled={saving}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors ${
|
||||
config.enabled ? "bg-violet-500" : "bg-border"
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={config.enabled}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
||||
config.enabled ? "translate-x-5" : "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Memory config fields */}
|
||||
{config.enabled && (
|
||||
<>
|
||||
{/* Max tokens */}
|
||||
<div className="p-4 rounded-lg bg-surface/30 border border-border/30 mb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-sm font-medium">{t("maxTokens")}</p>
|
||||
<span className="text-sm font-mono tabular-nums text-violet-400">
|
||||
{config.maxTokens.toLocaleString()} {t("tokens")}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="16000"
|
||||
step="500"
|
||||
value={config.maxTokens}
|
||||
onChange={(e) => save({ maxTokens: parseInt(e.target.value) })}
|
||||
className="w-full accent-violet-500"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-text-muted mt-1">
|
||||
<span>{t("off")}</span>
|
||||
<span>4K</span>
|
||||
<span>8K</span>
|
||||
<span>16K</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Retention days */}
|
||||
<div className="p-4 rounded-lg bg-surface/30 border border-border/30 mb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-sm font-medium">{t("retentionDays")}</p>
|
||||
<span className="text-sm font-mono tabular-nums text-violet-400">
|
||||
{config.retentionDays} {t("days")}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="90"
|
||||
step="1"
|
||||
value={config.retentionDays}
|
||||
onChange={(e) => save({ retentionDays: parseInt(e.target.value) })}
|
||||
className="w-full accent-violet-500"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-text-muted mt-1">
|
||||
<span>1</span>
|
||||
<span>30</span>
|
||||
<span>60</span>
|
||||
<span>90</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Strategy selector */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{STRATEGIES.map((s) => (
|
||||
<button
|
||||
key={s.value}
|
||||
onClick={() => save({ strategy: s.value as "recent" | "semantic" | "hybrid" })}
|
||||
disabled={loading || saving}
|
||||
className={`flex flex-col items-start p-3 rounded-lg border text-left transition-all ${
|
||||
config.strategy === s.value
|
||||
? "border-violet-500/50 bg-violet-500/5 ring-1 ring-violet-500/20"
|
||||
: "border-border/50 hover:border-border hover:bg-surface/30"
|
||||
}`}
|
||||
>
|
||||
<p
|
||||
className={`text-sm font-medium ${config.strategy === s.value ? "text-violet-400" : ""}`}
|
||||
>
|
||||
{t(s.labelKey)}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-0.5 leading-relaxed">{t(s.descKey)}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Skills Settings (placeholder) */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-500">
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
handyman
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{t("skillsTitle")}</h3>
|
||||
<p className="text-sm text-text-muted">{t("skillsDesc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-surface/30 border border-border/30">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t("skillsEnabled")}</p>
|
||||
<p className="text-xs text-text-muted mt-0.5">{t("skillsEnabledDesc")}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => save({ skillsEnabled: !config.skillsEnabled })}
|
||||
disabled={saving}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors ${
|
||||
config.skillsEnabled ? "bg-amber-500" : "bg-border"
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={config.skillsEnabled}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
||||
config.skillsEnabled ? "translate-x-5" : "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-text-muted mt-3">{t("skillsComingSoon")}</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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" && <SystemStorageTab />}
|
||||
{activeTab === "general" && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<SystemStorageTab />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "appearance" && <AppearanceTab />}
|
||||
{activeTab === "appearance" && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<AppearanceTab />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "ai" && (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -87,6 +96,7 @@ export default function SettingsPage() {
|
||||
<SystemPromptTab />
|
||||
<CacheStatsCard />
|
||||
<CacheSettingsTab />
|
||||
<MemorySkillsTab />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
230
src/app/(dashboard)/dashboard/skills/page.tsx
Normal file
230
src/app/(dashboard)/dashboard/skills/page.tsx
Normal file
@@ -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<Skill[]>([]);
|
||||
const [executions, setExecutions] = useState<Execution[]>([]);
|
||||
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 (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-text-muted">{t("loading")}...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{t("title")}</h1>
|
||||
<p className="text-text-muted mt-1">{t("description")}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 border-b border-border">
|
||||
<button
|
||||
onClick={() => setActiveTab("skills")}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === "skills"
|
||||
? "border-violet-500 text-violet-400"
|
||||
: "border-transparent text-text-muted hover:text-text-main"
|
||||
}`}
|
||||
>
|
||||
{t("skillsTab")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("executions")}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === "executions"
|
||||
? "border-violet-500 text-violet-400"
|
||||
: "border-transparent text-text-muted hover:text-text-main"
|
||||
}`}
|
||||
>
|
||||
{t("executionsTab")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("sandbox")}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === "sandbox"
|
||||
? "border-violet-500 text-violet-400"
|
||||
: "border-transparent text-text-muted hover:text-text-main"
|
||||
}`}
|
||||
>
|
||||
{t("sandboxTab")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "skills" && (
|
||||
<div className="grid gap-4">
|
||||
{skills.length === 0 ? (
|
||||
<Card>
|
||||
<div className="text-center py-8 text-text-muted">{t("noSkills")}</div>
|
||||
</Card>
|
||||
) : (
|
||||
skills.map((skill) => (
|
||||
<Card key={skill.id}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold">{skill.name}</h3>
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-surface/50 text-text-muted">
|
||||
v{skill.version}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted mt-1">{skill.description}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleSkill(skill.id, skill.enabled)}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors ${
|
||||
skill.enabled ? "bg-violet-500" : "bg-border"
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={skill.enabled}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
||||
skill.enabled ? "translate-x-5" : "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "executions" && (
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-left text-sm text-text-muted border-b border-border">
|
||||
<th className="pb-3 font-medium">{t("skill")}</th>
|
||||
<th className="pb-3 font-medium">{t("status")}</th>
|
||||
<th className="pb-3 font-medium">{t("duration")}</th>
|
||||
<th className="pb-3 font-medium">{t("time")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{executions.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="py-8 text-center text-text-muted">
|
||||
{t("noExecutions")}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
executions.map((exec) => (
|
||||
<tr key={exec.id} className="border-b border-border/50">
|
||||
<td className="py-3 font-medium">{exec.skillName}</td>
|
||||
<td className="py-3">
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded ${
|
||||
exec.status === "success"
|
||||
? "bg-emerald-500/10 text-emerald-400"
|
||||
: exec.status === "error"
|
||||
? "bg-red-500/10 text-red-400"
|
||||
: "bg-amber-500/10 text-amber-400"
|
||||
}`}
|
||||
>
|
||||
{exec.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 text-text-muted">{exec.duration}ms</td>
|
||||
<td className="py-3 text-text-muted text-sm">
|
||||
{new Date(exec.createdAt).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === "sandbox" && (
|
||||
<div className="grid gap-4">
|
||||
<Card>
|
||||
<h3 className="font-semibold mb-4">{t("sandboxConfig")}</h3>
|
||||
<div className="grid gap-4">
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/30">
|
||||
<div>
|
||||
<p className="font-medium">{t("cpuLimit")}</p>
|
||||
<p className="text-xs text-text-muted">{t("cpuLimitDesc")}</p>
|
||||
</div>
|
||||
<span className="font-mono">100ms</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/30">
|
||||
<div>
|
||||
<p className="font-medium">{t("memoryLimit")}</p>
|
||||
<p className="text-xs text-text-muted">{t("memoryLimitDesc")}</p>
|
||||
</div>
|
||||
<span className="font-mono">256MB</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/30">
|
||||
<div>
|
||||
<p className="font-medium">{t("timeout")}</p>
|
||||
<p className="text-xs text-text-muted">{t("timeoutDesc")}</p>
|
||||
</div>
|
||||
<span className="font-mono">30s</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/30">
|
||||
<div>
|
||||
<p className="font-medium">{t("networkAccess")}</p>
|
||||
<p className="text-xs text-text-muted">{t("networkAccessDesc")}</p>
|
||||
</div>
|
||||
<span className="text-text-muted">{t("disabled")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
src/app/api/memory/[id]/route.ts
Normal file
36
src/app/api/memory/[id]/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
36
src/app/api/memory/route.ts
Normal file
36
src/app/api/memory/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
30
src/app/api/skills/[id]/route.ts
Normal file
30
src/app/api/skills/[id]/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
12
src/app/api/skills/executions/route.ts
Normal file
12
src/app/api/skills/executions/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
13
src/app/api/skills/route.ts
Normal file
13
src/app/api/skills/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
33
src/lib/benchmarks.ts
Normal file
33
src/lib/benchmarks.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export interface BenchmarkResult {
|
||||
name: string;
|
||||
duration: number;
|
||||
opsPerSecond: number;
|
||||
memory: number;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export async function runBenchmarks(): Promise<BenchmarkResult[]> {
|
||||
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");
|
||||
}
|
||||
22
src/lib/db/migrations/015_create_memories.sql
Normal file
22
src/lib/db/migrations/015_create_memories.sql
Normal file
@@ -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);
|
||||
37
src/lib/db/migrations/016_create_skills.sql
Normal file
37
src/lib/db/migrations/016_create_skills.sql
Normal file
@@ -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);
|
||||
206
src/lib/memory/__tests__/injection.test.ts
Normal file
206
src/lib/memory/__tests__/injection.test.ts
Normal file
@@ -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> = {}): 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> = {}): 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);
|
||||
});
|
||||
});
|
||||
44
src/lib/memory/__tests__/schemas.test.ts
Normal file
44
src/lib/memory/__tests__/schemas.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
64
src/lib/memory/cache.ts
Normal file
64
src/lib/memory/cache.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { getDbInstance } from "../db/core";
|
||||
|
||||
interface MemoryCache {
|
||||
key: string;
|
||||
value: any;
|
||||
timestamp: number;
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
class MemoryCachingLayer {
|
||||
private cache: Map<string, MemoryCache> = new Map();
|
||||
private maxSize: number = 1000;
|
||||
private defaultTtl: number = 300000;
|
||||
|
||||
async get(key: string): Promise<any | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const regex = new RegExp(pattern);
|
||||
for (const key of this.cache.keys()) {
|
||||
if (regex.test(key)) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
stats() {
|
||||
return {
|
||||
size: this.cache.size,
|
||||
maxSize: this.maxSize,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const memoryCache = new MemoryCachingLayer();
|
||||
180
src/lib/memory/extraction.ts
Normal file
180
src/lib/memory/extraction.ts
Normal file
@@ -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<string>
|
||||
): 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<string>();
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
104
src/lib/memory/injection.ts
Normal file
104
src/lib/memory/injection.ts
Normal file
@@ -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: <content>"
|
||||
*/
|
||||
|
||||
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: <content1>\n<content2>..."
|
||||
*/
|
||||
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;
|
||||
}
|
||||
99
src/lib/memory/retrieval.ts
Normal file
99
src/lib/memory/retrieval.ts
Normal file
@@ -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<MemoryConfig> = {}
|
||||
): Promise<Memory[]> {
|
||||
// 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;
|
||||
}
|
||||
46
src/lib/memory/schemas.ts
Normal file
46
src/lib/memory/schemas.ts
Normal file
@@ -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<typeof MemoryConfigSchema>;
|
||||
export type MemoryCreateInput = z.infer<typeof MemoryCreateInputSchema>;
|
||||
export type MemoryUpdateInput = z.infer<typeof MemoryUpdateInputSchema>;
|
||||
339
src/lib/memory/store.ts
Normal file
339
src/lib/memory/store.ts
Normal file
@@ -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<string, CacheEntry<Memory>>();
|
||||
|
||||
// Helper function to safely parse JSON strings
|
||||
function parseJSON(value: unknown): Record<string, unknown> {
|
||||
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<TKey, TValue>(cache: Map<TKey, TValue>) {
|
||||
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<string, RegExp>();
|
||||
|
||||
// Cache for memory validation (similar to apiKeys)
|
||||
const _memoryValidationCache = new Map<string, { exists: boolean; timestamp: number }>();
|
||||
const MEMORY_VALIDATION_CACHE_TTL = 60 * 1000; // 1 minute TTL
|
||||
|
||||
/**
|
||||
* Check if memory exists with caching
|
||||
*/
|
||||
async function memoryExists(id: string): Promise<boolean> {
|
||||
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<Memory, "id" | "createdAt" | "updatedAt">
|
||||
): Promise<Memory> {
|
||||
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<Memory | null> {
|
||||
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<Omit<Memory, "id" | "createdAt">>
|
||||
): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<Memory[]> {
|
||||
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,
|
||||
}));
|
||||
}
|
||||
99
src/lib/memory/summarization.ts
Normal file
99
src/lib/memory/summarization.ts
Normal file
@@ -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<SummarizationResult> {
|
||||
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(". ") + ".";
|
||||
}
|
||||
41
src/lib/memory/types.ts
Normal file
41
src/lib/memory/types.ts
Normal file
@@ -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<string, unknown>;
|
||||
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";
|
||||
}
|
||||
47
src/lib/skills/__tests__/integration.test.ts
Normal file
47
src/lib/skills/__tests__/integration.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
34
src/lib/skills/a2a.ts
Normal file
34
src/lib/skills/a2a.ts
Normal file
@@ -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);
|
||||
}
|
||||
35
src/lib/skills/builtin/browser.ts
Normal file
35
src/lib/skills/builtin/browser.ts
Normal file
@@ -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);
|
||||
}
|
||||
68
src/lib/skills/builtins.ts
Normal file
68
src/lib/skills/builtins.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { SkillHandler } from "./types";
|
||||
|
||||
export const builtinSkills: Record<string, SkillHandler> = {
|
||||
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);
|
||||
}
|
||||
}
|
||||
41
src/lib/skills/custom.ts
Normal file
41
src/lib/skills/custom.ts
Normal file
@@ -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<string, unknown>; output: Record<string, unknown> };
|
||||
handler: string;
|
||||
apiKeyId: string;
|
||||
enabled?: boolean;
|
||||
}): Promise<any> {
|
||||
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<boolean> {
|
||||
const skill = skillRegistry.getSkill(skillId, apiKeyId);
|
||||
if (!skill) return false;
|
||||
return skillRegistry.unregister(skill.name, skill.version, apiKeyId);
|
||||
}
|
||||
167
src/lib/skills/executor.ts
Normal file
167
src/lib/skills/executor.ts
Normal file
@@ -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<string, SkillHandler> = 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<string, unknown>,
|
||||
context: { apiKeyId: string; sessionId?: string }
|
||||
): Promise<SkillExecution> {
|
||||
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<string, unknown> | 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<T>(promise: Promise<T>): Promise<T> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, 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();
|
||||
67
src/lib/skills/hybrid.ts
Normal file
67
src/lib/skills/hybrid.ts
Normal file
@@ -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<HybridConfig> = {}) {
|
||||
this.config = { ...defaultHybridConfig, ...config };
|
||||
}
|
||||
|
||||
setConfig(config: Partial<HybridConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
|
||||
async execute(skillName: string, input: any, context: any): Promise<any> {
|
||||
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<any> {
|
||||
return { mode: "direct", result: {} };
|
||||
}
|
||||
|
||||
private async executeInSandbox(skillName: string, input: any, context: any): Promise<any> {
|
||||
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();
|
||||
119
src/lib/skills/injection.ts
Normal file
119
src/lib/skills/injection.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { skillRegistry } from "./registry";
|
||||
import { Skill } from "./types";
|
||||
|
||||
interface OpenAITool {
|
||||
type: string;
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
interface ClaudeTool {
|
||||
name: string;
|
||||
description: string;
|
||||
input_schema: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface GeminiTool {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
135
src/lib/skills/interception.ts
Normal file
135
src/lib/skills/interception.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { skillExecutor } from "./executor";
|
||||
import { detectProvider } from "./injection";
|
||||
|
||||
interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
if (typeof args === "object") {
|
||||
return args;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(args);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleToolCallExecution(
|
||||
response: any,
|
||||
modelId: string,
|
||||
context: ExecutionContext
|
||||
): Promise<any> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
211
src/lib/skills/registry.ts
Normal file
211
src/lib/skills/registry.ts
Normal file
@@ -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<string, Skill> = new Map();
|
||||
private versionCache: Map<string, Map<string, Skill>> = 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<Skill> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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();
|
||||
160
src/lib/skills/sandbox.ts
Normal file
160
src/lib/skills/sandbox.ts
Normal file
@@ -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<string, ChildProcess> = new Map();
|
||||
private config: SandboxConfig;
|
||||
|
||||
private constructor(config: Partial<SandboxConfig> = {}) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
}
|
||||
|
||||
static getInstance(config?: Partial<SandboxConfig>): SandboxRunner {
|
||||
if (!SandboxRunner.instance) {
|
||||
SandboxRunner.instance = new SandboxRunner(config);
|
||||
}
|
||||
return SandboxRunner.instance;
|
||||
}
|
||||
|
||||
setConfig(config: Partial<SandboxConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
|
||||
async run(
|
||||
image: string,
|
||||
command: string[],
|
||||
env: Record<string, string> = {}
|
||||
): Promise<SandboxResult> {
|
||||
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 };
|
||||
47
src/lib/skills/schemas.ts
Normal file
47
src/lib/skills/schemas.ts
Normal file
@@ -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<typeof SkillCreateInputSchema>;
|
||||
export type SkillUpdateInput = z.infer<typeof SkillUpdateInputSchema>;
|
||||
export type SkillConfig = z.infer<typeof SkillConfigSchema>;
|
||||
57
src/lib/skills/types.ts
Normal file
57
src/lib/skills/types.ts
Normal file
@@ -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<string, unknown>;
|
||||
output: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
output: Record<string, unknown> | 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<string, unknown>,
|
||||
context: { apiKeyId: string; sessionId: string }
|
||||
) => Promise<Record<string, unknown>>;
|
||||
169
tests/unit/memory-extraction.test.mjs
Normal file
169
tests/unit/memory-extraction.test.mjs
Normal file
@@ -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"));
|
||||
});
|
||||
Reference in New Issue
Block a user