diff --git a/src/config.ts b/src/config.ts index 9247e7e9..d983d5b4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -42,6 +42,7 @@ interface OpenCodeMemConfig { autoCaptureMaxIterations?: number; autoCaptureIterationTimeout?: number; autoCaptureMaxRetries?: number; + autoCaptureMaxContextBytes?: number; autoCaptureLanguage?: string; memoryProvider?: "openai-chat" | "openai-responses" | "anthropic" | "minimax"; memoryModel?: string; @@ -81,6 +82,8 @@ interface OpenCodeMemConfig { userProfileCentroidDriftThreshold?: number; userProfileEmbeddingMinDescriptionLength?: number; userProfileMinEvidenceForRetention?: number; + userProfileAutoCleanupEnabled?: boolean; + userProfileAutoCleanupInterval?: number; userProfileValidationEnabled?: boolean; showAutoCaptureToasts?: boolean; showUserProfileToasts?: boolean; @@ -152,6 +155,7 @@ const DEFAULTS: Required< autoCaptureMaxIterations: 5, autoCaptureIterationTimeout: 30000, autoCaptureMaxRetries: 3, + autoCaptureMaxContextBytes: 131072, aiSessionRetentionDays: 7, webServerEnabled: true, webServerPort: 4747, @@ -179,6 +183,8 @@ const DEFAULTS: Required< userProfileCentroidDriftThreshold: 0.65, userProfileEmbeddingMinDescriptionLength: 5, userProfileMinEvidenceForRetention: 3, + userProfileAutoCleanupEnabled: true, + userProfileAutoCleanupInterval: 100, userProfileValidationEnabled: false, showAutoCaptureToasts: true, showUserProfileToasts: true, @@ -410,6 +416,11 @@ const CONFIG_TEMPLATE = `{ // Maximum number of times to retry capturing a prompt if it fails (due to network, API errors, etc.) "autoCaptureMaxRetries": 3, + + // Maximum UTF-8 bytes for the auto-capture markdown context sent to the summary model. + // Prevents HTTP 400 context overflows on models with ~131K token windows (e.g. Groq Llama). + // Rough guide: tokens ≈ bytes / 4 for mixed code/prose. + "autoCaptureMaxContextBytes": 131072, // Days to keep AI session history before cleanup "aiSessionRetentionDays": 7, @@ -488,6 +499,11 @@ const CONFIG_TEMPLATE = `{ // Items confirmed fewer times are more likely to be pruned when confidence decays "userProfileMinEvidenceForRetention": 3, + // Periodically merge duplicate or irrelevant profile items with the configured AI provider + "userProfileAutoCleanupEnabled": true, + // Number of analyzed user prompts between automatic AI cleanup runs + "userProfileAutoCleanupInterval": 100, + // Enable LLM validation of existing preferences against recent behavior. // When enabled, each analysis round checks if top-5 preferences still match recent prompts. // Experimental — disabled by default. @@ -568,11 +584,23 @@ function getEmbeddingDimensions(model: string): number { return dimensionMap[model] || 768; } +export function normalizeAutoCaptureMaxContextBytes(value: number): number { + if (!Number.isInteger(value) || value < 16384 || value > 16 * 1024 * 1024) { + throw new Error(`Invalid autoCaptureMaxContextBytes config: ${value}`); + } + return value; +} + function buildConfig(fileConfig: OpenCodeMemConfig) { const memoryApiKey = resolveSecretValue(fileConfig.memoryApiKey); const embeddingDimensions = fileConfig.embeddingDimensions ?? getEmbeddingDimensions(fileConfig.embeddingModel ?? DEFAULTS.embeddingModel); + const autoCaptureMaxContextBytes = normalizeAutoCaptureMaxContextBytes( + fileConfig.autoCaptureMaxContextBytes ?? DEFAULTS.autoCaptureMaxContextBytes + ); + const userProfileAutoCleanupInterval = + fileConfig.userProfileAutoCleanupInterval ?? DEFAULTS.userProfileAutoCleanupInterval; if ( !Number.isInteger(embeddingDimensions) || @@ -581,6 +609,11 @@ function buildConfig(fileConfig: OpenCodeMemConfig) { ) { throw new Error(`Invalid embeddingDimensions config: ${embeddingDimensions}`); } + if (!Number.isInteger(userProfileAutoCleanupInterval) || userProfileAutoCleanupInterval <= 0) { + throw new Error( + `Invalid userProfileAutoCleanupInterval config: ${userProfileAutoCleanupInterval}` + ); + } return { storagePath: expandPath(fileConfig.storagePath ?? DEFAULTS.storagePath), @@ -605,6 +638,7 @@ function buildConfig(fileConfig: OpenCodeMemConfig) { autoCaptureIterationTimeout: fileConfig.autoCaptureIterationTimeout ?? DEFAULTS.autoCaptureIterationTimeout, autoCaptureMaxRetries: fileConfig.autoCaptureMaxRetries ?? DEFAULTS.autoCaptureMaxRetries, + autoCaptureMaxContextBytes, autoCaptureLanguage: fileConfig.autoCaptureLanguage, memoryProvider: (fileConfig.memoryProvider ?? "openai-chat") as "openai-chat" | "openai-responses" | "anthropic" | "minimax", @@ -677,6 +711,9 @@ function buildConfig(fileConfig: OpenCodeMemConfig) { DEFAULTS.userProfileEmbeddingMinDescriptionLength, userProfileMinEvidenceForRetention: fileConfig.userProfileMinEvidenceForRetention ?? DEFAULTS.userProfileMinEvidenceForRetention, + userProfileAutoCleanupEnabled: + fileConfig.userProfileAutoCleanupEnabled ?? DEFAULTS.userProfileAutoCleanupEnabled, + userProfileAutoCleanupInterval, userProfileValidationEnabled: fileConfig.userProfileValidationEnabled ?? DEFAULTS.userProfileValidationEnabled, userProfileStaleDays: fileConfig.userProfileStaleDays ?? DEFAULTS.userProfileStaleDays, diff --git a/src/index.ts b/src/index.ts index ef1fcae1..51281671 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,6 +54,92 @@ function extractSessionTitle(response: unknown): string | undefined { return obj.data?.title ?? obj.title; } +function unwrapSdkData(response: unknown): T | undefined { + if (!response || typeof response !== "object") return undefined; + const obj = response as { data?: T }; + return (obj.data ?? response) as T; +} + +/** + * Resolve the session's active agent so compaction memory injection does not + * reset OpenCode to the stock "general-purpose" fallback (issue #236). + * + * Preference order: + * 1. session.get().agent (v2 hosts) + * 2. Latest non-compaction user message agent + * 3. Latest non-compaction / non-summary assistant mode (v1) or agent (v2) + */ +export async function resolveSessionAgent( + client: unknown, + sessionID: string +): Promise { + const sessionClient = ( + client as { + session?: { + get?: (args: unknown) => Promise; + messages?: (args: unknown) => Promise; + }; + } + )?.session; + + if (typeof sessionClient?.get === "function") { + try { + const session = unwrapSdkData<{ agent?: string }>( + await sessionClient.get({ path: { id: sessionID } }) + ); + if (typeof session?.agent === "string" && session.agent.trim()) { + return session.agent.trim(); + } + } catch (error) { + log("resolveSessionAgent: session.get failed", { sessionID, error: String(error) }); + } + } + + if (typeof sessionClient?.messages !== "function") { + return undefined; + } + + try { + const messages = unwrapSdkData< + Array<{ + info?: { + role?: string; + agent?: string; + mode?: string; + summary?: boolean; + }; + }> + >(await sessionClient.messages({ path: { id: sessionID } })); + + if (!Array.isArray(messages)) return undefined; + + for (let i = messages.length - 1; i >= 0; i--) { + const info = messages[i]?.info; + if (!info) continue; + + if (info.role === "user") { + if (typeof info.agent === "string" && info.agent.trim()) { + return info.agent.trim(); + } + continue; + } + + if (info.role === "assistant") { + if (info.summary === true || info.mode === "compaction") continue; + const agent = + (typeof info.agent === "string" && info.agent.trim()) || + (typeof info.mode === "string" && info.mode.trim()) || + undefined; + if (agent) return agent; + } + } + } catch (error) { + log("resolveSessionAgent: session.messages failed", { sessionID, error: String(error) }); + } + + return undefined; +} + async function isInternalCaptureSession(client: unknown, sessionID: string): Promise { // Fast path: sessions we created ourselves (survives brief post-delete window). if (isTrackedInternalCaptureSession(sessionID)) { @@ -873,12 +959,23 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { } const memoryContext = formatMemoriesForCompaction(memoriesResult.results); + const agent = await resolveSessionAgent(ctx.client, sessionID); + if (!agent) { + log( + "Compaction: skipped memory injection because session agent could not be resolved", + { + sessionID, + } + ); + return; + } await ctx.client.session.prompt({ path: { id: sessionID }, body: { parts: [{ id: `prt-compaction-${Date.now()}`, type: "text", text: memoryContext }], noReply: true, + agent, }, }); @@ -898,6 +995,7 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { log("Compaction memory injected", { sessionID, count: memoriesResult.results.length, + agent: agent ?? null, }); } catch (error) { log("Compaction handler error", { error: String(error) }); diff --git a/src/services/ai/validators/user-profile-validator.ts b/src/services/ai/validators/user-profile-validator.ts index 4803de2e..16951801 100644 --- a/src/services/ai/validators/user-profile-validator.ts +++ b/src/services/ai/validators/user-profile-validator.ts @@ -62,8 +62,13 @@ export class UserProfileValidator { if (!pref.description || typeof pref.description !== "string") { errors.push(`preferences[${i}].description is missing or invalid`); } - if (typeof pref.confidence !== "number") { - errors.push(`preferences[${i}].confidence is missing or invalid`); + if ( + typeof pref.confidence !== "number" || + !Number.isFinite(pref.confidence) || + pref.confidence < 0 || + pref.confidence > 1 + ) { + errors.push(`preferences[${i}].confidence must be a finite number between 0 and 1`); } if (!Array.isArray(pref.evidence)) { errors.push(`preferences[${i}].evidence must be an array`); diff --git a/src/services/api-handlers.ts b/src/services/api-handlers.ts index db9e2315..ac9aafe7 100644 --- a/src/services/api-handlers.ts +++ b/src/services/api-handlers.ts @@ -1022,15 +1022,18 @@ const pendingCleanups = new Map< cleaned: UserProfileData; oldProfileData: UserProfileData; diff: any; - allMergedIds: string[][]; + allMerged: Array<{ ids: string[]; result: string }>; allRemovedIds: string[]; + includeIds?: string[]; + profileVersion: number; expiresAt: number; } >(); export async function handleAICleanup( userId?: string, - includeIds?: string[] + includeIds?: string[], + profileVersion?: number ): Promise> { try { const { userProfileManager } = await import("./user-profile/user-profile-manager.js"); @@ -1048,13 +1051,20 @@ export async function handleAICleanup( if (!profile) { return { success: false, error: "No profile found to clean up" }; } + if (profileVersion !== undefined && profile.version !== profileVersion) { + return { success: false, error: "Profile changed. Reload it before running AI cleanup." }; + } const profileData: UserProfileData = JSON.parse(profile.profileData); + profileData.preferences = sortProfileItems(profileData.preferences as any[], "confidence"); + profileData.patterns = sortProfileItems(profileData.patterns as any[], "frequency"); + profileData.workflows = sortProfileItems(profileData.workflows as any[], "frequency"); let indexed; let result; - if (includeIds && includeIds.length > 0) { - indexed = filterProfileForCleanup(profileData, includeIds); + const scopedIds = includeIds && includeIds.length > 0 ? includeIds : undefined; + if (scopedIds) { + indexed = filterProfileForCleanup(profileData, scopedIds); result = await aiCleanupProfileFromIndexed(indexed); } else { result = await aiCleanupProfile(profileData); @@ -1064,8 +1074,10 @@ export async function handleAICleanup( cleaned: result.cleaned, oldProfileData: profileData, diff: result.diff, - allMergedIds: (result.diff?.merged || []).map((m: any) => m.ids || []), + allMerged: result.diff?.merged || [], allRemovedIds: (result.diff?.removed || []).map((r: any) => r.id), + includeIds: scopedIds, + profileVersion: profile.version, expiresAt: Date.now() + 30 * 60 * 1000, }); @@ -1083,6 +1095,102 @@ export async function handleAICleanup( } } +/** + * Merge an AI-cleanup result into a full profile. + * When includeIds is set, only scoped items are mutated; everything else is preserved. + */ +export function mergeCleanupIntoProfile(args: { + currentProfile: UserProfileData; + oldProfileData: UserProfileData; + cleanedData: UserProfileData; + includeIds?: string[]; + acceptedMerged?: string[][]; + acceptedRemoved?: string[]; + allMerged?: Array<{ ids: string[]; result: string }>; + allRemovedIds?: string[]; + /** True when the client sent acceptance arrays (even if empty = reject all). */ + explicitAcceptance?: boolean; +}): UserProfileData { + const { + currentProfile, + oldProfileData, + cleanedData, + includeIds, + acceptedMerged = [], + acceptedRemoved = [], + allMerged = [], + allRemovedIds = [], + explicitAcceptance = false, + } = args; + + // Start from cleaned data, then restore any rejected removals/merges. + const scopedResult: UserProfileData = { + preferences: [...cleanedData.preferences], + patterns: [...cleanedData.patterns], + workflows: [...cleanedData.workflows], + }; + + if (explicitAcceptance) { + for (const id of acceptedRemoved) { + removeItemFromProfile(scopedResult, oldProfileData, id); + } + + for (const ids of acceptedMerged) { + for (let i = 1; i < ids.length; i++) { + removeItemFromProfile(scopedResult, oldProfileData, ids[i] ?? ""); + } + } + + const acceptedTargetIds = new Set(acceptedMerged.map((g) => g[0])); + for (const merge of allMerged) { + const groupIds = merge.ids; + if (groupIds.length <= 1) continue; + if (acceptedTargetIds.has(groupIds[0])) continue; + removeOneByDescription(scopedResult, merge.result, itemTypeFromId(groupIds[0] ?? "")); + for (const id of groupIds) { + if (id) pushItemFromProfile(scopedResult, oldProfileData, id); + } + } + + const acceptedRemovedSet = new Set(acceptedRemoved); + for (const removedId of allRemovedIds) { + if (acceptedRemovedSet.has(removedId)) continue; + pushItemFromProfile(scopedResult, oldProfileData, removedId); + } + } + + // Full-profile cleanup: cleaned (+ acceptance) replaces the whole profile. + if (!includeIds || includeIds.length === 0) { + return scopedResult; + } + + // Partial selection: mutate only the analyzed scope inside the current full profile. + const result: UserProfileData = { + preferences: [...currentProfile.preferences], + patterns: [...currentProfile.patterns], + workflows: [...currentProfile.workflows], + }; + + for (const id of includeIds) { + removeItemFromProfile(result, oldProfileData, id); + } + + result.preferences.push(...scopedResult.preferences); + result.patterns.push(...scopedResult.patterns); + result.workflows.push(...scopedResult.workflows); + + return result; +} + +function pushItemFromProfile(target: UserProfileData, source: UserProfileData, id: string): void { + const srcItem = findItemById(source, id); + if (!srcItem) return; + const { id: _id, ...rest } = srcItem as any; + if (id.startsWith("pref_")) target.preferences.push(rest); + else if (id.startsWith("pat_")) target.patterns.push(rest); + else if (id.startsWith("wf_")) target.workflows.push(rest); +} + export async function handleApplyCleanup(userId?: string, body?: any): Promise> { try { const { userProfileManager } = await import("./user-profile/user-profile-manager.js"); @@ -1108,90 +1216,41 @@ export async function handleApplyCleanup(userId?: string, body?: any): Promise 0 || acceptedRemoved.length > 0) { - const existingData: UserProfileData = JSON.parse(profile.profileData); - const result: UserProfileData = { - preferences: [...cleanedData.preferences], - patterns: [...cleanedData.patterns], - workflows: [...cleanedData.workflows], - }; - - // Remove items the user chose NOT to merge (revert to old descriptions) - for (const id of acceptedRemoved) { - const desc = findItemDesc(pending.oldProfileData, id); - if (desc) removeByDesc(result, desc, itemTypeFromId(id)); - } - - // For merges: just remove the source items; target is already in cleaned - for (const ids of acceptedMerged) { - for (let i = 1; i < ids.length; i++) { - const srcDesc = findItemDesc(pending.oldProfileData, ids[i] ?? ""); - if (srcDesc) removeByDesc(result, srcDesc, itemTypeFromId(ids[i] ?? "")); - } - } - - // Restore source items from unapproved merges - const acceptedTargetIds = new Set(acceptedMerged.map((g) => g[0])); - for (const groupIds of pending.allMergedIds || []) { - if (groupIds.length <= 1) continue; - if (acceptedTargetIds.has(groupIds[0])) continue; - for (let i = 1; i < groupIds.length; i++) { - const srcId = groupIds[i] ?? ""; - if (!srcId) continue; - const srcDesc = findItemDesc(pending.oldProfileData, srcId); - if (!srcDesc) continue; - const srcItem = findItemByDesc(pending.oldProfileData, srcDesc); - if (srcItem) { - const { id: _id, ...rest } = srcItem as any; - if (srcId.startsWith("pref_")) result.preferences.push(rest); - else if (srcId.startsWith("pat_")) result.patterns.push(rest); - else if (srcId.startsWith("wf_")) result.workflows.push(rest); - } - } - } - - // Restore items from unapproved removals - const acceptedRemovedSet = new Set(acceptedRemoved); - for (const removedId of pending.allRemovedIds || []) { - if (acceptedRemovedSet.has(removedId)) continue; - const desc = findItemDesc(pending.oldProfileData, removedId); - if (!desc) continue; - const srcItem = findItemByDesc(pending.oldProfileData, desc); - if (srcItem) { - const { id: _id, ...rest } = srcItem as any; - if (removedId.startsWith("pref_")) result.preferences.push(rest); - else if (removedId.startsWith("pat_")) result.patterns.push(rest); - else if (removedId.startsWith("wf_")) result.workflows.push(rest); - } - } + const acceptedMerged: string[][] = Array.isArray(body?.acceptedMerged) + ? body.acceptedMerged + : []; + const acceptedRemoved: string[] = Array.isArray(body?.acceptedRemoved) + ? body.acceptedRemoved + : []; + const explicitAcceptance = + Array.isArray(body?.acceptedMerged) || Array.isArray(body?.acceptedRemoved); + const existingData: UserProfileData = JSON.parse(profile.profileData); + + const result = mergeCleanupIntoProfile({ + currentProfile: existingData, + oldProfileData: pending.oldProfileData, + cleanedData, + includeIds: pending.includeIds, + acceptedMerged, + acceptedRemoved, + allMerged: pending.allMerged, + allRemovedIds: pending.allRemovedIds, + explicitAcceptance, + }); - const success = await userProfileManager.updateProfile( - profile.id, - result, - 0, - "AI cleanup applied (partial)" - ); - if (!success) - return { success: false, error: "Profile was modified by another session. Please retry." }; - pendingCleanups.delete(targetUserId); - return { - success: true, - data: { message: "Partial cleanup applied", version: profile.version + 1 }, - }; - } + const partial = (pending.includeIds && pending.includeIds.length > 0) || explicitAcceptance; const success = await userProfileManager.updateProfile( profile.id, - cleanedData, + result, 0, - "AI cleanup applied" + partial ? "AI cleanup applied (partial)" : "AI cleanup applied" ); if (!success) { @@ -1202,7 +1261,10 @@ export async function handleApplyCleanup(userId?: string, body?: any): Promise p.description === desc); - if (found) return found; - } - return null; +function removeItemFromProfile( + target: UserProfileData, + source: UserProfileData, + id: string +): boolean { + const sourceItem = findItemById(source, id); + if (!sourceItem) return false; + const itemType = itemTypeFromId(id) as keyof Pick< + UserProfileData, + "preferences" | "patterns" | "workflows" + >; + const items = target[itemType] as any[]; + const sourceKey = profileItemIdentityKey(sourceItem); + const index = items.findIndex((item) => profileItemIdentityKey(item) === sourceKey); + if (index < 0) return false; + items.splice(index, 1); + return true; } -function removeByDesc(profile: UserProfileData, desc: string, itemType?: string) { - if (!itemType || itemType === "preferences") { - profile.preferences = profile.preferences.filter((p) => p.description !== desc); - } - if (!itemType || itemType === "patterns") { - profile.patterns = profile.patterns.filter((p) => p.description !== desc); - } - if (!itemType || itemType === "workflows") { - profile.workflows = profile.workflows.filter((w) => w.description !== desc); - } +function profileItemIdentityKey(item: any): string { + return JSON.stringify({ + category: item.category ?? null, + description: item.description ?? null, + steps: Array.isArray(item.steps) ? item.steps : null, + }); +} +function removeOneByDescription(profile: UserProfileData, desc: string, itemType: string): boolean { + const items = profile[ + itemType as keyof Pick + ] as any[]; + const index = items.findIndex((item) => item.description === desc); + if (index < 0) return false; + items.splice(index, 1); + return true; } export async function handleUpdateProfileItem(body?: any): Promise> { diff --git a/src/services/auto-capture.ts b/src/services/auto-capture.ts index d7c5188c..65348461 100644 --- a/src/services/auto-capture.ts +++ b/src/services/auto-capture.ts @@ -1,10 +1,12 @@ import type { PluginInput } from "@opencode-ai/plugin"; +import { randomUUID } from "node:crypto"; import { memoryClient } from "./client.js"; import { getTags } from "./tags.js"; import { log } from "./logger.js"; import { CONFIG } from "../config.js"; import { userPromptManager, type UserPrompt } from "./user-prompt/user-prompt-manager.js"; import { loadOpencodeProvider } from "./ai/opencode-provider-loader.js"; +import { truncateToMaxBytes, utf8ByteLength } from "../utils/context-limit.js"; interface ToolCallInfo { name: string; @@ -13,6 +15,11 @@ interface ToolCallInfo { const MAX_TOOL_INPUT_LENGTH = 100; const RETRY_BASE_DELAY_MS = 2000; +const DEFAULT_AUTO_CAPTURE_MAX_CONTEXT_BYTES = 131072; +const CONTEXT_TRUNCATION_MARKER = "\n[... truncated to autoCaptureMaxContextBytes ...]\n"; +const SUMMARY_REQUEST_OVERHEAD_BYTES = 1024; +const SUMMARY_OUTPUT_RESERVE_BYTES = 16384; +const SUMMARY_ANALYSIS_SUFFIX = `Analyze this conversation. If it contains technical work (code, bugs, features, decisions), create a concise summary and relevant tags. If it's non-technical (greetings, casual chat, incomplete requests), return type="skip" with empty summary.`; let isCaptureRunning = false; @@ -94,7 +101,8 @@ async function capturePrompt( prompt.content, textResponses, toolCalls, - latestMemory + latestMemory, + getAutoCaptureMarkdownBudget() ); let summaryResult: { summary: string; type: string; tags: string[] } | null; @@ -293,54 +301,188 @@ async function getLatestProjectMemory(containerTag: string): Promise= 0; i--) { + const response = textResponses[i] ?? ""; + const responseBytes = utf8ByteLength(response); + const extraSeparator = kept.length > 0 ? separatorBytes : 0; + const needed = responseBytes + extraSeparator; + + if (usedBytes + needed <= maxBytes) { + kept.unshift(response); + usedBytes += needed; + continue; + } + + const remaining = maxBytes - usedBytes - extraSeparator; + if (remaining > utf8ByteLength(CONTEXT_TRUNCATION_MARKER)) { + kept.unshift(truncateToMaxBytes(response, remaining, CONTEXT_TRUNCATION_MARKER)); + } + break; + } + + return kept.join(separator); +} + +function joinSections(sections: string[]): string { + return sections.join("\n"); +} + +export function getAutoCaptureMarkdownBudget( + totalRequestBytes: number = CONFIG.autoCaptureMaxContextBytes ?? + DEFAULT_AUTO_CAPTURE_MAX_CONTEXT_BYTES +): number { + const requestReserve = Math.min(24576, Math.floor(totalRequestBytes * 0.25)); + return Math.max(4096, totalRequestBytes - requestReserve); +} + +export function buildBoundedSummaryPrompt( + context: string, + systemPrompt: string, + schema: unknown, + totalRequestBytes: number = CONFIG.autoCaptureMaxContextBytes ?? + DEFAULT_AUTO_CAPTURE_MAX_CONTEXT_BYTES +): string { + const schemaBytes = utf8ByteLength(JSON.stringify(schema)); + const outputReserve = Math.min( + SUMMARY_OUTPUT_RESERVE_BYTES, + Math.floor(totalRequestBytes * 0.125) + ); + const userBudget = Math.max( + 0, + totalRequestBytes - + utf8ByteLength(systemPrompt) - + schemaBytes - + outputReserve - + SUMMARY_REQUEST_OVERHEAD_BYTES + ); + return truncateToMaxBytes( + `${context}\n\n${SUMMARY_ANALYSIS_SUFFIX}`, + userBudget, + CONTEXT_TRUNCATION_MARKER + ); +} + +/** Build auto-capture markdown context, capped to autoCaptureMaxContextBytes (UTF-8). */ +export function buildMarkdownContext( userPrompt: string, textResponses: string[], toolCalls: ToolCallInfo[], - latestMemory: string | null + latestMemory: string | null, + maxContextBytes: number = CONFIG.autoCaptureMaxContextBytes ?? + DEFAULT_AUTO_CAPTURE_MAX_CONTEXT_BYTES ): string { - const sections: string[] = []; - + const memorySections: string[] = []; if (latestMemory) { - sections.push(`## Previous Memory Context`); - sections.push(`---`); - sections.push(latestMemory); - sections.push(`---\n`); - } - - sections.push(`## User Request`); - sections.push(`---`); - sections.push(userPrompt); - sections.push(`---\n`); - - if (textResponses.length > 0) { - sections.push(`## AI Response`); - sections.push(`---`); - sections.push(textResponses.join("\n\n")); - sections.push(`---\n`); + memorySections.push(`## Previous Memory Context`); + memorySections.push(`---`); + memorySections.push(latestMemory); + memorySections.push(`---\n`); } + const toolsSections: string[] = []; if (toolCalls.length > 0) { - sections.push(`## Tools Used`); - sections.push(`---`); + toolsSections.push(`## Tools Used`); + toolsSections.push(`---`); for (const tool of toolCalls) { if (tool.input) { - sections.push(`- ${tool.name}(${tool.input})`); + toolsSections.push(`- ${tool.name}(${tool.input})`); } else { - sections.push(`- ${tool.name}`); + toolsSections.push(`- ${tool.name}`); } } - sections.push(`---\n`); + toolsSections.push(`---\n`); } - return sections.join("\n"); + const userWrapper = ["## User Request", "---", "", "---\n"]; + const aiWrapper = + textResponses.length > 0 ? ["## AI Response", "---", "", "---\n"] : ([] as string[]); + + const skeletonWithoutBodies = joinSections([ + ...memorySections, + ...userWrapper, + ...aiWrapper, + ...toolsSections, + ]); + const skeletonBytes = utf8ByteLength(skeletonWithoutBodies); + + let userBudget = Math.max(0, maxContextBytes - skeletonBytes); + if (textResponses.length > 0 && userBudget > 1024) { + const preferredAiFloor = Math.min(4096, Math.floor(maxContextBytes * 0.25)); + userBudget = Math.max(256, userBudget - preferredAiFloor); + } + + const boundedUser = + utf8ByteLength(userPrompt) <= userBudget + ? userPrompt + : truncateToMaxBytes(userPrompt, userBudget, CONTEXT_TRUNCATION_MARKER); + + const prefix = joinSections([ + ...memorySections, + "## User Request", + "---", + boundedUser, + "---\n", + ...toolsSections, + ]); + + if (textResponses.length === 0) { + if (utf8ByteLength(prefix) <= maxContextBytes) return prefix; + return truncateToMaxBytes(prefix, maxContextBytes, CONTEXT_TRUNCATION_MARKER); + } + + // Insert AI section before tools to preserve the historical section order. + const prefixWithoutTools = joinSections([ + ...memorySections, + "## User Request", + "---", + boundedUser, + "---\n", + ]); + const toolsBlock = toolsSections.length > 0 ? "\n" + joinSections(toolsSections) : ""; + const aiWrapperBytes = utf8ByteLength(joinSections(["## AI Response", "---", "", "---\n"])); + const aiBudget = Math.max( + 0, + maxContextBytes - + utf8ByteLength(prefixWithoutTools) - + utf8ByteLength(toolsBlock) - + aiWrapperBytes + ); + const boundedAi = fitTextResponses(textResponses, aiBudget); + + const result = joinSections([ + ...memorySections, + "## User Request", + "---", + boundedUser, + "---\n", + "## AI Response", + "---", + boundedAi, + "---\n", + ...toolsSections, + ]); + + if (utf8ByteLength(result) <= maxContextBytes) return result; + return truncateToMaxBytes(result, maxContextBytes, CONTEXT_TRUNCATION_MARKER); } async function generateSummary( context: string, sessionID: string, userPrompt: string, - prompt?: { providerId: string | null; modelId: string | null } + prompt?: { id?: string; providerId: string | null; modelId: string | null } ): Promise<{ summary: string; type: string; tags: string[] } | null> { // Opencode provider path (when opencodeProvider + opencodeModel configured) if (CONFIG.opencodeProvider && CONFIG.opencodeModel) { @@ -407,16 +549,13 @@ FORMAT: SKIP if: greetings, casual chat, no code/decisions made CAPTURE if: code changed, bug fixed, feature added, decision made`; - const aiPrompt = `${context} - -Analyze this conversation. If it contains technical work (code, bugs, features, decisions), create a concise summary and relevant tags. If it's non-technical (greetings, casual chat, incomplete requests), return type="skip" with empty summary.`; - const { z } = await import("zod"); const schema = z.object({ summary: z.string(), type: z.string(), tags: z.array(z.string()), }); + const aiPrompt = buildBoundedSummaryPrompt(context, systemPrompt, z.toJSONSchema(schema)); const result = await generateStructuredOutput({ client: v2Client, @@ -479,10 +618,6 @@ FORMAT: SKIP if: greetings, casual chat, no code/decisions made CAPTURE if: code changed, bug fixed, feature added, decision made`; - const aiPrompt = `${context} - -Analyze this conversation. If it contains technical work (code, bugs, features, decisions), create a concise summary and relevant tags. If it's non-technical (greetings, casual chat, incomplete requests), return type="skip" with empty summary.`; - const toolSchema = { type: "function" as const, function: { @@ -510,8 +645,15 @@ Analyze this conversation. If it contains technical work (code, bugs, features, }, }, }; - - const result = await provider.executeToolCall(systemPrompt, aiPrompt, toolSchema, sessionID); + const aiPrompt = buildBoundedSummaryPrompt(context, systemPrompt, toolSchema); + const captureSessionID = `auto-capture-${prompt?.id ?? sessionID}-${randomUUID()}`; + + const result = await provider.executeToolCall( + systemPrompt, + aiPrompt, + toolSchema, + captureSessionID + ); if (!result.success || !result.data) { throw new Error(result.error || "Failed to generate summary"); diff --git a/src/services/user-memory-learning.ts b/src/services/user-memory-learning.ts index 76035463..eebbe628 100644 --- a/src/services/user-memory-learning.ts +++ b/src/services/user-memory-learning.ts @@ -11,6 +11,50 @@ import { loadOpencodeProvider } from "./ai/opencode-provider-loader.js"; let isLearningRunning = false; +export function shouldRunAutomaticProfileCleanup( + previousPromptCount: number, + addedPromptCount: number, + interval: number = CONFIG.userProfileAutoCleanupInterval +): boolean { + if (!CONFIG.userProfileAutoCleanupEnabled || !Number.isInteger(interval) || interval <= 0) { + return false; + } + return ( + Math.floor(previousPromptCount / interval) < + Math.floor((previousPromptCount + addedPromptCount) / interval) + ); +} + +async function runAutomaticProfileCleanup(userId: string): Promise { + try { + const profile = await userProfileManager.getActiveProfile(userId); + if (!profile) return; + const profileData: UserProfileData = JSON.parse(profile.profileData); + const itemCount = + profileData.preferences.length + profileData.patterns.length + profileData.workflows.length; + if (itemCount < 2) return; + + const { aiCleanupProfile } = await import("./user-profile/ai-cleanup.js"); + const result = await aiCleanupProfile(profileData); + if (result.diff.merged.length === 0 && result.diff.removed.length === 0) return; + + const updated = await userProfileManager.updateProfile( + profile.id, + result.cleaned, + 0, + `Automatic AI cleanup: ${result.diff.merged.length} merged, ${result.diff.removed.length} removed` + ); + log("user-profile-learning: automatic cleanup complete", { + userId, + updated, + merged: result.diff.merged.length, + removed: result.diff.removed.length, + }); + } catch (error) { + log("user-profile-learning: automatic cleanup failed", { userId, error: String(error) }); + } +} + export async function performUserProfileLearning( ctx: PluginInput, directory: string @@ -138,6 +182,7 @@ Rules: } const { raw: llmResult, merged: initialMerged } = analysisResult; + let cleanupPreviousPromptCount = 0; if (existingProfile) { let updatedProfileData = initialMerged!; @@ -163,6 +208,7 @@ Rules: profileId: existingProfile.id, }); } + cleanupPreviousPromptCount = existingProfile.totalPromptsAnalyzed; let changeSummary = generateChangeSummary( JSON.parse(existingProfile.profileData), @@ -219,6 +265,10 @@ Rules: await userPromptManager.markMultipleAsUserLearningCaptured(prompts.map((p) => p.id)); } + if (shouldRunAutomaticProfileCleanup(cleanupPreviousPromptCount, prompts.length)) { + await runAutomaticProfileCleanup(userId); + } + if (CONFIG.showUserProfileToasts) { await ctx.client?.tui .showToast({ @@ -375,6 +425,128 @@ CRITICAL: Only output observations grounded in the RECENT PROMPTS above. Write d return truncate(base); } +/** Upper bound for LLM-inferred preference confidence (0–1 scale). */ +export const USER_PROFILE_LLM_CONFIDENCE_MAX = 1; + +/** Shared analysis schema for OpenCode structured output and external tool calls. */ +export function createUserProfileAnalysisSchema(z: typeof import("zod").z) { + return z.object({ + preferences: z.array( + z.object({ + category: z.string(), + description: z.string(), + confidence: z.number().min(0).max(USER_PROFILE_LLM_CONFIDENCE_MAX), + evidence: z.array(z.string()), + }) + ), + patterns: z.array( + z.object({ + category: z.string(), + description: z.string(), + }) + ), + workflows: z.array( + z.object({ + description: z.string(), + steps: z.array(z.string()), + }) + ), + validations: z + .array( + z.object({ + index: z.number(), + verdict: z.enum([ + "confirmed", + "contradicted", + "no_evidence", + "inaccurate", + "oversimplified", + ]), + reason: z.string(), + }) + ) + .optional(), + }); +} + +export function createUserProfileToolSchema(existingProfile: boolean) { + return { + type: "function" as const, + function: { + name: "update_user_profile", + description: existingProfile + ? "Update existing user profile with new insights" + : "Create new user profile", + parameters: { + type: "object", + properties: { + preferences: { + type: "array", + items: { + type: "object", + properties: { + category: { type: "string" }, + description: { type: "string" }, + confidence: { + type: "number", + minimum: 0, + maximum: USER_PROFILE_LLM_CONFIDENCE_MAX, + }, + evidence: { type: "array", items: { type: "string" }, maxItems: 3 }, + }, + required: ["category", "description", "confidence", "evidence"], + }, + }, + patterns: { + type: "array", + items: { + type: "object", + properties: { + category: { type: "string" }, + description: { type: "string" }, + }, + required: ["category", "description"], + }, + }, + workflows: { + type: "array", + items: { + type: "object", + properties: { + description: { type: "string" }, + steps: { type: "array", items: { type: "string" } }, + }, + required: ["description", "steps"], + }, + }, + validations: { + type: "array", + items: { + type: "object", + properties: { + index: { type: "number" }, + verdict: { + type: "string", + enum: [ + "confirmed", + "contradicted", + "no_evidence", + "inaccurate", + "oversimplified", + ], + }, + reason: { type: "string" }, + }, + required: ["index", "verdict", "reason"], + }, + }, + }, + required: ["preferences", "patterns", "workflows"], + }, + }, + }; +} + type AnalysisResult = { raw: UserProfileData; merged: UserProfileData | null }; function applyValidations( @@ -484,43 +656,7 @@ CRITICAL: All JSON string values MUST escape double quotes with backslash. Do NO Use the update_user_profile tool to save the ${existingProfile ? "updated" : "new"} profile.`; const { z } = await import("zod"); - const schema = z.object({ - preferences: z.array( - z.object({ - category: z.string(), - description: z.string(), - confidence: z.number().min(0).max(0.5), - evidence: z.array(z.string()), - }) - ), - patterns: z.array( - z.object({ - category: z.string(), - description: z.string(), - }) - ), - workflows: z.array( - z.object({ - description: z.string(), - steps: z.array(z.string()), - }) - ), - validations: z - .array( - z.object({ - index: z.number(), - verdict: z.enum([ - "confirmed", - "contradicted", - "no_evidence", - "inaccurate", - "oversimplified", - ]), - reason: z.string(), - }) - ) - .optional(), - }); + const schema = createUserProfileAnalysisSchema(z); log("user-profile-learning: calling LLM", { contextLen: context.length }); @@ -589,77 +725,7 @@ CRITICAL: All JSON string values MUST escape double quotes with backslash. Do NO Use the update_user_profile tool to save the ${existingProfile ? "updated" : "new"} profile.`; - const toolSchema = { - type: "function" as const, - function: { - name: "update_user_profile", - description: existingProfile - ? "Update existing user profile with new insights" - : "Create new user profile", - parameters: { - type: "object", - properties: { - preferences: { - type: "array", - items: { - type: "object", - properties: { - category: { type: "string" }, - description: { type: "string" }, - confidence: { type: "number", minimum: 0, maximum: 0.5 }, - evidence: { type: "array", items: { type: "string" }, maxItems: 3 }, - }, - required: ["category", "description", "confidence", "evidence"], - }, - }, - patterns: { - type: "array", - items: { - type: "object", - properties: { - category: { type: "string" }, - description: { type: "string" }, - }, - required: ["category", "description"], - }, - }, - workflows: { - type: "array", - items: { - type: "object", - properties: { - description: { type: "string" }, - steps: { type: "array", items: { type: "string" } }, - }, - required: ["description", "steps"], - }, - }, - validations: { - type: "array", - items: { - type: "object", - properties: { - index: { type: "number" }, - verdict: { - type: "string", - enum: [ - "confirmed", - "contradicted", - "no_evidence", - "inaccurate", - "oversimplified", - ], - }, - reason: { type: "string" }, - }, - required: ["index", "verdict", "reason"], - }, - }, - }, - required: ["preferences", "patterns", "workflows"], - }, - }, - }; + const toolSchema = createUserProfileToolSchema(Boolean(existingProfile)); const result = await provider.executeToolCall( systemPrompt, diff --git a/src/services/user-profile/ai-cleanup.ts b/src/services/user-profile/ai-cleanup.ts index a7aff30e..647f49a8 100644 --- a/src/services/user-profile/ai-cleanup.ts +++ b/src/services/user-profile/ai-cleanup.ts @@ -36,7 +36,7 @@ export async function aiCleanupProfile(profileData: UserProfileData): Promise, originalById: Map, @@ -522,7 +522,7 @@ function rebuildProfileUsing( } const unmentionedIds = new Set(); for (const id of allOriginalIds) { - if (!keptIds.has(id) && !mapping.removed.includes(id)) { + if (!keptIds.has(id) && !mergedSourceIds.has(id) && !mapping.removed.includes(id)) { unmentionedIds.add(id); } } @@ -548,7 +548,11 @@ function rebuildProfileUsing( return result; } -function generateDiff(original: IndexedProfile, mapping: AIMapping): CleanupDiff { +function generateDiff( + original: IndexedProfile, + mapping: AIMapping, + cleanedById: Map +): CleanupDiff { const index = buildItemIndex(original); const diff: CleanupDiff = { @@ -557,7 +561,7 @@ function generateDiff(original: IndexedProfile, mapping: AIMapping): CleanupDiff const first = group[0] ?? ""; return { ids: group, - result: index.get(first)?.description || first, + result: cleanedById.get(first)?.description || index.get(first)?.description || first, }; }), removed: mapping.removed.map((id) => ({ diff --git a/src/services/user-profile/user-profile-manager.ts b/src/services/user-profile/user-profile-manager.ts index 6e41e96e..a5aff065 100644 --- a/src/services/user-profile/user-profile-manager.ts +++ b/src/services/user-profile/user-profile-manager.ts @@ -417,13 +417,20 @@ export class UserProfileManager { const age = now - ((item as any).lastSeen || now); const ageDays = age / (24 * 60 * 60 * 1000); - const alpha = (item as any).alpha ?? 1; - - if (alpha <= 2 && ageDays > 30) { + const staleDays = CONFIG.userProfileStaleDays ?? 2; + const minEvidence = CONFIG.userProfileMinEvidenceForRetention ?? 3; + const evidenceCount = Array.isArray((item as any).evidence) + ? (item as any).evidence.length + : 0; + + // Remove inactive, low-evidence items using configured retention thresholds. + if (ageDays > staleDays && evidenceCount < minEvidence) { log("profile decay: removed stale", { cat: (item as any).category || (item as any).description?.substring(0, 30), - alpha, + evidenceCount, + minEvidence, ageDays: Math.round(ageDays), + staleDays, }); hasChanges = true; return false; @@ -1273,12 +1280,8 @@ export class UserProfileManager { const needsAlphaMigration = item.alpha === undefined || (item.alpha === 0.5 && item.beta === 1.5); if (needsAlphaMigration) { - const conf = item.confidence ?? 1.0; - if (conf >= 1.0) { - item.alpha = 1 + (item.frequency || 1) * 0.5; - } else { - item.alpha = conf / (1 - conf + 0.01); - } + const conf = Math.max(0, Math.min(1, item.confidence ?? 1.0)); + item.alpha = conf / (1 - conf + 0.01); item.beta = 1; } item.weakAlpha = item.weakAlpha ?? 1; diff --git a/src/services/web-server.ts b/src/services/web-server.ts index 9fceb8f8..1fd63692 100644 --- a/src/services/web-server.ts +++ b/src/services/web-server.ts @@ -567,7 +567,9 @@ export class WebServer { const includeIds = Array.isArray(body.includeIds) ? (body.includeIds as string[]) : undefined; - const result = await handleAICleanup(userId, includeIds); + const profileVersion = + typeof body.profileVersion === "number" ? body.profileVersion : undefined; + const result = await handleAICleanup(userId, includeIds, profileVersion); return this.jsonResponse(result); } diff --git a/src/shared/api/schemas.ts b/src/shared/api/schemas.ts index cb3440e1..31194eed 100644 --- a/src/shared/api/schemas.ts +++ b/src/shared/api/schemas.ts @@ -109,6 +109,7 @@ export const PendingCleanupSchema = z.object({ export const AICleanupRequestSchema = z.object({ userId: z.string().optional(), includeIds: z.array(z.string()).optional(), + profileVersion: z.number().int().nonnegative().optional(), }); export const ApplyCleanupRequestSchema = z.object({ diff --git a/src/utils/context-limit.ts b/src/utils/context-limit.ts new file mode 100644 index 00000000..ed8f4e40 --- /dev/null +++ b/src/utils/context-limit.ts @@ -0,0 +1,53 @@ +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +/** UTF-8 byte length of a string. */ +export function utf8ByteLength(text: string): number { + return encoder.encode(text).byteLength; +} + +/** Decode a UTF-8 byte slice without splitting multi-byte characters. */ +export function sliceUtf8Bytes(text: string, start: number, end?: number): string { + const bytes = encoder.encode(text); + let from = Math.max(0, Math.min(start, bytes.length)); + let to = Math.min(bytes.length, end ?? bytes.length); + if (to <= from) return ""; + + // If `from` lands inside a multi-byte character, advance to the next lead byte. + while (from < to && (bytes[from]! & 0xc0) === 0x80) { + from++; + } + + // If `to` lands inside a multi-byte character, back up to that character's start. + while (to > from && (bytes[to]! & 0xc0) === 0x80) { + to--; + } + + return decoder.decode(bytes.subarray(from, to)); +} + +/** + * Truncate text to at most `maxBytes` UTF-8 bytes. + * Prefers keeping the start and end (head + tail) when space allows, + * so summaries retain both opening context and closing conclusions. + */ +export function truncateToMaxBytes( + text: string, + maxBytes: number, + marker = "\n[... truncated ...]\n" +): string { + if (maxBytes <= 0) return ""; + if (utf8ByteLength(text) <= maxBytes) return text; + + const markerBytes = utf8ByteLength(marker); + if (maxBytes <= markerBytes) { + return sliceUtf8Bytes(text, 0, maxBytes); + } + + const available = maxBytes - markerBytes; + const headBytes = Math.floor(available / 2); + const tailBytes = available - headBytes; + const totalBytes = utf8ByteLength(text); + + return sliceUtf8Bytes(text, 0, headBytes) + marker + sliceUtf8Bytes(text, totalBytes - tailBytes); +} diff --git a/tests/ai-cleanup-apply.test.ts b/tests/ai-cleanup-apply.test.ts new file mode 100644 index 00000000..f842f292 --- /dev/null +++ b/tests/ai-cleanup-apply.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from "bun:test"; +import { mergeCleanupIntoProfile } from "../src/services/api-handlers.js"; +import { rebuildProfileUsing } from "../src/services/user-profile/ai-cleanup.js"; +import type { UserProfileData } from "../src/services/user-profile/types.js"; + +function pref(description: string, category = "style") { + return { + category, + description, + confidence: 0.5, + evidence: ["e1"], + }; +} + +function baseProfile(descriptions: string[]): UserProfileData { + return { + preferences: descriptions.map((d) => pref(d)), + patterns: [], + workflows: [], + }; +} + +describe("mergeCleanupIntoProfile (#237)", () => { + it("preserves unselected items when applying a scoped cleanup", () => { + const oldProfile = baseProfile([ + "keep-0", + "keep-1", + "scope-a", + "scope-b", + "keep-4", + "keep-5", + "keep-6", + "keep-7", + "keep-8", + "keep-9", + ]); + + const cleaned = baseProfile(["scope-a-merged"]); + const result = mergeCleanupIntoProfile({ + currentProfile: oldProfile, + oldProfileData: oldProfile, + cleanedData: cleaned, + includeIds: ["pref_2", "pref_3"], + acceptedMerged: [["pref_2", "pref_3"]], + acceptedRemoved: [], + allMerged: [{ ids: ["pref_2", "pref_3"], result: "scope-a-merged" }], + allRemovedIds: [], + explicitAcceptance: true, + }); + + const descs = result.preferences.map((p) => p.description); + expect(descs).toContain("keep-0"); + expect(descs).toContain("keep-9"); + expect(descs).toContain("scope-a-merged"); + expect(descs).not.toContain("scope-a"); + expect(descs).not.toContain("scope-b"); + expect(descs.filter((d) => d.startsWith("keep-"))).toHaveLength(8); + }); + + it("restores items when a removal is rejected", () => { + const oldProfile = baseProfile(["keep", "remove-me"]); + const cleaned = baseProfile(["keep"]); + + const result = mergeCleanupIntoProfile({ + currentProfile: oldProfile, + oldProfileData: oldProfile, + cleanedData: cleaned, + includeIds: ["pref_0", "pref_1"], + acceptedMerged: [], + acceptedRemoved: [], // removal rejected + allMerged: [], + allRemovedIds: ["pref_1"], + explicitAcceptance: true, + }); + + const descs = result.preferences.map((p) => p.description); + expect(descs).toContain("keep"); + expect(descs).toContain("remove-me"); + }); + + it("removes items when a removal is accepted", () => { + const oldProfile = baseProfile(["keep", "remove-me", "outside"]); + const cleaned = baseProfile(["keep"]); + + const result = mergeCleanupIntoProfile({ + currentProfile: oldProfile, + oldProfileData: oldProfile, + cleanedData: cleaned, + includeIds: ["pref_0", "pref_1"], + acceptedMerged: [], + acceptedRemoved: ["pref_1"], + allMerged: [], + allRemovedIds: ["pref_1"], + explicitAcceptance: true, + }); + + const descs = result.preferences.map((p) => p.description); + expect(descs).toContain("keep"); + expect(descs).toContain("outside"); + expect(descs).not.toContain("remove-me"); + }); + + it("restores merge sources when a merge is rejected", () => { + const oldProfile = baseProfile(["target", "source", "outside"]); + const cleaned = baseProfile(["target-merged"]); + + const result = mergeCleanupIntoProfile({ + currentProfile: oldProfile, + oldProfileData: oldProfile, + cleanedData: cleaned, + includeIds: ["pref_0", "pref_1"], + acceptedMerged: [], // merge rejected + acceptedRemoved: [], + allMerged: [{ ids: ["pref_0", "pref_1"], result: "target-merged" }], + allRemovedIds: [], + explicitAcceptance: true, + }); + + const descs = result.preferences.map((p) => p.description); + expect(descs).toContain("outside"); + expect(descs).toContain("source"); + expect(descs).toContain("target"); + expect(descs).not.toContain("target-merged"); + }); + + it("preserves items added after analysis", () => { + const oldProfile = baseProfile(["scope-a", "keep"]); + const currentProfile = baseProfile(["scope-a", "keep", "brand-new"]); + const cleaned = baseProfile(["scope-a-clean"]); + + const result = mergeCleanupIntoProfile({ + currentProfile, + oldProfileData: oldProfile, + cleanedData: cleaned, + includeIds: ["pref_0"], + acceptedMerged: [], + acceptedRemoved: [], + allMerged: [], + allRemovedIds: [], + explicitAcceptance: false, + }); + + const descs = result.preferences.map((p) => p.description); + expect(descs).toContain("keep"); + expect(descs).toContain("brand-new"); + expect(descs).toContain("scope-a-clean"); + expect(descs).not.toContain("scope-a"); + }); + + it("replaces the full profile when includeIds is unset", () => { + const oldProfile = baseProfile(["a", "b", "c"]); + const cleaned = baseProfile(["a-clean"]); + + const result = mergeCleanupIntoProfile({ + currentProfile: oldProfile, + oldProfileData: oldProfile, + cleanedData: cleaned, + acceptedMerged: [], + acceptedRemoved: [], + allMerged: [], + allRemovedIds: ["pref_1", "pref_2"], + explicitAcceptance: false, + }); + + expect(result.preferences.map((p) => p.description)).toEqual(["a-clean"]); + }); + + it("removes only the selected occurrence when descriptions are duplicated", () => { + const oldProfile = baseProfile(["duplicate", "duplicate", "outside"]); + const cleaned = baseProfile(["duplicate-cleaned"]); + + const result = mergeCleanupIntoProfile({ + currentProfile: oldProfile, + oldProfileData: oldProfile, + cleanedData: cleaned, + includeIds: ["pref_0"], + acceptedMerged: [], + acceptedRemoved: [], + allMerged: [], + allRemovedIds: [], + explicitAcceptance: false, + }); + + expect(result.preferences.map((p) => p.description)).toEqual([ + "duplicate", + "outside", + "duplicate-cleaned", + ]); + }); + + it("restores a rejected merge without duplicating items whose derived metadata changed", () => { + const oldProfile = baseProfile(["duplicate", "duplicate", "outside"]); + oldProfile.preferences[1]!.centroid = [0.1, 0.2]; + const currentProfile = structuredClone(oldProfile); + currentProfile.preferences[1]!.centroid = undefined; + const cleaned = baseProfile(["duplicate"]); + + const result = mergeCleanupIntoProfile({ + currentProfile, + oldProfileData: oldProfile, + cleanedData: cleaned, + includeIds: ["pref_0", "pref_1"], + acceptedMerged: [], + acceptedRemoved: [], + allMerged: [{ ids: ["pref_0", "pref_1"], result: "duplicate" }], + allRemovedIds: [], + explicitAcceptance: true, + }); + + expect(result.preferences).toHaveLength(3); + expect(result.preferences.filter((p) => p.description === "duplicate")).toHaveLength(2); + expect(result.preferences.map((p) => p.description)).toContain("outside"); + }); +}); + +describe("rebuildProfileUsing (#237)", () => { + it("does not preserve merge sources as unmentioned items", () => { + const target = { id: "pref_0", ...pref("duplicate"), frequency: 2, lastSeen: 1 }; + const source = { id: "pref_1", ...pref("duplicate"), frequency: 3, lastSeen: 1 }; + const cleanedTarget = { id: "pref_0", ...pref("duplicate"), frequency: 5, lastSeen: 1 }; + + const result = rebuildProfileUsing( + { kept: [], merged: [["pref_0", "pref_1"]], removed: [] }, + new Map([["pref_0", cleanedTarget]]), + new Map([ + ["pref_0", target], + ["pref_1", source], + ]) + ); + + expect(result.preferences).toHaveLength(1); + expect(result.preferences[0]?.description).toBe("duplicate"); + }); +}); diff --git a/tests/auto-capture-context.test.ts b/tests/auto-capture-context.test.ts new file mode 100644 index 00000000..681df4b4 --- /dev/null +++ b/tests/auto-capture-context.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "bun:test"; +import { + buildBoundedSummaryPrompt, + buildMarkdownContext, + getAutoCaptureMarkdownBudget, +} from "../src/services/auto-capture.js"; +import { utf8ByteLength } from "../src/utils/context-limit.js"; + +describe("buildMarkdownContext budgeting (#232)", () => { + it("leaves small contexts unchanged", () => { + const context = buildMarkdownContext( + "Fix the bug", + ["Done. Updated the handler."], + [{ name: "edit", input: "file.ts" }], + "Prior memory", + 131072 + ); + + expect(context).toContain("## User Request"); + expect(context).toContain("Fix the bug"); + expect(context).toContain("Done. Updated the handler."); + expect(context).toContain("## Tools Used"); + expect(context).not.toContain("truncated to autoCaptureMaxContextBytes"); + }); + + it("truncates oversized AI responses within the total budget", () => { + const hugeAi = "A".repeat(50_000); + const maxBytes = 8_192; + const context = buildMarkdownContext("short request", [hugeAi], [], null, maxBytes); + + expect(utf8ByteLength(context)).toBeLessThanOrEqual(maxBytes); + expect(context).toContain("short request"); + expect(context).toContain("truncated to autoCaptureMaxContextBytes"); + }); + + it("prefers newer AI responses when multiple turns exceed the budget", () => { + const older = "OLD-" + "x".repeat(4_000); + const newer = "NEW-" + "y".repeat(4_000); + const maxBytes = 3_000; + const context = buildMarkdownContext("q", [older, newer], [], null, maxBytes); + + expect(utf8ByteLength(context)).toBeLessThanOrEqual(maxBytes); + expect(context).toContain("NEW-"); + // Older content may be dropped entirely once the newest turn fills the budget. + expect(context.includes("OLD-") && !context.includes("NEW-")).toBe(false); + }); + + it("preserves section order: memory, user, AI, tools", () => { + const context = buildMarkdownContext( + "request", + ["response"], + [{ name: "bash", input: "ls" }], + "memory", + 131072 + ); + + const memoryIdx = context.indexOf("## Previous Memory Context"); + const userIdx = context.indexOf("## User Request"); + const aiIdx = context.indexOf("## AI Response"); + const toolsIdx = context.indexOf("## Tools Used"); + + expect(memoryIdx).toBeGreaterThanOrEqual(0); + expect(userIdx).toBeGreaterThan(memoryIdx); + expect(aiIdx).toBeGreaterThan(userIdx); + expect(toolsIdx).toBeGreaterThan(aiIdx); + }); + + it("guarantees the total UTF-8 size never exceeds the configured limit", () => { + const context = buildMarkdownContext( + "U".repeat(20_000), + ["A".repeat(40_000), "B".repeat(40_000)], + [{ name: "read", input: "x".repeat(100) }], + "M".repeat(500), + 10_000 + ); + + expect(utf8ByteLength(context)).toBeLessThanOrEqual(10_000); + }); + + it("reserves request space for prompts, schemas, and model output", () => { + const totalBudget = 131_072; + const markdownBudget = getAutoCaptureMarkdownBudget(totalBudget); + const context = buildMarkdownContext( + "request", + ["A".repeat(totalBudget)], + [], + null, + markdownBudget + ); + const systemPrompt = "system instructions"; + const schema = { type: "object", properties: { summary: { type: "string" } } }; + const userPrompt = buildBoundedSummaryPrompt(context, systemPrompt, schema, totalBudget); + + expect(markdownBudget).toBeLessThan(totalBudget); + expect(utf8ByteLength(userPrompt)).toBeLessThan(totalBudget); + expect(userPrompt).toContain("truncated to autoCaptureMaxContextBytes"); + }); +}); diff --git a/tests/compaction-agent-preservation.test.ts b/tests/compaction-agent-preservation.test.ts new file mode 100644 index 00000000..614e9c4c --- /dev/null +++ b/tests/compaction-agent-preservation.test.ts @@ -0,0 +1,235 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolveSessionAgent } from "../src/index.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("resolveSessionAgent (#236)", () => { + it("prefers session.get().agent when available", async () => { + const client = { + session: { + get: async () => ({ data: { agent: "orchestrator" } }), + messages: async () => ({ + data: [{ info: { role: "user", agent: "build" } }], + }), + }, + }; + + await expect(resolveSessionAgent(client, "ses-1")).resolves.toBe("orchestrator"); + }); + + it("falls back to the latest user message agent", async () => { + const client = { + session: { + get: async () => ({ data: {} }), + messages: async () => ({ + data: [ + { info: { role: "user", agent: "build" } }, + { info: { role: "assistant", mode: "build" } }, + { info: { role: "user", agent: "my-orchestrator" } }, + ], + }), + }, + }; + + await expect(resolveSessionAgent(client, "ses-1")).resolves.toBe("my-orchestrator"); + }); + + it("uses assistant mode when no user agent is present", async () => { + const client = { + session: { + get: async () => ({ data: {} }), + messages: async () => ({ + data: [ + { info: { role: "assistant", mode: "build" } }, + { info: { role: "assistant", mode: "compaction", summary: true } }, + ], + }), + }, + }; + + await expect(resolveSessionAgent(client, "ses-1")).resolves.toBe("build"); + }); + + it("skips compaction summary messages", async () => { + const client = { + session: { + get: async () => ({}), + messages: async () => ({ + data: [ + { info: { role: "user", agent: "custom-agent" } }, + { info: { role: "assistant", mode: "compaction", summary: true } }, + ], + }), + }, + }; + + await expect(resolveSessionAgent(client, "ses-1")).resolves.toBe("custom-agent"); + }); +}); + +const indexUrl = new URL("../src/index.js", import.meta.url).href; +const clientUrl = new URL("../src/services/client.js", import.meta.url).href; +const configUrl = new URL("../src/config.js", import.meta.url).href; +const tagsUrl = new URL("../src/services/tags.js", import.meta.url).href; +const contextUrl = new URL("../src/services/context.js", import.meta.url).href; +const privacyUrl = new URL("../src/services/privacy.js", import.meta.url).href; +const autoCaptureUrl = new URL("../src/services/auto-capture.js", import.meta.url).href; +const learningUrl = new URL("../src/services/user-memory-learning.js", import.meta.url).href; +const promptManagerUrl = new URL( + "../src/services/user-prompt/user-prompt-manager.js", + import.meta.url +).href; +const webServerUrl = new URL("../src/services/web-server.js", import.meta.url).href; +const loggerUrl = new URL("../src/services/logger.js", import.meta.url).href; +const languageUrl = new URL("../src/services/language-detector.js", import.meta.url).href; + +function runCompactionScenario(opts: { + memories: Array<{ memory: string; tags?: string[] }>; + messages: Array<{ info: Record }>; + sessionAgent?: string; + compactionEnabled?: boolean; +}) { + const dir = mkdtempSync(join(tmpdir(), "opencode-mem-compaction-agent-")); + tempDirs.push(dir); + const scriptPath = join(dir, "scenario.mjs"); + + const script = ` +import { mock } from "bun:test"; + +const promptCalls = []; + +mock.module(${JSON.stringify(clientUrl)}, () => ({ + memoryClient: { + warmup: async () => {}, + isReady: async () => true, + searchMemoriesBySessionID: async () => ({ + success: true, + results: ${JSON.stringify(opts.memories)}, + total: ${opts.memories.length}, + }), + close() {}, + }, +})); + +mock.module(${JSON.stringify(configUrl)}, () => ({ + CONFIG: { + compaction: { enabled: ${opts.compactionEnabled !== false}, memoryLimit: 10 }, + autoCaptureEnabled: false, + }, + initConfig: () => {}, + isConfigured: () => true, +})); + +mock.module(${JSON.stringify(tagsUrl)}, () => ({ + getTags: () => ({ project: { tag: "project-tag" }, user: { userEmail: "u@example.com" } }), +})); +mock.module(${JSON.stringify(contextUrl)}, () => ({ formatContextForPrompt: () => "" })); +mock.module(${JSON.stringify(privacyUrl)}, () => ({ + stripPrivateContent: (value) => value, + isFullyPrivate: () => false, +})); +mock.module(${JSON.stringify(autoCaptureUrl)}, () => ({ performAutoCapture: async () => {} })); +mock.module(${JSON.stringify(learningUrl)}, () => ({ performUserProfileLearning: async () => {} })); +mock.module(${JSON.stringify(promptManagerUrl)}, () => ({ userPromptManager: { savePrompt() {} } })); +mock.module(${JSON.stringify(webServerUrl)}, () => ({ + startWebServer: async () => null, + WebServer: class {}, +})); +mock.module(${JSON.stringify(loggerUrl)}, () => ({ log: () => {} })); +mock.module(${JSON.stringify(languageUrl)}, () => ({ getLanguageName: () => "English" })); + +const mockClient = { + session: { + get: async () => ({ data: ${JSON.stringify({ agent: opts.sessionAgent })} }), + messages: async () => ({ data: ${JSON.stringify(opts.messages)} }), + prompt: async (args) => { + promptCalls.push(args); + return {}; + }, + }, + tui: { showToast: async () => ({}) }, +}; + +const { OpenCodeMemPlugin } = await import(${JSON.stringify(indexUrl)}); +const plugin = await OpenCodeMemPlugin({ directory: "/workspace", client: mockClient }); +await plugin.event({ + event: { type: "session.compacted", properties: { sessionID: "ses-1" } }, +}); + +console.log(JSON.stringify({ promptCalls })); +`; + + writeFileSync(scriptPath, script); + const result = Bun.spawnSync({ + cmd: [process.execPath, scriptPath], + stdout: "pipe", + stderr: "pipe", + }); + + const stdout = Buffer.from(result.stdout).toString("utf8").trim(); + const stderr = Buffer.from(result.stderr).toString("utf8").trim(); + return { + exitCode: result.exitCode, + stdout, + stderr, + parsed: stdout ? JSON.parse(stdout) : null, + }; +} + +describe("session.compacted agent preservation (#236)", () => { + it("passes the resolved custom agent to session.prompt", () => { + const result = runCompactionScenario({ + sessionAgent: "my-orchestrator", + memories: [{ memory: "remember this", tags: ["t1"] }], + messages: [{ info: { role: "user", agent: "my-orchestrator" } }], + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.parsed?.promptCalls).toHaveLength(1); + expect(result.parsed?.promptCalls[0]?.body?.agent).toBe("my-orchestrator"); + expect(result.parsed?.promptCalls[0]?.body?.noReply).toBe(true); + }); + + it("does not call session.prompt when there are no memories", () => { + const result = runCompactionScenario({ + sessionAgent: "my-orchestrator", + memories: [], + messages: [{ info: { role: "user", agent: "my-orchestrator" } }], + }); + + expect(result.exitCode).toBe(0); + expect(result.parsed?.promptCalls).toEqual([]); + }); + + it("does not inject memories when the active agent cannot be resolved", () => { + const result = runCompactionScenario({ + memories: [{ memory: "remember this" }], + messages: [{ info: { role: "assistant", mode: "compaction", summary: true } }], + }); + + expect(result.exitCode).toBe(0); + expect(result.parsed?.promptCalls).toEqual([]); + }); + + it("does nothing when compaction is disabled", () => { + const result = runCompactionScenario({ + compactionEnabled: false, + sessionAgent: "my-orchestrator", + memories: [{ memory: "remember this" }], + messages: [{ info: { role: "user", agent: "my-orchestrator" } }], + }); + + expect(result.exitCode).toBe(0); + expect(result.parsed?.promptCalls).toEqual([]); + }); +}); diff --git a/tests/config.test.ts b/tests/config.test.ts index 4757a5b0..3438710a 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -15,6 +15,7 @@ const { hasAutoCaptureProviderConfig, isConfigured, isPlaceholderApiKey, + normalizeAutoCaptureMaxContextBytes, } = await import("../src/config.js"); afterAll(() => { @@ -88,6 +89,16 @@ describe("config", () => { expect(typeof CONFIG.deduplicationEnabled).toBe("boolean"); }); + it("should default autoCaptureMaxContextBytes to 131072", () => { + expect(CONFIG.autoCaptureMaxContextBytes).toBe(131072); + }); + + it("should reject unsafe auto-capture context budgets", () => { + expect(() => normalizeAutoCaptureMaxContextBytes(-1)).toThrow(); + expect(() => normalizeAutoCaptureMaxContextBytes(1024)).toThrow(); + expect(() => normalizeAutoCaptureMaxContextBytes(16 * 1024 * 1024 + 1)).toThrow(); + }); + it("should expose memory scope config", () => { const defaultScope = CONFIG.memory.defaultScope ?? "project"; expect(["project", "all-projects"]).toContain(defaultScope); @@ -100,6 +111,10 @@ describe("config", () => { expect(typeof CONFIG.userProfileDisplayWorkflows).toBe("number"); expect(typeof CONFIG.userProfileConfidenceDecayDays).toBe("number"); expect(typeof CONFIG.userProfileChangelogRetentionCount).toBe("number"); + expect(CONFIG.userProfileStaleDays).toBe(2); + expect(CONFIG.userProfileMinEvidenceForRetention).toBe(3); + expect(CONFIG.userProfileAutoCleanupEnabled).toBe(true); + expect(CONFIG.userProfileAutoCleanupInterval).toBe(100); }); it("should have toast settings as booleans", () => { diff --git a/tests/context-limit.test.ts b/tests/context-limit.test.ts new file mode 100644 index 00000000..7da885b0 --- /dev/null +++ b/tests/context-limit.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "bun:test"; +import { sliceUtf8Bytes, truncateToMaxBytes, utf8ByteLength } from "../src/utils/context-limit.js"; + +describe("context-limit utilities (#232)", () => { + it("counts UTF-8 bytes, not JS string length", () => { + expect(utf8ByteLength("a")).toBe(1); + expect(utf8ByteLength("ä")).toBe(2); + expect(utf8ByteLength("你好")).toBe(6); + }); + + it("returns text unchanged when under the budget", () => { + expect(truncateToMaxBytes("hello world", 100)).toBe("hello world"); + }); + + it("keeps head and tail when truncating", () => { + const text = "AAAA" + "x".repeat(200) + "BBBB"; + const truncated = truncateToMaxBytes(text, 40, "|TRUNC|"); + expect(utf8ByteLength(truncated)).toBeLessThanOrEqual(40); + expect(truncated.startsWith("AAAA")).toBe(true); + expect(truncated.includes("|TRUNC|")).toBe(true); + expect(truncated.endsWith("BBBB")).toBe(true); + }); + + it("slices multi-byte characters on UTF-8 boundaries", () => { + const text = "ä".repeat(10); + const sliced = sliceUtf8Bytes(text, 0, 3); + expect(utf8ByteLength(sliced)).toBeLessThanOrEqual(3); + expect(sliced.includes("�")).toBe(false); + }); +}); diff --git a/tests/user-profile-decay.test.ts b/tests/user-profile-decay.test.ts new file mode 100644 index 00000000..72a55797 --- /dev/null +++ b/tests/user-profile-decay.test.ts @@ -0,0 +1,156 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { tursoConnectionManager } from "../src/services/turso/connection-manager.js"; + +let tmpDir: string; +const DAY_MS = 24 * 60 * 60 * 1000; + +async function makeManager() { + const { CONFIG } = await import("../src/config.js"); + CONFIG.storagePath = tmpDir; + const { UserProfileManager } = + await import("../src/services/user-profile/user-profile-manager.js"); + return { mgr: new UserProfileManager(), CONFIG }; +} + +describe("user profile decay (#237)", () => { + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "opencode-mem-decay-")); + }); + + afterEach(async () => { + await tursoConnectionManager.closeAll(); + await new Promise((r) => setTimeout(r, 50)); + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch {} + }); + + it("removes stale items below minEvidence after staleDays", async () => { + const { mgr, CONFIG } = await makeManager(); + CONFIG.userProfileStaleDays = 2; + CONFIG.userProfileMinEvidenceForRetention = 3; + + const now = Date.now(); + const { data, hasChanges } = mgr.decayInMemory({ + preferences: [ + { + category: "style", + description: "stale low evidence", + confidence: 0.4, + evidence: ["once"], + lastSeen: now - 3 * DAY_MS, + alpha: 5, + beta: 1, + }, + ], + patterns: [], + workflows: [], + }); + + expect(hasChanges).toBe(true); + expect(data.preferences).toHaveLength(0); + }); + + it("keeps items with enough evidence even when stale", async () => { + const { mgr, CONFIG } = await makeManager(); + CONFIG.userProfileStaleDays = 2; + CONFIG.userProfileMinEvidenceForRetention = 3; + + const now = Date.now(); + const { data } = mgr.decayInMemory({ + preferences: [ + { + category: "style", + description: "well evidenced", + confidence: 0.6, + evidence: ["a", "b", "c"], + lastSeen: now - 10 * DAY_MS, + alpha: 5, + beta: 1, + }, + ], + patterns: [], + workflows: [], + }); + + expect(data.preferences).toHaveLength(1); + expect(data.preferences[0]?.description).toBe("well evidenced"); + }); + + it("keeps items within staleDays regardless of evidence", async () => { + const { mgr, CONFIG } = await makeManager(); + CONFIG.userProfileStaleDays = 2; + CONFIG.userProfileMinEvidenceForRetention = 3; + + const now = Date.now(); + const { data } = mgr.decayInMemory({ + preferences: [ + { + category: "style", + description: "fresh", + confidence: 0.3, + evidence: ["once"], + lastSeen: now - 1 * DAY_MS, + alpha: 1, + beta: 1, + }, + ], + patterns: [], + workflows: [], + }); + + expect(data.preferences).toHaveLength(1); + }); + + it("respects custom staleDays and minEvidence config", async () => { + const { mgr, CONFIG } = await makeManager(); + CONFIG.userProfileStaleDays = 1; + CONFIG.userProfileMinEvidenceForRetention = 5; + + const now = Date.now(); + const { data } = mgr.decayInMemory({ + preferences: [ + { + category: "style", + description: "custom threshold", + confidence: 0.4, + evidence: ["a", "b", "c", "d"], + lastSeen: now - 2 * DAY_MS, + alpha: 10, + beta: 1, + }, + ], + patterns: [], + workflows: [], + }); + + // 4 evidence < 5 and age > 1 day → removed (not the old hardcoded 30-day/alpha gate) + expect(data.preferences).toHaveLength(0); + }); + + it("migrates confidence 1 continuously instead of dropping it to 0.6", async () => { + const { mgr, CONFIG } = await makeManager(); + CONFIG.userProfileStaleDays = 2; + CONFIG.userProfileMinEvidenceForRetention = 3; + + const { data } = mgr.decayInMemory({ + preferences: [ + { + category: "style", + description: "maximum confidence", + confidence: 1, + evidence: ["a", "b", "c"], + frequency: 1, + lastSeen: Date.now(), + }, + ], + patterns: [], + workflows: [], + }); + + expect(data.preferences[0]?.confidence).toBeGreaterThan(0.98); + }); +}); diff --git a/tests/user-profile-learning-confidence.test.ts b/tests/user-profile-learning-confidence.test.ts new file mode 100644 index 00000000..027358fa --- /dev/null +++ b/tests/user-profile-learning-confidence.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "bun:test"; +import { z } from "zod"; +import { + createUserProfileAnalysisSchema, + createUserProfileToolSchema, + shouldRunAutomaticProfileCleanup, + USER_PROFILE_LLM_CONFIDENCE_MAX, +} from "../src/services/user-memory-learning.js"; +import { UserProfileValidator } from "../src/services/ai/validators/user-profile-validator.js"; + +describe("user-profile-learning confidence schema (#231)", () => { + const schema = createUserProfileAnalysisSchema(z); + + const baseProfile = { + preferences: [ + { + category: "style", + description: "Prefers concise answers", + confidence: 0.8, + evidence: ["please keep it short"], + }, + ], + patterns: [], + workflows: [], + }; + + it("accepts confidence values above 0.5 (Groq Llama native 0–1 scale)", () => { + const parsed = schema.parse(baseProfile); + expect(parsed.preferences[0]?.confidence).toBe(0.8); + }); + + it("accepts confidence at the upper bound of 1", () => { + const parsed = schema.parse({ + ...baseProfile, + preferences: [{ ...baseProfile.preferences[0], confidence: 1 }], + }); + expect(parsed.preferences[0]?.confidence).toBe(1); + }); + + it("rejects confidence above 1", () => { + expect(() => + schema.parse({ + ...baseProfile, + preferences: [{ ...baseProfile.preferences[0], confidence: 1.1 }], + }) + ).toThrow(); + }); + + it("exposes matching JSON-schema maximum for the external tool path", () => { + const jsonSchema = createUserProfileToolSchema(false).function.parameters; + + expect(USER_PROFILE_LLM_CONFIDENCE_MAX).toBe(1); + expect(jsonSchema.properties?.preferences?.items?.properties?.confidence?.minimum).toBe(0); + expect(jsonSchema.properties?.preferences?.items?.properties?.confidence?.maximum).toBe( + USER_PROFILE_LLM_CONFIDENCE_MAX + ); + }); + + it("rejects out-of-range confidence during provider response validation", () => { + expect( + UserProfileValidator.validate({ + ...baseProfile, + preferences: [{ ...baseProfile.preferences[0], confidence: 1.1 }], + }).valid + ).toBe(false); + expect(UserProfileValidator.validate(baseProfile).valid).toBe(true); + }); + + it("schedules automatic cleanup only when a prompt interval is crossed", () => { + expect(shouldRunAutomaticProfileCleanup(90, 10, 100)).toBe(true); + expect(shouldRunAutomaticProfileCleanup(100, 10, 100)).toBe(false); + }); +}); diff --git a/web/src/lib/components/explorer/AiCleanupDialog.tsx b/web/src/lib/components/explorer/AiCleanupDialog.tsx index 2e0edd48..19c371d4 100644 --- a/web/src/lib/components/explorer/AiCleanupDialog.tsx +++ b/web/src/lib/components/explorer/AiCleanupDialog.tsx @@ -154,7 +154,7 @@ export function AiCleanupDialog({ open = false, profile = null, onOpenChange, on const result = await fetchAPI("/api/user-profile/ai-cleanup", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ includeIds: ids }), + body: JSON.stringify({ includeIds: ids, profileVersion: profile?.version }), timeout: 180000, }); if (!result.success || !result.data) {