fix settings persistence and cache analytics layout (#952)

This commit is contained in:
Randi
2026-04-03 09:17:30 -04:00
committed by GitHub
parent a0cfae214d
commit 1b873d3bad
13 changed files with 632 additions and 108 deletions
+20 -2
View File
@@ -99,6 +99,11 @@ import { generateRequestId } from "@/shared/utils/requestId";
import { normalizePayloadForLog } from "@/lib/logPayloads";
import { injectMemory, shouldInjectMemory } from "@/lib/memory/injection";
import { retrieveMemories } from "@/lib/memory/retrieval";
import {
DEFAULT_MEMORY_SETTINGS,
getMemorySettings,
toMemoryRetrievalConfig,
} from "@/lib/memory/settings";
import {
buildClaudeCodeCompatibleRequest,
isClaudeCodeCompatibleProvider,
@@ -714,9 +719,22 @@ export async function handleChatCore({
});
}
if (apiKeyInfo?.id && shouldInjectMemory(body as Parameters<typeof shouldInjectMemory>[0])) {
const memorySettings = apiKeyInfo?.id
? await getMemorySettings().catch(() => DEFAULT_MEMORY_SETTINGS)
: null;
if (
apiKeyInfo?.id &&
memorySettings &&
shouldInjectMemory(body as Parameters<typeof shouldInjectMemory>[0], {
enabled: memorySettings.enabled && memorySettings.maxTokens > 0,
})
) {
try {
const memories = await retrieveMemories(apiKeyInfo.id);
const memories = await retrieveMemories(
apiKeyInfo.id,
toMemoryRetrievalConfig(memorySettings)
);
if (memories.length > 0) {
const injected = injectMemory(
body as Parameters<typeof injectMemory>[0],
@@ -2,29 +2,55 @@
import { useEffect, useState } from "react";
import { Card } from "@/shared/components";
import { useTranslations } from "next-intl";
interface DiversityProviderStat {
share: number;
}
interface DiversityReport {
score: number;
providers: Record<string, DiversityProviderStat>;
windowSize: number;
ttlMs: number;
}
export default function DiversityScoreCard() {
const [data, setData] = useState<any>(null);
const [data, setData] = useState<DiversityReport | null>(null);
const [loading, setLoading] = useState(true);
const t = useTranslations("analytics");
useEffect(() => {
fetch("/api/analytics/diversity")
.then((res) => res.json())
.then((json) => {
setData(json);
setLoading(false);
})
.catch((err) => {
console.error(err);
setLoading(false);
});
let cancelled = false;
async function loadDiversity() {
try {
const res = await fetch("/api/analytics/diversity");
if (!res.ok) {
throw new Error(`Failed to fetch diversity analytics: ${res.status}`);
}
const json = (await res.json()) as DiversityReport;
if (!cancelled) {
setData(json);
}
} catch (error) {
console.error(error);
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void loadDiversity();
return () => {
cancelled = true;
};
}, []);
if (loading || !data) {
return (
<Card className="p-5 flex flex-col justify-center items-center h-full min-h-[200px]">
<Card className="p-5 flex flex-col justify-center items-center min-h-[220px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</Card>
);
@@ -47,40 +73,43 @@ export default function DiversityScoreCard() {
}
return (
<Card className="p-5 flex flex-col h-full bg-[var(--card-bg,#1e1e2e)] relative overflow-hidden group">
<div className="flex items-center gap-2 mb-4">
<span className="material-symbols-outlined text-[20px] text-cyan-400">pie_chart</span>
<h3 className="font-semibold text-[var(--text-primary,#fff)] flex-1">
Provider Diversity Score
</h3>
<Card className="p-5 flex flex-col gap-5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[20px] text-primary">pie_chart</span>
<h3 className="font-semibold text-text-main">Provider Diversity</h3>
</div>
<p className="text-sm text-text-muted mt-1">
Provider concentration snapshot for the recent traffic window.
</p>
</div>
<span
className={`text-xs px-2 py-0.5 rounded-md border ${gaugeColor.replace("bg-", "border-").replace("500", "500/20")} ${gaugeColor.replace("500", "500/10")} ${riskColor}`}
className={`shrink-0 rounded-full px-2.5 py-1 text-xs font-medium ${
scorePercentage < 40
? "bg-red-500/10 text-red-500"
: scorePercentage < 70
? "bg-amber-500/10 text-amber-500"
: "bg-green-500/10 text-green-500"
}`}
>
Shannon Entropy
Shannon entropy
</span>
</div>
<div className="flex items-center justify-between mt-2 mb-6">
<div className="flex flex-col">
<span className={`text-4xl font-bold tabular-nums tracking-tight ${riskColor}`}>
{scorePercentage}%
</span>
<span className="text-sm text-[var(--text-muted,#aaaaaa)] mt-1">{riskLabel}</span>
</div>
{/* Simple CSS Donut */}
<div className="relative w-20 h-20 flex-shrink-0">
<svg className="w-full h-full transform -rotate-90" viewBox="0 0 36 36">
<div className="grid gap-4 sm:grid-cols-[112px_minmax(0,1fr)] sm:items-center">
<div className="relative mx-auto h-28 w-28">
<svg className="h-full w-full -rotate-90" viewBox="0 0 36 36">
<path
className="text-[var(--border,#333)]"
strokeWidth="4"
className="text-border/70"
strokeWidth="3.5"
stroke="currentColor"
fill="none"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
/>
<path
className={riskColor}
strokeWidth="4"
strokeWidth="3.5"
strokeDasharray={`${scorePercentage}, 100`}
stroke="currentColor"
fill="none"
@@ -88,48 +117,58 @@ export default function DiversityScoreCard() {
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className={`text-2xl font-semibold tabular-nums ${riskColor}`}>
{scorePercentage}%
</span>
<span className="text-[11px] uppercase tracking-[0.18em] text-text-muted">score</span>
</div>
</div>
<div className="space-y-4">
<div className="rounded-xl border border-border/30 bg-surface/20 px-4 py-3">
<div className={`text-sm font-medium ${riskColor}`}>{riskLabel}</div>
<div className="text-xs text-text-muted mt-1">
Higher values indicate traffic is spread across multiple providers instead of
clustering on one vendor.
</div>
</div>
{Object.keys(data.providers || {}).length === 0 ? (
<div className="rounded-xl border border-dashed border-border/50 px-4 py-5 text-sm text-text-muted">
No recent usage data available.
</div>
) : (
<div className="space-y-3">
{Object.entries(data.providers)
.sort(([, a], [, b]) => b.share - a.share)
.slice(0, 4)
.map(([provider, stat]) => (
<div key={provider} className="space-y-1.5">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="font-medium text-text-main capitalize">{provider}</span>
<span className="tabular-nums text-text-muted">
{Math.round(stat.share * 100)}%
</span>
</div>
<div className="h-2 rounded-full bg-surface/50 overflow-hidden">
<div
className={`h-full rounded-full ${gaugeColor}`}
style={{ width: `${Math.round(stat.share * 100)}%` }}
/>
</div>
</div>
))}
</div>
)}
</div>
</div>
<div className="space-y-4 flex-1">
<p className="text-xs uppercase tracking-wider font-semibold text-[var(--text-muted,#888)]">
Provider Share
</p>
{Object.keys(data.providers || {}).length === 0 ? (
<div className="text-sm text-[var(--text-secondary,#666)] py-2">
No recent usage data available.
</div>
) : (
<div className="space-y-3">
{Object.entries(data.providers)
.sort(([, a]: any, [, b]: any) => b.share - a.share)
.slice(0, 4) // Top 4 providers
.map(([provider, stat]: [string, any]) => (
<div key={provider} className="flex flex-col gap-1.5">
<div className="flex items-center justify-between text-sm">
<span className="font-medium text-[var(--text-primary,#ddd)] capitalize">
{provider}
</span>
<span className="font-mono text-[var(--text-muted,#aaa)]">
{Math.round(stat.share * 100)}%
</span>
</div>
<div className="w-full h-1.5 bg-[var(--surface,#333)] rounded-full overflow-hidden">
<div
className={`h-full ${gaugeColor} rounded-full`}
style={{ width: `${Math.round(stat.share * 100)}%` }}
/>
</div>
</div>
))}
</div>
)}
</div>
<div className="mt-4 pt-4 border-t border-[var(--border,#333)] flex justify-between text-[11px] text-[var(--text-muted,#777)]">
<span>Window: {data.windowSize} reqs</span>
<span>Based on Last {Math.round(data.ttlMs / 60000)} mins</span>
<div className="grid grid-cols-2 gap-3 border-t border-border/30 pt-4 text-xs text-text-muted">
<div className="rounded-lg bg-surface/20 px-3 py-2">Window: {data.windowSize} reqs</div>
<div className="rounded-lg bg-surface/20 px-3 py-2 text-right">
Based on last {Math.round(data.ttlMs / 60000)} mins
</div>
</div>
</Card>
);
@@ -45,13 +45,11 @@ export default function AnalyticsPage() {
/>
{activeTab === "overview" && (
<div className="flex flex-col gap-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<DiversityScoreCard />
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_340px] xl:items-start">
<Suspense fallback={<CardSkeleton />}>
<UsageAnalytics />
</Suspense>
<DiversityScoreCard />
</div>
)}
{activeTab === "evals" && <EvalsTab />}
+137 -13
View File
@@ -5,7 +5,6 @@ import { Card, Button, EmptyState } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
import { useTranslations } from "next-intl";
import CacheEntriesTab from "./components/CacheEntriesTab";
import CacheStatsCard from "../settings/components/CacheStatsCard";
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -192,7 +191,18 @@ export default function CachePage() {
const promptCacheHitRate =
pc && pc.totalRequests > 0 ? (pc.requestsWithCacheControl / pc.totalRequests) * 100 : 0;
const providerEntries = pc ? Object.entries(pc.byProvider) : [];
const promptCacheReuseRatio =
pc && pc.totalInputTokens > 0 ? (pc.totalCachedTokens / pc.totalInputTokens) * 100 : 0;
const providerEntries = pc
? Object.entries(pc.byProvider).sort(
([, left], [, right]) => right.cachedTokens - left.cachedTokens
)
: [];
const strategyEntries = pc
? Object.entries(pc.byStrategy).sort(
([, left], [, right]) => right.cachedTokens - left.cachedTokens
)
: [];
const maxTrendRequests = Math.max(1, ...trend.map((p) => p.requests));
@@ -351,30 +361,47 @@ export default function CachePage() {
{pc && (
<Card>
<div className="p-5 flex flex-col gap-4">
<div className="flex items-center gap-2">
<span
className="material-symbols-outlined text-base text-text-muted"
aria-hidden="true"
>
bolt
<div className="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-2">
<span
className="material-symbols-outlined text-base text-text-muted"
aria-hidden="true"
>
bolt
</span>
<h2 className="font-medium text-sm">{t("promptCache")}</h2>
</div>
<span className="text-xs text-text-muted">
Last updated {new Date(pc.lastUpdated).toLocaleString()}
</span>
<h2 className="font-medium text-sm">{t("promptCache")}</h2>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-4">
<div className="p-3 rounded-lg bg-surface/50">
<div className="text-lg font-semibold tabular-nums">
{pc.totalRequests.toLocaleString()}
</div>
<div className="text-xs text-text-muted mt-0.5">{t("total")}</div>
</div>
<div className="p-3 rounded-lg bg-surface/50">
<div className="text-lg font-semibold tabular-nums">
{pc.requestsWithCacheControl.toLocaleString()}
</div>
<div className="text-xs text-text-muted mt-0.5">{t("cachedRequests")}</div>
<div className="text-xs text-text-muted mt-0.5">
{t("withCacheControl")}
</div>
</div>
<div className="p-3 rounded-lg bg-surface/50">
<div className="text-lg font-semibold tabular-nums text-green-500">
{promptCacheHitRate.toFixed(1)}%
</div>
<div className="text-xs text-text-muted mt-0.5">
{t("cacheHitRate")} ({pc.requestsWithCacheControl}/{pc.totalRequests})
<div className="text-xs text-text-muted mt-0.5">{t("cacheHitRate")}</div>
</div>
<div className="p-3 rounded-lg bg-surface/50">
<div className="text-lg font-semibold tabular-nums text-emerald-500">
{promptCacheReuseRatio.toFixed(1)}%
</div>
<div className="text-xs text-text-muted mt-0.5">{t("cacheReuseRatio")}</div>
</div>
<div className="p-3 rounded-lg bg-surface/50">
<div className="text-lg font-semibold tabular-nums text-blue-400">
@@ -390,6 +417,103 @@ export default function CachePage() {
{t("cacheCreationTokens")}
</div>
</div>
<div className="p-3 rounded-lg bg-surface/50">
<div className="text-lg font-semibold tabular-nums text-green-500">
${pc.estimatedCostSaved.toFixed(4)}
</div>
<div className="text-xs text-text-muted mt-0.5">{t("estCostSaved")}</div>
</div>
</div>
<div className="grid gap-4 xl:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<div className="rounded-xl border border-border/30 bg-surface/20 p-4">
<div className="flex items-center justify-between gap-3 mb-3">
<div>
<h3 className="text-sm font-medium text-text-main">
{t("cacheMetrics")}
</h3>
<p className="text-xs text-text-muted">{t("cacheReuseRatioDesc")}</p>
</div>
<div className="text-right">
<div className="text-lg font-semibold tabular-nums text-emerald-500">
{promptCacheReuseRatio.toFixed(1)}%
</div>
<div className="text-xs text-text-muted">
{pc.totalCachedTokens.toLocaleString()} /{" "}
{pc.totalInputTokens.toLocaleString()}{" "}
{t("inputTokens").toLowerCase()}
</div>
</div>
</div>
<HitRateBar hitRate={promptCacheReuseRatio} label={t("cacheReuseRatio")} />
<div className="mt-3">
<HitRateBar
hitRate={promptCacheHitRate}
label={`${t("withCacheControl")} (${pc.requestsWithCacheControl}/${pc.totalRequests})`}
/>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 mt-4">
<div className="rounded-lg bg-surface/40 px-3 py-2">
<div className="text-xs text-text-muted">{t("tokensSaved")}</div>
<div className="text-sm font-semibold tabular-nums text-green-500 mt-1">
{pc.tokensSaved.toLocaleString()}
</div>
</div>
<div className="rounded-lg bg-surface/40 px-3 py-2">
<div className="text-xs text-text-muted">{t("cachedTokensRead")}</div>
<div className="text-sm font-semibold tabular-nums text-blue-400 mt-1">
{pc.totalCachedTokens.toLocaleString()}
</div>
</div>
<div className="rounded-lg bg-surface/40 px-3 py-2">
<div className="text-xs text-text-muted">{t("cacheCreationWrite")}</div>
<div className="text-sm font-semibold tabular-nums text-purple-400 mt-1">
{pc.totalCacheCreationTokens.toLocaleString()}
</div>
</div>
</div>
</div>
{strategyEntries.length > 0 && (
<div className="rounded-xl border border-border/30 bg-surface/20 p-4">
<h3 className="text-sm font-medium text-text-main">Strategy Breakdown</h3>
<div className="mt-3 space-y-3">
{strategyEntries.map(([strategy, data]) => {
const ratio =
data.inputTokens > 0
? (data.cachedTokens / data.inputTokens) * 100
: 0;
return (
<div key={strategy} className="space-y-1.5">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="font-medium text-text-main capitalize">
{strategy}
</span>
<span className="tabular-nums text-text-muted">
{ratio.toFixed(1)}%
</span>
</div>
<div className="h-2 rounded-full bg-surface/50 overflow-hidden">
<div
className="h-full rounded-full bg-blue-400"
style={{ width: `${Math.min(ratio, 100)}%` }}
/>
</div>
<div className="flex items-center justify-between text-xs text-text-muted">
<span>
{data.requests.toLocaleString()} {t("requestsShort")}
</span>
<span>
{data.cachedTokens.toLocaleString()}{" "}
{t("cachedTokensCol").toLowerCase()}
</span>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
{providerEntries.length > 0 && (
@@ -33,15 +33,16 @@ export default function MemorySkillsTab() {
useEffect(() => {
fetch("/api/settings/memory")
.then((res) => res.json())
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
setConfig(data);
setLoading(false);
if (data) setConfig(data);
})
.catch(() => setLoading(false));
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const save = async (updates: Partial<MemoryConfig>) => {
const previousConfig = config;
const newConfig = { ...config, ...updates };
setConfig(newConfig);
setSaving(true);
@@ -53,12 +54,16 @@ export default function MemorySkillsTab() {
body: JSON.stringify(newConfig),
});
if (res.ok) {
const savedConfig = await res.json().catch(() => newConfig);
setConfig(savedConfig);
setStatus("saved");
setTimeout(() => setStatus(""), 2000);
} else {
setConfig(previousConfig);
setStatus("error");
}
} catch {
setConfig(previousConfig);
setStatus("error");
} finally {
setSaving(false);
@@ -243,8 +248,6 @@ export default function MemorySkillsTab() {
/>
</button>
</div>
<p className="text-xs text-text-muted mt-3">{t("skillsComingSoon")}</p>
</Card>
</div>
);
@@ -16,8 +16,6 @@ import CodexServiceTierTab from "./components/CodexServiceTierTab";
import SystemPromptTab from "./components/SystemPromptTab";
import ModelAliasesTab from "./components/ModelAliasesTab";
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";
@@ -95,7 +93,6 @@ export default function SettingsPage() {
<ThinkingBudgetTab />
<CodexServiceTierTab />
<SystemPromptTab />
<CacheStatsCard />
<CacheSettingsTab />
<MemorySkillsTab />
</div>
@@ -0,0 +1,104 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../../../shared/utils/apiAuth", () => ({
isAuthenticated: vi.fn(),
}));
vi.mock("../../../../lib/localDb", () => ({
getSettings: vi.fn(),
updateSettings: vi.fn(),
}));
vi.mock("../../../../lib/memory/settings", async () => {
const actual = await vi.importActual("../../../../lib/memory/settings");
return {
...actual,
invalidateMemorySettingsCache: vi.fn(),
};
});
import { GET, PUT } from "../memory/route";
import { isAuthenticated } from "../../../../shared/utils/apiAuth";
import { getSettings, updateSettings } from "../../../../lib/localDb";
import { invalidateMemorySettingsCache } from "../../../../lib/memory/settings";
function createRequest(method: "GET" | "PUT", body?: unknown) {
return new Request("http://localhost/api/settings/memory", {
method,
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
}
describe("/api/settings/memory", () => {
beforeEach(() => {
vi.resetAllMocks();
(isAuthenticated as any).mockResolvedValue(true);
(getSettings as any).mockResolvedValue({
memoryEnabled: true,
memoryMaxTokens: 2000,
memoryRetentionDays: 30,
memoryStrategy: "hybrid",
skillsEnabled: false,
});
(updateSettings as any).mockImplementation(async (updates: Record<string, unknown>) => ({
memoryEnabled: true,
memoryMaxTokens: 2000,
memoryRetentionDays: 30,
memoryStrategy: "hybrid",
skillsEnabled: false,
...updates,
}));
});
it("returns normalized memory and skills settings", async () => {
(getSettings as any).mockResolvedValue({
memoryEnabled: false,
memoryMaxTokens: 3200,
memoryRetentionDays: 999,
memoryStrategy: "recent",
skillsEnabled: true,
});
const res = await GET(createRequest("GET") as any);
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({
enabled: false,
maxTokens: 3200,
retentionDays: 365,
strategy: "recent",
skillsEnabled: true,
});
});
it("persists updates and clears the cached settings snapshot", async () => {
const res = await PUT(
createRequest("PUT", {
enabled: false,
maxTokens: 0,
retentionDays: 14,
strategy: "semantic",
skillsEnabled: true,
}) as any
);
expect(res.status).toBe(200);
expect(updateSettings).toHaveBeenCalledOnce();
expect(updateSettings).toHaveBeenCalledWith({
memoryEnabled: false,
memoryMaxTokens: 0,
memoryRetentionDays: 14,
memoryStrategy: "semantic",
skillsEnabled: true,
});
expect(invalidateMemorySettingsCache).toHaveBeenCalledOnce();
await expect(res.json()).resolves.toEqual({
enabled: false,
maxTokens: 0,
retentionDays: 14,
strategy: "semantic",
skillsEnabled: true,
});
});
});
+61
View File
@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getSettings, updateSettings } from "@/lib/localDb";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import {
invalidateMemorySettingsCache,
normalizeMemorySettings,
toMemorySettingsUpdates,
} from "@/lib/memory/settings";
const memorySettingsUpdateSchema = z
.object({
enabled: z.boolean().optional(),
maxTokens: z.number().int().min(0).max(16000).optional(),
retentionDays: z.number().int().min(1).max(365).optional(),
strategy: z.enum(["recent", "semantic", "hybrid"]).optional(),
skillsEnabled: z.boolean().optional(),
})
.strict();
export async function GET(request: NextRequest) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const settings = (await getSettings()) as Record<string, unknown>;
return NextResponse.json(normalizeMemorySettings(settings));
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const validation = validateBody(memorySettingsUpdateSchema, rawBody);
if (isValidationFailure(validation)) {
return validation.response;
}
const updates = toMemorySettingsUpdates(validation.data);
const settings = (await updateSettings(updates)) as Record<string, unknown>;
invalidateMemorySettingsCache();
return NextResponse.json(normalizeMemorySettings(settings));
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
+15 -2
View File
@@ -21,7 +21,7 @@ export async function retrieveMemories(
const normalizedConfig = MemoryConfigSchema.parse({
enabled: true,
maxTokens: 2000,
retrievalStrategy: "recent",
retrievalStrategy: "exact",
autoSummarize: false,
persistAcrossModels: false,
retentionDays: 30,
@@ -29,6 +29,10 @@ export async function retrieveMemories(
...config,
});
if (!normalizedConfig.enabled || normalizedConfig.maxTokens <= 0) {
return [];
}
const maxTokens = Math.min(Math.max(normalizedConfig.maxTokens, 100), 8000);
const strategy = normalizedConfig.retrievalStrategy;
@@ -37,9 +41,18 @@ export async function retrieveMemories(
let totalTokens = 0;
// Build base query
let query = "SELECT * FROM memory WHERE apiKeyId = ?";
let query =
"SELECT * FROM memory WHERE apiKeyId = ? AND (expiresAt IS NULL OR datetime(expiresAt) > datetime('now'))";
const params: any[] = [apiKeyId];
if (normalizedConfig.retentionDays > 0) {
const cutoff = new Date(
Date.now() - normalizedConfig.retentionDays * 24 * 60 * 60 * 1000
).toISOString();
query += " AND datetime(createdAt) >= datetime(?)";
params.push(cutoff);
}
// Add ordering based on strategy
switch (strategy) {
case "semantic":
+1 -1
View File
@@ -6,7 +6,7 @@ import { MemoryType } from "./types";
*/
export const MemoryConfigSchema = z.object({
enabled: z.boolean(),
maxTokens: z.number().int().positive(),
maxTokens: z.number().int().nonnegative(),
retrievalStrategy: z.enum(["exact", "semantic", "hybrid"]).optional(),
autoSummarize: z.boolean(),
persistAcrossModels: z.boolean(),
+97
View File
@@ -0,0 +1,97 @@
import { getSettings } from "@/lib/db/settings";
import type { MemoryConfig } from "./types";
export interface MemorySettings {
enabled: boolean;
maxTokens: number;
retentionDays: number;
strategy: "recent" | "semantic" | "hybrid";
skillsEnabled: boolean;
}
export const DEFAULT_MEMORY_SETTINGS: MemorySettings = {
enabled: true,
maxTokens: 2000,
retentionDays: 30,
strategy: "hybrid",
skillsEnabled: false,
};
let cachedMemorySettings: MemorySettings | null = null;
function toBoolean(value: unknown, fallback: boolean): boolean {
return typeof value === "boolean" ? value : fallback;
}
function clampInteger(value: unknown, fallback: number, min: number, max: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
return Math.min(Math.max(Math.round(value), min), max);
}
function normalizeStrategy(value: unknown): MemorySettings["strategy"] {
return value === "recent" || value === "semantic" || value === "hybrid"
? value
: DEFAULT_MEMORY_SETTINGS.strategy;
}
export function normalizeMemorySettings(rawSettings: Record<string, unknown> = {}): MemorySettings {
return {
enabled: toBoolean(rawSettings.memoryEnabled, DEFAULT_MEMORY_SETTINGS.enabled),
maxTokens: clampInteger(
rawSettings.memoryMaxTokens,
DEFAULT_MEMORY_SETTINGS.maxTokens,
0,
16000
),
retentionDays: clampInteger(
rawSettings.memoryRetentionDays,
DEFAULT_MEMORY_SETTINGS.retentionDays,
1,
365
),
strategy: normalizeStrategy(rawSettings.memoryStrategy),
skillsEnabled: toBoolean(rawSettings.skillsEnabled, DEFAULT_MEMORY_SETTINGS.skillsEnabled),
};
}
export function toMemorySettingsUpdates(
settings: Partial<MemorySettings>
): Record<string, unknown> {
const updates: Record<string, unknown> = {};
if (settings.enabled !== undefined) updates.memoryEnabled = settings.enabled;
if (settings.maxTokens !== undefined) updates.memoryMaxTokens = settings.maxTokens;
if (settings.retentionDays !== undefined) updates.memoryRetentionDays = settings.retentionDays;
if (settings.strategy !== undefined) updates.memoryStrategy = settings.strategy;
if (settings.skillsEnabled !== undefined) updates.skillsEnabled = settings.skillsEnabled;
return updates;
}
export function toMemoryRetrievalConfig(settings: MemorySettings): Partial<MemoryConfig> {
const enabled = settings.enabled && settings.maxTokens > 0;
return {
enabled,
maxTokens: enabled ? settings.maxTokens : 0,
retrievalStrategy: settings.strategy === "recent" ? "exact" : settings.strategy,
autoSummarize: false,
persistAcrossModels: false,
retentionDays: settings.retentionDays,
scope: "apiKey",
};
}
export async function getMemorySettings(): Promise<MemorySettings> {
if (cachedMemorySettings !== null) {
return cachedMemorySettings;
}
const settings = (await getSettings()) as Record<string, unknown>;
cachedMemorySettings = normalizeMemorySettings(settings);
return cachedMemorySettings;
}
export function invalidateMemorySettingsCache(): void {
cachedMemorySettings = null;
}
@@ -279,9 +279,11 @@ describe("Page Integration — settings page wiring", () => {
describe("Page Integration — cache page wiring", () => {
const src = readProjectFile("src/app/(dashboard)/dashboard/cache/page.tsx");
it("should include cache stats card for prompt cache metrics", () => {
it("should consolidate prompt cache metrics directly into cache management", () => {
assert.ok(src, "src/app/(dashboard)/dashboard/cache/page.tsx should exist");
assert.match(src, /CacheStatsCard/);
assert.doesNotMatch(src, /CacheStatsCard/);
assert.match(src, /promptCacheReuseRatio/);
assert.match(src, /strategyEntries/);
});
});
+68
View File
@@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import { describe, test } from "node:test";
import {
DEFAULT_MEMORY_SETTINGS,
normalizeMemorySettings,
toMemoryRetrievalConfig,
toMemorySettingsUpdates,
} from "../../src/lib/memory/settings.ts";
describe("memory settings helpers", () => {
test("normalizeMemorySettings applies defaults and clamps persisted values", () => {
const settings = normalizeMemorySettings({
memoryEnabled: "yes",
memoryMaxTokens: 20001,
memoryRetentionDays: 0,
memoryStrategy: "unsupported",
skillsEnabled: true,
});
assert.deepEqual(settings, {
enabled: DEFAULT_MEMORY_SETTINGS.enabled,
maxTokens: 16000,
retentionDays: 1,
strategy: DEFAULT_MEMORY_SETTINGS.strategy,
skillsEnabled: true,
});
});
test("toMemorySettingsUpdates maps UI fields to persisted keys", () => {
assert.deepEqual(
toMemorySettingsUpdates({
enabled: false,
maxTokens: 4096,
retentionDays: 21,
strategy: "hybrid",
skillsEnabled: true,
}),
{
memoryEnabled: false,
memoryMaxTokens: 4096,
memoryRetentionDays: 21,
memoryStrategy: "hybrid",
skillsEnabled: true,
}
);
});
test("toMemoryRetrievalConfig disables injection when memory is off and remaps recent strategy", () => {
assert.deepEqual(
toMemoryRetrievalConfig({
enabled: true,
maxTokens: 0,
retentionDays: 10,
strategy: "recent",
skillsEnabled: false,
}),
{
enabled: false,
maxTokens: 0,
retrievalStrategy: "exact",
autoSummarize: false,
persistAcrossModels: false,
retentionDays: 10,
scope: "apiKey",
}
);
});
});