diff --git a/src/services/api-handlers.ts b/src/services/api-handlers.ts index 2f3b82d..c26714c 100644 --- a/src/services/api-handlers.ts +++ b/src/services/api-handlers.ts @@ -1003,19 +1003,24 @@ export async function handleGetProfileSnapshot(changelogId: string): Promise> { try { const { getTags } = await import("./tags.js"); + const { userProfileManager } = await import("./user-profile/user-profile-manager.js"); const { userPromptManager } = await import("./user-prompt/user-prompt-manager.js"); let targetUserId = userId; if (!targetUserId) { const tags = getTags(process.cwd()); targetUserId = tags.user.userEmail || "unknown"; } + const profile = userProfileManager.getActiveProfile(targetUserId); + const decayApplied = profile ? userProfileManager.applyConfidenceDecay(profile.id) : false; const unanalyzedCount = userPromptManager.countUnanalyzedForUserLearning(); return { success: true, data: { - message: "Profile refresh queued", + message: decayApplied ? "Profile confidence decay applied" : "Profile refresh queued", + profileExists: Boolean(profile), + decayApplied, unanalyzedPrompts: unanalyzedCount, - note: "Profile will be updated when threshold is reached", + note: "Confidence decay runs immediately; AI profile learning still runs when the prompt threshold is reached", }, }; } catch (error) { diff --git a/src/services/user-memory-learning.ts b/src/services/user-memory-learning.ts index 7bf2663..af35d1a 100644 --- a/src/services/user-memory-learning.ts +++ b/src/services/user-memory-learning.ts @@ -32,7 +32,10 @@ export async function performUserProfileLearning( const tags = getTags(directory); const userId = tags.user.userEmail || "unknown"; - const existingProfile = userProfileManager.getActiveProfile(userId); + let existingProfile = userProfileManager.getActiveProfile(userId); + if (existingProfile && userProfileManager.applyConfidenceDecay(existingProfile.id)) { + existingProfile = userProfileManager.getActiveProfile(userId); + } const context = buildUserAnalysisContext(prompts, existingProfile); diff --git a/src/services/user-profile/user-profile-manager.ts b/src/services/user-profile/user-profile-manager.ts index c4ab74e..54646c0 100644 --- a/src/services/user-profile/user-profile-manager.ts +++ b/src/services/user-profile/user-profile-manager.ts @@ -86,11 +86,7 @@ export class UserProfileManager { const id = `profile_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; const now = Date.now(); - const cleanedData: UserProfileData = { - preferences: safeArray(profileData.preferences), - patterns: safeArray(profileData.patterns), - workflows: safeArray(profileData.workflows), - }; + const cleanedData = this.normalizeProfileData(profileData, now); const stmt = this.db.prepare(` INSERT INTO user_profiles ( @@ -126,11 +122,7 @@ export class UserProfileManager { ): void { const now = Date.now(); - const cleanedData: UserProfileData = { - preferences: safeArray(profileData.preferences), - patterns: safeArray(profileData.patterns), - workflows: safeArray(profileData.workflows), - }; + const cleanedData = this.normalizeProfileData(profileData, now); const getVersionStmt = this.db.prepare(`SELECT version FROM user_profiles WHERE id = ?`); const versionRow = getVersionStmt.get(profileId) as any; @@ -208,9 +200,9 @@ export class UserProfileManager { return rows.map((row) => this.rowToChangelog(row)); } - applyConfidenceDecay(profileId: string): void { + applyConfidenceDecay(profileId: string): boolean { const profile = this.getProfileById(profileId); - if (!profile) return; + if (!profile) return false; const profileData: UserProfileData = JSON.parse(profile.profileData); const now = Date.now(); @@ -218,21 +210,48 @@ export class UserProfileManager { let hasChanges = false; - profileData.preferences = profileData.preferences + profileData.preferences = this.ensureArray(profileData.preferences) .map((pref) => { - const age = now - pref.lastUpdated; + const lastUpdated = this.preferenceLastUpdated(pref, profile, now); + const evidence = this.ensureArray(pref.evidence); + const normalizedPref = { + ...pref, + confidence: this.normalizeConfidence(pref.confidence), + evidence, + lastUpdated, + }; + + if ( + pref.lastUpdated !== lastUpdated || + pref.confidence !== normalizedPref.confidence || + !Array.isArray(pref.evidence) + ) { + hasChanges = true; + } + + const age = now - lastUpdated; if (age > decayThreshold) { hasChanges = true; const decayFactor = Math.max(0.5, 1 - (age - decayThreshold) / decayThreshold); - return { ...pref, confidence: pref.confidence * decayFactor }; + return { + ...normalizedPref, + confidence: normalizedPref.confidence * decayFactor, + lastUpdated: now, + }; } - return pref; + return normalizedPref; }) - .filter((pref) => pref.confidence >= 0.3); + .filter((pref) => { + const keep = pref.confidence >= 0.3; + if (!keep) hasChanges = true; + return keep; + }); if (hasChanges) { this.updateProfile(profileId, profileData, 0, "Applied confidence decay to preferences"); } + + return hasChanges; } deleteProfile(profileId: string): void { @@ -382,6 +401,57 @@ export class UserProfileManager { } return Array.isArray(val) ? val : []; } + + private normalizeProfileData(profileData: UserProfileData, now: number): UserProfileData { + return { + preferences: safeArray(profileData.preferences).map((pref: any) => ({ + ...pref, + confidence: this.normalizeConfidence(pref.confidence), + evidence: this.ensureArray(pref.evidence), + lastUpdated: this.isValidTimestamp(pref.lastUpdated) ? pref.lastUpdated : now, + })), + patterns: safeArray(profileData.patterns).map((pattern: any) => ({ + ...pattern, + frequency: this.normalizePositiveNumber(pattern.frequency, 1), + lastSeen: this.isValidTimestamp(pattern.lastSeen) ? pattern.lastSeen : now, + })), + workflows: safeArray(profileData.workflows).map((workflow: any) => ({ + ...workflow, + frequency: this.normalizePositiveNumber(workflow.frequency, 1), + })), + }; + } + + private preferenceLastUpdated(pref: any, profile: UserProfile, fallback: number): number { + if (this.isValidTimestamp(pref.lastUpdated)) { + return pref.lastUpdated; + } + if (this.isValidTimestamp(profile.lastAnalyzedAt)) { + return profile.lastAnalyzedAt; + } + if (this.isValidTimestamp(profile.createdAt)) { + return profile.createdAt; + } + return fallback; + } + + private normalizeConfidence(value: any): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + return 0.5; + } + return Math.min(1, Math.max(0, value)); + } + + private normalizePositiveNumber(value: any, fallback: number): number { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return fallback; + } + return value; + } + + private isValidTimestamp(value: any): value is number { + return typeof value === "number" && Number.isFinite(value) && value > 0; + } } export const userProfileManager = new UserProfileManager(); diff --git a/tests/profile-write.test.ts b/tests/profile-write.test.ts index 1016e1b..b612e35 100644 --- a/tests/profile-write.test.ts +++ b/tests/profile-write.test.ts @@ -3,15 +3,19 @@ * Exercises the write path added to src/index.ts `profile` mode * by testing the underlying manager directly (no live plugin context needed). */ -import { describe, it, expect, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync } from "node:fs"; +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "bun:test"; +import { mkdirSync, mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { connectionManager } from "../src/services/sqlite/connection-manager.js"; import { removeDirWithRetries } from "./helpers/temp-dir.mjs"; // We patch CONFIG.storagePath before importing the manager so the DB lands in tmp. +let suiteTmpDir: string; let tmpDir: string; +let testCounter = 0; + +const WINDOWS_CLEANUP_LOCK_ERRORS = new Set(["EBUSY", "ENOTEMPTY", "EPERM"]); async function makeManager() { // Dynamic import after setting storagePath so the constructor picks up the temp dir. @@ -25,13 +29,30 @@ async function makeManager() { } describe("UserProfileManager – explicit preference writes", () => { + beforeAll(() => { + suiteTmpDir = mkdtempSync(join(tmpdir(), "opencode-mem-profile-write-")); + }); + beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), "opencode-mem-test-")); + testCounter += 1; + tmpDir = join(suiteTmpDir, `case-${testCounter}`); + mkdirSync(tmpDir, { recursive: true }); + }); + + afterEach(() => { + connectionManager.closeAll(); }); - afterEach(async () => { + afterAll(async () => { connectionManager.closeAll(); - await removeDirWithRetries(tmpDir); + try { + await removeDirWithRetries(suiteTmpDir, 8); + } catch (error: any) { + // Windows can briefly keep SQLite temp dirs locked after closeAll(). + if (!WINDOWS_CLEANUP_LOCK_ERRORS.has(error?.code)) { + throw error; + } + } }); it("creates a profile with an explicit preference when none exists", async () => { @@ -68,6 +89,123 @@ describe("UserProfileManager – explicit preference writes", () => { expect(data.preferences[0].evidence).toContain("manual-write"); }); + it("adds lastUpdated to generated preferences when creating a profile", async () => { + const mgr = await makeManager(); + const userId = "test@example.com"; + const before = Date.now(); + + mgr.createProfile( + userId, + "Test User", + "testuser", + userId, + { + preferences: [ + { + category: "style", + description: "Prefers concise answers", + confidence: 0.7, + evidence: ["observed"], + } as any, + ], + patterns: [], + workflows: [], + }, + 10 + ); + + const after = Date.now(); + const profile = mgr.getActiveProfile(userId)!; + const data = JSON.parse(profile.profileData); + const lastUpdated = data.preferences[0].lastUpdated; + + expect(typeof lastUpdated).toBe("number"); + expect(lastUpdated).toBeGreaterThanOrEqual(before); + expect(lastUpdated).toBeLessThanOrEqual(after); + }); + + it("applies confidence decay to stale preferences", async () => { + const mgr = await makeManager(); + const userId = "test@example.com"; + const staleTimestamp = Date.now() - 61 * 24 * 60 * 60 * 1000; + + mgr.createProfile( + userId, + "Test User", + "testuser", + userId, + { + preferences: [ + { + category: "style", + description: "Prefers concise answers", + confidence: 0.8, + evidence: ["observed"], + lastUpdated: staleTimestamp, + }, + ], + patterns: [], + workflows: [], + }, + 10 + ); + + const profile = mgr.getActiveProfile(userId)!; + const changed = mgr.applyConfidenceDecay(profile.id); + + expect(changed).toBe(true); + + const updated = mgr.getActiveProfile(userId)!; + const data = JSON.parse(updated.profileData); + + expect(updated.version).toBe(2); + expect(data.preferences[0].confidence).toBeCloseTo(0.4, 2); + expect(data.preferences[0].lastUpdated).toBeGreaterThan(staleTimestamp); + + const changedAgain = mgr.applyConfidenceDecay(updated.id); + const unchanged = mgr.getActiveProfile(userId)!; + + expect(changedAgain).toBe(false); + expect(unchanged.version).toBe(2); + }); + + it("removes preferences that decay below the confidence floor", async () => { + const mgr = await makeManager(); + const userId = "test@example.com"; + const staleTimestamp = Date.now() - 61 * 24 * 60 * 60 * 1000; + + mgr.createProfile( + userId, + "Test User", + "testuser", + userId, + { + preferences: [ + { + category: "style", + description: "Weak stale preference", + confidence: 0.4, + evidence: ["observed"], + lastUpdated: staleTimestamp, + }, + ], + patterns: [], + workflows: [], + }, + 10 + ); + + const profile = mgr.getActiveProfile(userId)!; + const changed = mgr.applyConfidenceDecay(profile.id); + + expect(changed).toBe(true); + + const updated = mgr.getActiveProfile(userId)!; + const data = JSON.parse(updated.profileData); + + expect(data.preferences).toHaveLength(0); + }); + it("merges a new explicit preference into an existing profile without clobbering other prefs", async () => { const mgr = await makeManager(); const userId = "test@example.com";