om-memory-system 4.18.0 → 4.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/adapters/opencode/profile-learning.js +7 -6
  2. package/dist/adapters/pi/profile.js +2 -1
  3. package/dist/cli/profile-normalise-categories-command.js +1 -1
  4. package/dist/core/memory-operations.js +10 -2
  5. package/dist/importer/claude-hook-api.js +2 -2
  6. package/dist/importer/profile-backlog.d.ts +1 -1
  7. package/dist/importer/profile-backlog.js +3 -2
  8. package/dist/importer/profile-import.d.ts +1 -1
  9. package/dist/services/ai-cleanup-handlers.d.ts +31 -0
  10. package/dist/services/ai-cleanup-handlers.js +126 -0
  11. package/dist/services/api-handlers.d.ts +0 -22
  12. package/dist/services/api-handlers.js +3 -242
  13. package/dist/services/profile-admin.d.ts +1 -1
  14. package/dist/services/profile-admin.js +1 -1
  15. package/dist/services/request-timeouts.d.ts +2 -0
  16. package/dist/services/request-timeouts.js +2 -0
  17. package/dist/services/user-profile/ai-cleanup-apply.d.ts +27 -0
  18. package/dist/services/user-profile/ai-cleanup-apply.js +94 -0
  19. package/dist/services/user-profile/ai-cleanup-batches.d.ts +34 -0
  20. package/dist/services/user-profile/ai-cleanup-batches.js +177 -0
  21. package/dist/services/user-profile/ai-cleanup-job.d.ts +52 -0
  22. package/dist/services/user-profile/ai-cleanup-job.js +112 -0
  23. package/dist/services/user-profile/ai-cleanup.d.ts +11 -6
  24. package/dist/services/user-profile/ai-cleanup.js +5 -40
  25. package/dist/services/user-profile/merge-and-save.d.ts +25 -0
  26. package/dist/services/user-profile/merge-and-save.js +38 -0
  27. package/dist/services/user-profile/user-profile-manager.d.ts +2 -2
  28. package/dist/services/user-profile/user-profile-manager.js +2 -2
  29. package/dist/services/web-server.js +9 -11
  30. package/dist/web/assets/index-CYcRfxr4.css +2 -0
  31. package/dist/web/assets/index-DvNL0vOb.js +127 -0
  32. package/dist/web/index.html +2 -2
  33. package/package.json +1 -1
  34. package/dist/web/assets/index-BktRr9cC.css +0 -2
  35. package/dist/web/assets/index-CIODmA62.js +0 -127
@@ -5,6 +5,7 @@ import { CONFIG, refreshConfigIfChanged } from "../../config.js";
5
5
  import { resolveOpencodeHostModel } from "../../services/ai/live-model-choice.js";
6
6
  import { userPromptManager } from "../../services/user-prompt/user-prompt-manager.js";
7
7
  import { userProfileManager } from "../../services/user-profile/user-profile-manager.js";
8
+ import { saveProfile } from "../../services/user-profile/merge-and-save.js";
8
9
  import { sortProfileItems } from "../../utils/profile.js";
9
10
  import { truncateToMaxBytes } from "../../utils/context-limit.js";
10
11
  import { loadOpencodeProvider } from "./opencode-provider-loader.js";
