Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/services/api-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1003,19 +1003,24 @@ export async function handleGetProfileSnapshot(changelogId: string): Promise<Api
export async function handleRefreshProfile(userId?: string): Promise<ApiResponse<any>> {
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) {
Expand Down
5 changes: 4 additions & 1 deletion src/services/user-memory-learning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
104 changes: 87 additions & 17 deletions src/services/user-profile/user-profile-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -208,31 +200,58 @@ 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();
const decayThreshold = CONFIG.userProfileConfidenceDecayDays * 24 * 60 * 60 * 1000;

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 {
Expand Down Expand Up @@ -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();
148 changes: 143 additions & 5 deletions tests/profile-write.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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";
Expand Down