@@ -29,7 +30,8 @@ async function runAutomaticProfileCleanup(userId) {
29
30
  const result = await aiCleanupProfile(profileData);
30
31
  if (result.diff.merged.length === 0 && result.diff.removed.length === 0)
31
32
  return;
32
- const updated = await userProfileManager.updateProfile(profile.id, result.cleaned, 0, `Automatic AI cleanup: ${result.diff.merged.length} merged, ${result.diff.removed.length} removed`);
33
+ const summary = `Automatic AI cleanup: ${result.diff.merged.length} merged, ${result.diff.removed.length} removed`;
34
+ const updated = await saveProfile(profile, result.cleaned, summary);
33
35
  log("user-profile-learning: automatic cleanup complete", {
34
36
  userId,
35
37
  updated,
@@ -177,7 +179,7 @@ Rules:
177
179
  if (validationSummary) {
178
180
  changeSummary = changeSummary + "; " + validationSummary;
179
181
  }
180
- success = await userProfileManager.updateProfile(existingProfile.id, updatedProfileData, prompts.length, changeSummary);
182
+ success = await saveProfile(existingProfile, updatedProfileData, changeSummary, prompts.length);
181
183
  if (!success) {
182
184
  log("User profile update conflict, retrying", {
183
185
  profileId: existingProfile.id,
@@ -188,13 +190,12 @@ Rules:
188
190
  retries++;
189
191
  }
190
192
  if (!success) {
191
- // Mark the batch anyway so the same prompts are not retried forever
192
- // (token burn + repeated toasts on every subsequent idle).
193
+ // Keep the prompts for a later run; the backoff stops a retry on every idle.
193
194
  log("User profile update conflict: exhausted retries", {
194
195
  profileId: existingProfile?.id,
195
196
  userId,
196
197
  });
197
- await userPromptManager.markMultipleAsUserLearningCaptured(prompts.map((p) => p.id));
198
+ recordProfileFailure("opencode", "user-profile-learning: conflict", new Error("conflict"));
198
199
  return;
199
200
  }
200
201
  await userPromptManager.markMultipleAsUserLearningCaptured(prompts.map((p) => p.id));
@@ -664,5 +665,5 @@ If no clear chains, return { "paths": [] }.`;
664
665
  return;
665
666
  const data = JSON.parse(profile.profileData);
666
667
  data.learning_paths = result.paths;
667
- await userProfileManager.updateProfile(profileId, data, 0, `Updated learning paths: ${result.paths.map((p) => p.topic).join(", ")}`);
668
+ await saveProfile(profile, data, `Updated learning paths: ${result.paths.map((p) => p.topic).join(", ")}`);
668
669
  }
@@ -62,6 +62,7 @@ export async function performPiProfileLearning(input) {
62
62
  }
63
63
  const userId = tags.user.userEmail;
64
64
  const { userProfileManager } = await import("../../services/user-profile/user-profile-manager.js");
65
+ const { saveMergedProfile } = await import("../../services/user-profile/merge-and-save.js");
65
66
  const existing = await userProfileManager.getActiveProfile(userId);
66
67
  const resolveProfileModel = input.resolveProfileModel;
67
68
  const analyzer = resolveProfileModel
@@ -79,7 +80,7 @@ export async function performPiProfileLearning(input) {
79
80
  return;
80
81
  }
81
82
  if (existing && analysis.merged) {
82
- const updated = await userProfileManager.updateProfile(existing.id, analysis.merged, input.prompts.length, `Pi analysis of ${input.prompts.length} prompts`);
83
+ const updated = await saveMergedProfile(userProfileManager, existing, analysis, input.prompts.length, `Pi analysis of ${input.prompts.length} prompts`);
83
84
  if (!updated) {
84
85
  log("pi profile learning: update conflict", { profileId: existing.id });
85
86
  return;
@@ -69,7 +69,7 @@ export async function runProfileNormaliseCategoriesCommand(argv, print = console
69
69
  print("Preview only. Run again with --yes to save.");
70
70
  return 0;
71
71
  }
72
- const saved = await userProfileManager.updateProfile(profile.id, after, 0, `Normalised profile categories: ${changes} items renamed`);
72
+ const saved = await userProfileManager.updateProfile(profile.id, after, 0, `Normalised profile categories: ${changes} items renamed`, profile.version);
73
73
  if (!saved)
74
74
  throw new Error("Profile was modified by another session. Please retry.");
75
75
  print("Category renames saved. The previous snapshot remains in the profile changelog.");
@@ -145,6 +145,7 @@ export async function executeMemoryOperation(args, context) {
145
145
  };
146
146
  }
147
147
  const { userProfileManager } = await import("../services/user-profile/user-profile-manager.js");
148
+ const { saveMergedProfile } = await import("../services/user-profile/merge-and-save.js");
148
149
  const userId = tags.user.userEmail || "unknown";
149
150
  if (args.content !== undefined) {
150
151
  const trimmed = args.content.trim();
@@ -172,8 +173,15 @@ export async function executeMemoryOperation(args, context) {
172
173
  const existingProfile = await userProfileManager.getActiveProfile(userId);
173
174
  if (existingProfile) {
174
175
  const existingData = JSON.parse(existingProfile.profileData);
175
- const mergedData = await userProfileManager.mergeProfileData(existingData, { preferences: [newPreference] }, undefined, existingProfile.id);
176
- await userProfileManager.updateProfile(existingProfile.id, mergedData, 0, `Explicit preference added: ${sanitizedContent.slice(0, 80)}`);
176
+ const raw = { preferences: [newPreference] };
177
+ const mergedData = await userProfileManager.mergeProfileData(existingData, raw, undefined, existingProfile.id);
178
+ const saved = await saveMergedProfile(userProfileManager, existingProfile, { raw, merged: mergedData }, 0, `Explicit preference added: ${sanitizedContent.slice(0, 80)}`);
179
+ if (!saved) {
180
+ return {
181
+ success: false,
182
+ error: "The profile changed while saving. Try again.",
183
+ };
184
+ }
177
185
  return { success: true, message: "Preference saved to profile" };
178
186
  }
179
187
  await userProfileManager.createProfile(userId, tags.user.displayName || userId, tags.user.userName || userId, tags.user.userEmail || userId, { preferences: [newPreference], patterns: [], workflows: [] }, 0);
@@ -449,8 +449,8 @@ async function learnProfile(directory, deps) {
449
449
  const context = batch.map((prompt, index) => `${index + 1}. ${prompt.content}`).join("\n");
450
450
  const analysis = await analyzeProfile(model, context, existing ? { id: existing.id, profileData: existing.profileData } : null);
451
451
  if (existing) {
452
- const updated = analysis.merged &&
453
- (await userProfileManager.updateProfile(existing.id, analysis.merged, batch.length, `Claude Code analysis of ${batch.length} prompts`));
452
+ const { saveMergedProfile } = await import("../services/user-profile/merge-and-save.js");
453
+ const updated = await saveMergedProfile(userProfileManager, existing, analysis, batch.length, `Claude Code analysis of ${batch.length} prompts`);
454
454
  if (!updated)
455
455
  throw new Error("Profile update conflict");
456
456
  }
@@ -3,7 +3,7 @@ import { type ProfileFailureCode } from "../core/profile-failure.js";
3
3
  import { userProfileManager } from "../services/user-profile/user-profile-manager.js";
4
4
  import { userPromptManager } from "../services/user-prompt/user-prompt-manager.js";
5
5
  export type BacklogPromptStore = Pick<typeof userPromptManager, "countUnanalyzedForUserLearning" | "getPromptsForUserLearning" | "markMultipleAsUserLearningCaptured"> & Partial<Pick<typeof userPromptManager, "skipTrivialPromptsForLearning">>;
6
- export type BacklogProfileStore = Pick<typeof userProfileManager, "getActiveProfile" | "createProfile" | "updateProfile">;
6
+ export type BacklogProfileStore = Pick<typeof userProfileManager, "getActiveProfile" | "getProfileById" | "createProfile" | "mergeProfileData" | "updateProfile">;
7
7
  export interface DrainProfileBacklogOptions {
8
8
  user: {
9
9
  userEmail: string;
@@ -1,5 +1,6 @@
1
1
  import { analyzeProfile } from "../core/profile-analysis.js";
2
2
  import { profileFailureCode } from "../core/profile-failure.js";
3
+ import { saveMergedProfile } from "../services/user-profile/merge-and-save.js";
3
4
  import { userProfileManager } from "../services/user-profile/user-profile-manager.js";
4
5
  import { userPromptManager } from "../services/user-prompt/user-prompt-manager.js";
5
6
  /**
@@ -54,8 +55,8 @@ export async function drainProfileBacklog(options) {
54
55
  const context = batch.map((item, index) => `${index + 1}. ${item.content}`).join("\n");
55
56
  const analysis = await analyzeProfile(model, context, existing ? { id: existing.id, profileData: existing.profileData } : null);
56
57
  if (existing) {
57
- if (!analysis.merged ||
58
- !(await profiles.updateProfile(existing.id, analysis.merged, batch.length, `${label} of ${batch.length} prompts`)))
58
+ const summary = `${label} of ${batch.length} prompts`;
59
+ if (!(await saveMergedProfile(profiles, existing, analysis, batch.length, summary)))
59
60
  throw new Error("Profile update conflict");
60
61
  }
61
62
  else {
@@ -13,7 +13,7 @@ export interface ProfileImportReport {
13
13
  error?: string;
14
14
  }
15
15
  type PromptStore = Pick<typeof userPromptManager, "savePrompt" | "markAsCaptured" | "countUnanalyzedForUserLearning" | "getPromptsForUserLearning" | "markMultipleAsUserLearningCaptured" | "markForUserLearning">;
16
- type ProfileStore = Pick<typeof userProfileManager, "getActiveProfile" | "createProfile" | "updateProfile">;
16
+ type ProfileStore = Pick<typeof userProfileManager, "getActiveProfile" | "getProfileById" | "createProfile" | "mergeProfileData" | "updateProfile">;
17
17
  type Ledger = Pick<ImportLedger, "get" | "begin" | "complete"> & Partial<Pick<ImportLedger, "peek">>;
18
18
  export interface ProfileImportOptions {
19
19
  host: MemoryHost;
@@ -0,0 +1,31 @@
1
+ import type { UserProfileManager } from "./user-profile/user-profile-manager.js";
2
+ import type { AiCleanupJobDeps, AiCleanupJobView } from "./user-profile/ai-cleanup-job.js";
3
+ interface ApiResponse<T = any> {
4
+ success: boolean;
5
+ data?: T;
6
+ error?: string;
7
+ }
8
+ export interface AiCleanupHandlerDeps extends AiCleanupJobDeps {
9
+ manager?: UserProfileManager;
10
+ }
11
+ /**
12
+ * Starts a background cleanup of the selected items, or returns the job already running.
13
+ * A stale `profileVersion` fails at once, because the dialog's item ids are positions in
14
+ * the profile it read.
15
+ */
16
+ export declare function handleAiCleanupStart(userId?: string, body?: any, deps?: AiCleanupHandlerDeps): Promise<ApiResponse<AiCleanupJobView | null>>;
17
+ /** The state of the user's job, or null when there is none. */
18
+ export declare function handleAiCleanupStatus(userId?: string): Promise<ApiResponse<AiCleanupJobView | null>>;
19
+ /** Stops a running job, or discards a finished one. */
20
+ export declare function handleAiCleanupStop(userId?: string): Promise<ApiResponse<null>>;
21
+ /**
22
+ * Puts the ticked changes of the finished job into the newest profile and saves it with
23
+ * the version check. When another save lands first, it loads the profile and tries
24
+ * again, at most MAX_PROFILE_SAVE_ATTEMPTS times. The request's `profile` field is
25
+ * ignored; the job holds the result.
26
+ */
27
+ export declare function handleAiCleanupApply(userId?: string, body?: any, deps?: AiCleanupHandlerDeps): Promise<ApiResponse<{
28
+ applied: number;
29
+ skipped: number;
30
+ }>>;
31
+ export {};
@@ -0,0 +1,126 @@
1
+ import { log } from "./logger.js";
2
+ import { sortProfileItems, stripProfileVectors } from "../utils/profile.js";
3
+ async function cleanupUser(userId) {
4
+ if (userId)
5
+ return userId;
6
+ const { CONFIG } = await import("../config.js");
7
+ const { resolveWebProfileUserId } = await import("./profile-identity.js");
8
+ return (await resolveWebProfileUserId(CONFIG)) || "unknown";
9
+ }
10
+ async function managerOf(deps) {
11
+ return (deps.manager ?? (await import("./user-profile/user-profile-manager.js")).userProfileManager);
12
+ }
13
+ function handlerError(step, error) {
14
+ // The log never holds profile text, so only the error's kind is kept.
15
+ log(`AI cleanup ${step}: error`, { code: error instanceof Error ? error.name : "unknown" });
16
+ return { success: false, error: String(error) };
17
+ }
18
+ /** The profile in the order the dialog lists it, so item ids match its positions. */
19
+ function sortedProfile(profileData) {
20
+ const data = JSON.parse(profileData);
21
+ data.preferences = sortProfileItems(data.preferences, "confidence");
22
+ data.patterns = sortProfileItems(data.patterns, "frequency");
23
+ data.workflows = sortProfileItems(data.workflows, "frequency");
24
+ return data;
25
+ }
26
+ /** The job as the dialog sees it: no vectors, and no API key in an error. */
27
+ async function clientView(job) {
28
+ if (job?.status.state === "done") {
29
+ const result = { ...job.status.result, old: stripProfileVectors(job.status.result.old) };
30
+ return { ...job, status: { ...job.status, result } };
31
+ }
32
+ if (job?.status.state === "failed") {
33
+ const { CONFIG } = await import("../config.js");
34
+ const { redactTraceText } = await import("./capture-diagnostics.js");
35
+ return { ...job, status: { ...job.status, error: redactTraceText(job.status.error, CONFIG) } };
36
+ }
37
+ return job;
38
+ }
39
+ /**
40
+ * Starts a background cleanup of the selected items, or returns the job already running.
41
+ * A stale `profileVersion` fails at once, because the dialog's item ids are positions in
42
+ * the profile it read.
43
+ */
44
+ export async function handleAiCleanupStart(userId, body, deps = {}) {
45
+ try {
46
+ const { getAiCleanupJob, startAiCleanupJob } = await import("./user-profile/ai-cleanup-job.js");
47
+ const user = await cleanupUser(userId);
48
+ const current = getAiCleanupJob(user);
49
+ if (current?.status.state === "running")
50
+ return { success: true, data: current };
51
+ const profile = await (await managerOf(deps)).getActiveProfile(user);
52
+ if (!profile)
53
+ return { success: false, error: "No profile found to clean up" };
54
+ if (typeof body?.profileVersion === "number" && profile.version !== body.profileVersion) {
55
+ return { success: false, error: "Profile changed. Reload it before running AI cleanup." };
56
+ }
57
+ const includeIds = Array.isArray(body?.includeIds)
58
+ ? body.includeIds.filter((id) => typeof id === "string")
59
+ : undefined;
60
+ const job = startAiCleanupJob(user, { profileData: sortedProfile(profile.profileData), includeIds }, deps);
61
+ return { success: true, data: await clientView(job) };
62
+ }
63
+ catch (error) {
64
+ return handlerError("start", error);
65
+ }
66
+ }
67
+ /** The state of the user's job, or null when there is none. */
68
+ export async function handleAiCleanupStatus(userId) {
69
+ try {
70
+ const { getAiCleanupJob } = await import("./user-profile/ai-cleanup-job.js");
71
+ return { success: true, data: await clientView(getAiCleanupJob(await cleanupUser(userId))) };
72
+ }
73
+ catch (error) {
74
+ return handlerError("status", error);
75
+ }
76
+ }
77
+ /** Stops a running job, or discards a finished one. */
78
+ export async function handleAiCleanupStop(userId) {
79
+ try {
80
+ const { stopAiCleanupJob } = await import("./user-profile/ai-cleanup-job.js");
81
+ stopAiCleanupJob(await cleanupUser(userId));
82
+ return { success: true, data: null };
83
+ }
84
+ catch (error) {
85
+ return handlerError("stop", error);
86
+ }
87
+ }
88
+ /**
89
+ * Puts the ticked changes of the finished job into the newest profile and saves it with
90
+ * the version check. When another save lands first, it loads the profile and tries
91
+ * again, at most MAX_PROFILE_SAVE_ATTEMPTS times. The request's `profile` field is
92
+ * ignored; the job holds the result.
93
+ */
94
+ export async function handleAiCleanupApply(userId, body, deps = {}) {
95
+ try {
96
+ const { getAiCleanupResult, stopAiCleanupJob } = await import("./user-profile/ai-cleanup-job.js");
97
+ const { applyCleanupEdits } = await import("./user-profile/ai-cleanup-apply.js");
98
+ const { MAX_PROFILE_SAVE_ATTEMPTS, saveProfile } = await import("./user-profile/merge-and-save.js");
99
+ const user = await cleanupUser(userId);
100
+ const job = getAiCleanupResult(user);
101
+ if (!job)
102
+ return { success: false, error: "No finished cleanup found. Run AI cleanup again." };
103
+ const accepted = {
104
+ merged: Array.isArray(body?.acceptedMerged) ? body.acceptedMerged : [],
105
+ removed: Array.isArray(body?.acceptedRemoved) ? body.acceptedRemoved : [],
106
+ };
107
+ const manager = await managerOf(deps);
108
+ for (let attempt = 1; attempt <= MAX_PROFILE_SAVE_ATTEMPTS; attempt++) {
109
+ const profile = await manager.getActiveProfile(user);
110
+ if (!profile)
111
+ return { success: false, error: "Profile not found" };
112
+ const result = applyCleanupEdits(JSON.parse(profile.profileData), job, accepted);
113
+ const saved = result.applied === 0 ||
114
+ (await saveProfile(profile, result.profile, "AI cleanup applied", 0, manager));
115
+ if (saved) {
116
+ stopAiCleanupJob(user);
117
+ return { success: true, data: { applied: result.applied, skipped: result.skipped } };
118
+ }
119
+ log("AI cleanup apply: save conflict", { attempt, of: MAX_PROFILE_SAVE_ATTEMPTS });
120
+ }
121
+ return { success: false, error: "Profile saved at the same moment. Try Apply again." };
122
+ }
123
+ catch (error) {
124
+ return handlerError("apply", error);
125
+ }
126
+ }
@@ -1,6 +1,5 @@
1
1
  import { type KeywordCount } from "./keyword-counts.js";
2
2
  import type { MemoryType } from "../types/index.js";
3
- import type { UserProfileData } from "./user-profile/types.js";
4
3
  interface ApiResponse<T = any> {
5
4
  success: boolean;
6
5
  data?: T;
@@ -148,27 +147,6 @@ export declare function handleGetUserProfile(userId?: string): Promise<ApiRespon
148
147
  export declare function handleGetProfileChangelog(profileId: string, limit?: number): Promise<ApiResponse<any[]>>;
149
148
  export declare function handleGetProfileSnapshot(changelogId: string): Promise<ApiResponse<any>>;
150
149
  export declare function handleRefreshProfile(userId?: string): Promise<ApiResponse<any>>;
151
- export declare function handleAICleanup(userId?: string, includeIds?: string[], profileVersion?: number): Promise<ApiResponse<any>>;
152
- /**
153
- * Merge an AI-cleanup result into a full profile.
154
- * When includeIds is set, only scoped items are mutated; everything else is preserved.
155
- */
156
- export declare function mergeCleanupIntoProfile(args: {
157
- currentProfile: UserProfileData;
158
- oldProfileData: UserProfileData;
159
- cleanedData: UserProfileData;
160
- includeIds?: string[];
161
- acceptedMerged?: string[][];
162
- acceptedRemoved?: string[];
163
- allMerged?: Array<{
164
- ids: string[];
165
- result: string;
166
- }>;
167
- allRemovedIds?: string[];
168
- /** True when the client sent acceptance arrays (even if empty = reject all). */
169
- explicitAcceptance?: boolean;
170
- }): UserProfileData;
171
- export declare function handleApplyCleanup(userId?: string, body?: any): Promise<ApiResponse<any>>;
172
150
  type CategoryCleanupDeps = import("./user-profile/category-cleanup.js").CleanupDeps;
173
151
  /** Starts a background preview job, or returns the one already running. Changes nothing in the profile. */
174
152
  export declare function handleCategoryCleanupStart(userId?: string, deps?: CategoryCleanupDeps): Promise<ApiResponse<any>>;
@@ -9,6 +9,7 @@ import { log } from "./logger.js";
9
9
  import { countKeywords } from "./keyword-counts.js";
10
10
  import { CONFIG } from "../config.js";
11
11
  import { userPromptManager } from "./user-prompt/user-prompt-manager.js";
12
+ import { saveProfile } from "./user-profile/merge-and-save.js";
12
13
  import { sortProfileItems, stripProfileVectors } from "../utils/profile.js";
13
14
  import { resolveWebProfileUserId } from "./profile-identity.js";
14
15
  async function getAllMemoryShards() {
@@ -863,8 +864,7 @@ export async function handleRefreshProfile(userId) {
863
864
  const pData = JSON.parse(profile.profileData);
864
865
  const { data: decayed, hasChanges } = userProfileManager.decayInMemory(pData);
865
866
  if (hasChanges) {
866
- await userProfileManager.updateProfile(profile.id, decayed, 0, "Applied confidence decay");
867
- decayApplied = true;
867
+ decayApplied = await saveProfile(profile, decayed, "Applied confidence decay");
868
868
  }
869
869
  }
870
870
  const unanalyzedCount = await userPromptManager.countUnanalyzedForUserLearning();
@@ -884,194 +884,6 @@ export async function handleRefreshProfile(userId) {
884
884
  return { success: false, error: String(error) };
885
885
  }
886
886
  }
887
- // Temporary storage for pending AI cleanup results (userId → result)
888
- const pendingCleanups = new Map();
889
- export async function handleAICleanup(userId, includeIds, profileVersion) {
890
- try {
891
- const { userProfileManager } = await import("./user-profile/user-profile-manager.js");
892
- const { aiCleanupProfile, aiCleanupProfileFromIndexed, filterProfileForCleanup } = await import("./user-profile/ai-cleanup.js");
893
- let targetUserId = userId;
894
- if (!targetUserId) {
895
- targetUserId = (await resolveWebProfileUserId(CONFIG)) || "unknown";
896
- }
897
- const profile = await userProfileManager.getActiveProfile(targetUserId);
898
- if (!profile) {
899
- return { success: false, error: "No profile found to clean up" };
900
- }
901
- if (profileVersion !== undefined && profile.version !== profileVersion) {
902
- return { success: false, error: "Profile changed. Reload it before running AI cleanup." };
903
- }
904
- const profileData = JSON.parse(profile.profileData);
905
- profileData.preferences = sortProfileItems(profileData.preferences, "confidence");
906
- profileData.patterns = sortProfileItems(profileData.patterns, "frequency");
907
- profileData.workflows = sortProfileItems(profileData.workflows, "frequency");
908
- let indexed;
909
- let result;
910
- const scopedIds = includeIds && includeIds.length > 0 ? includeIds : undefined;
911
- if (scopedIds) {
912
- indexed = filterProfileForCleanup(profileData, scopedIds);
913
- result = await aiCleanupProfileFromIndexed(indexed);
914
- }
915
- else {
916
- result = await aiCleanupProfile(profileData);
917
- }
918
- pendingCleanups.set(targetUserId, {
919
- cleaned: result.cleaned,
920
- oldProfileData: profileData,
921
- diff: result.diff,
922
- allMerged: result.diff?.merged || [],
923
- allRemovedIds: (result.diff?.removed || []).map((r) => r.id),
924
- includeIds: scopedIds,
925
- profileVersion: profile.version,
926
- expiresAt: Date.now() + 30 * 60 * 1000,
927
- });
928
- return {
929
- success: true,
930
- data: {
931
- old: profileData,
932
- new: result.cleaned,
933
- changes: result.diff,
934
- },
935
- };
936
- }
937
- catch (error) {
938
- log("handleAICleanup: error", { error: String(error) });
939
- return { success: false, error: String(error) };
940
- }
941
- }
942
- /**
943
- * Merge an AI-cleanup result into a full profile.
944
- * When includeIds is set, only scoped items are mutated; everything else is preserved.
945
- */
946
- export function mergeCleanupIntoProfile(args) {
947
- const { currentProfile, oldProfileData, cleanedData, includeIds, acceptedMerged = [], acceptedRemoved = [], allMerged = [], allRemovedIds = [], explicitAcceptance = false, } = args;
948
- // Start from cleaned data, then restore any rejected removals/merges.
949
- const scopedResult = {
950
- preferences: [...cleanedData.preferences],
951
- patterns: [...cleanedData.patterns],
952
- workflows: [...cleanedData.workflows],
953
- };
954
- if (explicitAcceptance) {
955
- for (const id of acceptedRemoved) {
956
- removeItemFromProfile(scopedResult, oldProfileData, id);
957
- }
958
- for (const ids of acceptedMerged) {
959
- for (let i = 1; i < ids.length; i++) {
960
- removeItemFromProfile(scopedResult, oldProfileData, ids[i] ?? "");
961
- }
962
- }
963
- const acceptedTargetIds = new Set(acceptedMerged.map((g) => g[0]));
964
- for (const merge of allMerged) {
965
- const groupIds = merge.ids;
966
- if (groupIds.length <= 1)
967
- continue;
968
- if (acceptedTargetIds.has(groupIds[0]))
969
- continue;
970
- removeOneByDescription(scopedResult, merge.result, itemTypeFromId(groupIds[0] ?? ""));
971
- for (const id of groupIds) {
972
- if (id)
973
- pushItemFromProfile(scopedResult, oldProfileData, id);
974
- }
975
- }
976
- const acceptedRemovedSet = new Set(acceptedRemoved);
977
- for (const removedId of allRemovedIds) {
978
- if (acceptedRemovedSet.has(removedId))
979
- continue;
980
- pushItemFromProfile(scopedResult, oldProfileData, removedId);
981
- }
982
- }
983
- // Full-profile cleanup: cleaned (+ acceptance) replaces the whole profile.
984
- if (!includeIds || includeIds.length === 0) {
985
- return scopedResult;
986
- }
987
- // Partial selection: mutate only the analyzed scope inside the current full profile.
988
- const result = {
989
- preferences: [...currentProfile.preferences],
990
- patterns: [...currentProfile.patterns],
991
- workflows: [...currentProfile.workflows],
992
- };
993
- for (const id of includeIds) {
994
- removeItemFromProfile(result, oldProfileData, id);
995
- }
996
- result.preferences.push(...scopedResult.preferences);
997
- result.patterns.push(...scopedResult.patterns);
998
- result.workflows.push(...scopedResult.workflows);
999
- return result;
1000
- }
1001
- function pushItemFromProfile(target, source, id) {
1002
- const srcItem = findItemById(source, id);
1003
- if (!srcItem)
1004
- return;
1005
- const { id: _id, ...rest } = srcItem;
1006
- if (id.startsWith("pref_"))
1007
- target.preferences.push(rest);
1008
- else if (id.startsWith("pat_"))
1009
- target.patterns.push(rest);
1010
- else if (id.startsWith("wf_"))
1011
- target.workflows.push(rest);
1012
- }
1013
- export async function handleApplyCleanup(userId, body) {
1014
- try {
1015
- const { userProfileManager } = await import("./user-profile/user-profile-manager.js");
1016
- let targetUserId = userId;
1017
- if (!targetUserId) {
1018
- targetUserId = (await resolveWebProfileUserId(CONFIG)) || "unknown";
1019
- }
1020
- const pending = pendingCleanups.get(targetUserId);
1021
- if (!pending) {
1022
- return { success: false, error: "No pending cleanup found. Run AI cleanup first." };
1023
- }
1024
- if (Date.now() > pending.expiresAt) {
1025
- pendingCleanups.delete(targetUserId);
1026
- return { success: false, error: "Cleanup session expired. Run AI cleanup again." };
1027
- }
1028
- const profile = await userProfileManager.getActiveProfile(targetUserId);
1029
- if (!profile) {
1030
- return { success: false, error: "Profile not found" };
1031
- }
1032
- if (profile.version !== pending.profileVersion) {
1033
- pendingCleanups.delete(targetUserId);
1034
- return { success: false, error: "Profile changed. Run AI cleanup again." };
1035
- }
1036
- const cleanedData = body?.profile || pending.cleaned;
1037
- const acceptedMerged = Array.isArray(body?.acceptedMerged)
1038
- ? body.acceptedMerged
1039
- : [];
1040
- const acceptedRemoved = Array.isArray(body?.acceptedRemoved)
1041
- ? body.acceptedRemoved
1042
- : [];
1043
- const explicitAcceptance = Array.isArray(body?.acceptedMerged) || Array.isArray(body?.acceptedRemoved);
1044
- const existingData = JSON.parse(profile.profileData);
1045
- const result = mergeCleanupIntoProfile({
1046
- currentProfile: existingData,
1047
- oldProfileData: pending.oldProfileData,
1048
- cleanedData,
1049
- includeIds: pending.includeIds,
1050
- acceptedMerged,
1051
- acceptedRemoved,
1052
- allMerged: pending.allMerged,
1053
- allRemovedIds: pending.allRemovedIds,
1054
- explicitAcceptance,
1055
- });
1056
- const partial = (pending.includeIds && pending.includeIds.length > 0) || explicitAcceptance;
1057
- const success = await userProfileManager.updateProfile(profile.id, result, 0, partial ? "AI cleanup applied (partial)" : "AI cleanup applied");
1058
- if (!success) {
1059
- return { success: false, error: "Profile was modified by another session. Please retry." };
1060
- }
1061
- pendingCleanups.delete(targetUserId);
1062
- return {
1063
- success: true,
1064
- data: {
1065
- message: partial ? "Partial cleanup applied" : "Cleanup applied successfully",
1066
- version: profile.version + 1,
1067
- },
1068
- };
1069
- }
1070
- catch (error) {
1071
- log("handleApplyCleanup: error", { error: String(error) });
1072
- return { success: false, error: String(error) };
1073
- }
1074
- }
1075
887
  async function categoryCleanupUser(userId) {
1076
888
  return userId || (await resolveWebProfileUserId(CONFIG)) || "unknown";
1077
889
  }
@@ -1144,57 +956,6 @@ export async function handleCategoryCleanupUndo(userId, deps) {
1144
956
  return categoryCleanupError("undo", error);
1145
957
  }
1146
958
  }
1147
- function itemTypeFromId(id) {
1148
- if (id.startsWith("pref_"))
1149
- return "preferences";
1150
- if (id.startsWith("pat_"))
1151
- return "patterns";
1152
- return "workflows";
1153
- }
1154
- function findItemById(profile, id) {
1155
- if (typeof id !== "string" || !id.includes("_"))
1156
- return null;
1157
- const parts = id.split("_");
1158
- const prefix = parts[0];
1159
- const idx = parseInt(parts[1] || "", 10);
1160
- if (isNaN(idx))
1161
- return null;
1162
- if (prefix === "pref")
1163
- return profile.preferences[idx] || null;
1164
- if (prefix === "pat")
1165
- return profile.patterns[idx] || null;
1166
- if (prefix === "wf")
1167
- return profile.workflows[idx] || null;
1168
- return null;
1169
- }
1170
- function removeItemFromProfile(target, source, id) {
1171
- const sourceItem = findItemById(source, id);
1172
- if (!sourceItem)
1173
- return false;
1174
- const itemType = itemTypeFromId(id);
1175
- const items = target[itemType];
1176
- const sourceKey = profileItemIdentityKey(sourceItem);
1177
- const index = items.findIndex((item) => profileItemIdentityKey(item) === sourceKey);
1178
- if (index < 0)
1179
- return false;
1180
- items.splice(index, 1);
1181
- return true;
1182
- }
1183
- function profileItemIdentityKey(item) {
1184
- return JSON.stringify({
1185
- category: item.category ?? null,
1186
- description: item.description ?? null,
1187
- steps: Array.isArray(item.steps) ? item.steps : null,
1188
- });
1189
- }
1190
- function removeOneByDescription(profile, desc, itemType) {
1191
- const items = profile[itemType];
1192
- const index = items.findIndex((item) => item.description === desc);
1193
- if (index < 0)
1194
- return false;
1195
- items.splice(index, 1);
1196
- return true;
1197
- }
1198
959
  export async function handleUpdateProfileItem(body) {
1199
960
  try {
1200
961
  const { userProfileManager } = await import("./user-profile/user-profile-manager.js");
@@ -1243,7 +1004,7 @@ export async function handleUpdateProfileItem(body) {
1243
1004
  const changeSummary = action === "delete"
1244
1005
  ? `Deleted ${type.slice(0, -1)} at index ${index}`
1245
1006
  : `Edited ${type.slice(0, -1)} at index ${index}`;
1246
- const success = await userProfileManager.updateProfile(profile.id, profileData, 0, changeSummary);
1007
+ const success = await saveProfile(profile, profileData, changeSummary);
1247
1008
  if (!success)
1248
1009
  return { success: false, error: "Profile was modified by another session. Please retry." };
1249
1010
  return {
@@ -4,7 +4,7 @@ export interface ProfileAdminStore {
4
4
  getAllActiveProfiles(): Promise<UserProfile[]>;
5
5
  getProfileById(id: string): Promise<UserProfile | null>;
6
6
  mergeProfileData(existing: UserProfileData, updates: Partial<UserProfileData>, embedService?: undefined, profileId?: string): Promise<UserProfileData>;
7
- updateProfile(profileId: string, profileData: UserProfileData, additionalPromptsAnalyzed: number, changeSummary: string): Promise<boolean>;
7
+ updateProfile(profileId: string, profileData: UserProfileData, additionalPromptsAnalyzed: number, changeSummary: string, expectedVersion: number): Promise<boolean>;
8
8
  deactivateProfile(profileId: string): Promise<boolean>;
9
9
  }
10
10
  export interface ProfileSummary {
@@ -44,7 +44,7 @@ export async function mergeProfiles(store, sourceId, targetId) {
44
44
  throw new ProfileAdminError("Both profiles must exist and be active", 404);
45
45
  }
46
46
  const merged = await store.mergeProfileData(parse(target), parse(source), undefined, target.id);
47
- const saved = await store.updateProfile(target.id, merged, source.totalPromptsAnalyzed, `Merged profile ${source.userId}`);
47
+ const saved = await store.updateProfile(target.id, merged, source.totalPromptsAnalyzed, `Merged profile ${source.userId}`, target.version);
48
48
  if (!saved)
49
49
  throw new ProfileAdminError("Profile was changed by another session. Retry.", 409);
50
50
  await store.deactivateProfile(source.id);