om-memory-system 4.18.0 → 4.19.1

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.
@@ -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;
@@ -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();
@@ -1054,7 +1054,7 @@ export async function handleApplyCleanup(userId, body) {
1054
1054
  explicitAcceptance,
1055
1055
  });
1056
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");
1057
+ const success = await saveProfile(profile, result, partial ? "AI cleanup applied (partial)" : "AI cleanup applied");
1058
1058
  if (!success) {
1059
1059
  return { success: false, error: "Profile was modified by another session. Please retry." };
1060
1060
  }
@@ -1243,7 +1243,7 @@ export async function handleUpdateProfileItem(body) {
1243
1243
  const changeSummary = action === "delete"
1244
1244
  ? `Deleted ${type.slice(0, -1)} at index ${index}`
1245
1245
  : `Edited ${type.slice(0, -1)} at index ${index}`;
1246
- const success = await userProfileManager.updateProfile(profile.id, profileData, 0, changeSummary);
1246
+ const success = await saveProfile(profile, profileData, changeSummary);
1247
1247
  if (!success)
1248
1248
  return { success: false, error: "Profile was modified by another session. Please retry." };
1249
1249
  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);
@@ -0,0 +1,25 @@
1
+ import type { UserProfile, UserProfileData } from "./types.js";
2
+ /** Learning makes this many save attempts before it leaves the prompts for a later run. */
3
+ export declare const MAX_PROFILE_SAVE_ATTEMPTS = 3;
4
+ /** The profile store operations that a save needs. */
5
+ export interface ProfileSaveStore {
6
+ getProfileById(id: string): Promise<UserProfile | null>;
7
+ mergeProfileData(existing: UserProfileData, updates: Partial<UserProfileData>, embedService?: undefined, profileId?: string): Promise<UserProfileData>;
8
+ updateProfile(profileId: string, profileData: UserProfileData, additionalPromptsAnalyzed: number, changeSummary: string, expectedVersion: number): Promise<boolean>;
9
+ }
10
+ type ReadProfile = Pick<UserProfile, "id" | "version">;
11
+ /**
12
+ * Saves `data` over the profile as the caller read it. Returns false, and changes nothing,
13
+ * when another save landed since. The log holds a code only, never profile content.
14
+ */
15
+ export declare function saveProfile(profile: ReadProfile, data: UserProfileData, summary: string, promptsAnalyzed?: number, store?: Pick<ProfileSaveStore, "updateProfile">): Promise<boolean>;
16
+ /**
17
+ * Saves the merged result of a profile analysis. When another save landed first, it loads
18
+ * the newer profile, merges the same model result into it, and saves again. It never calls
19
+ * the model. Returns false when there is nothing to save or every attempt met a newer version.
20
+ */
21
+ export declare function saveMergedProfile(store: ProfileSaveStore, profile: ReadProfile, analysis: {
22
+ raw: Partial<UserProfileData>;
23
+ merged: UserProfileData | null;
24
+ }, promptsAnalyzed: number, summary: string): Promise<boolean>;
25
+ export {};
@@ -0,0 +1,38 @@
1
+ import { log } from "../logger.js";
2
+ /** Learning makes this many save attempts before it leaves the prompts for a later run. */
3
+ export const MAX_PROFILE_SAVE_ATTEMPTS = 3;
4
+ /**
5
+ * Saves `data` over the profile as the caller read it. Returns false, and changes nothing,
6
+ * when another save landed since. The log holds a code only, never profile content.
7
+ */
8
+ export async function saveProfile(profile, data, summary, promptsAnalyzed = 0, store) {
9
+ const target = store ?? (await import("./user-profile-manager.js")).userProfileManager;
10
+ const saved = await target.updateProfile(profile.id, data, promptsAnalyzed, summary, profile.version);
11
+ if (!saved)
12
+ log("profile save skipped", { code: "version-changed" });
13
+ return saved;
14
+ }
15
+ /**
16
+ * Saves the merged result of a profile analysis. When another save landed first, it loads
17
+ * the newer profile, merges the same model result into it, and saves again. It never calls
18
+ * the model. Returns false when there is nothing to save or every attempt met a newer version.
19
+ */
20
+ export async function saveMergedProfile(store, profile, analysis, promptsAnalyzed, summary) {
21
+ let merged = analysis.merged;
22
+ let version = profile.version;
23
+ for (let attempt = 1; merged && attempt <= MAX_PROFILE_SAVE_ATTEMPTS; attempt++) {
24
+ if (await store.updateProfile(profile.id, merged, promptsAnalyzed, summary, version)) {
25
+ return true;
26
+ }
27
+ log("profile save conflict", { attempt, of: MAX_PROFILE_SAVE_ATTEMPTS });
28
+ if (attempt === MAX_PROFILE_SAVE_ATTEMPTS)
29
+ break;
30
+ const fresh = await store.getProfileById(profile.id);
31
+ if (!fresh)
32
+ return false;
33
+ const current = JSON.parse(fresh.profileData);
34
+ merged = await store.mergeProfileData(current, analysis.raw, undefined, fresh.id);
35
+ version = fresh.version;
36
+ }
37
+ return false;
38
+ }
@@ -20,8 +20,8 @@ export declare class UserProfileManager {
20
20
  getActiveProfile(userId: string): Promise<UserProfile | null>;
21
21
  createProfile(userId: string, displayName: string, userName: string, userEmail: string, profileData: UserProfileData, promptsAnalyzed: number): Promise<string>;
22
22
  updateProfile(profileId: string, profileData: UserProfileData, additionalPromptsAnalyzed: number, changeSummary: string,
23
- /** When given, refuse the save unless the stored version still equals it. */
24
- expectedVersion?: number): Promise<boolean>;
23
+ /** The version the caller read. The save is refused when the stored version differs. */
24
+ expectedVersion: number): Promise<boolean>;
25
25
  private addChangelog;
26
26
  private cleanupOldChangelogs;
27
27
  getProfileChangelogs(profileId: string, limit?: number): Promise<UserProfileChangelog[]>;
@@ -274,7 +274,7 @@ export class UserProfileManager {
274
274
  return id;
275
275
  }
276
276
  async updateProfile(profileId, profileData, additionalPromptsAnalyzed, changeSummary,
277
- /** When given, refuse the save unless the stored version still equals it. */
277
+ /** The version the caller read. The save is refused when the stored version differs. */
278
278
  expectedVersion) {
279
279
  const db = await this.ready();
280
280
  const now = Date.now();
@@ -286,7 +286,7 @@ export class UserProfileManager {
286
286
  });
287
287
  const versionRow = await db.get(`SELECT version FROM user_profiles WHERE id = ?`, [profileId]);
288
288
  const currentVersion = Number(versionRow?.version ?? 0);
289
- if (expectedVersion !== undefined && currentVersion !== expectedVersion)
289
+ if (currentVersion !== expectedVersion)
290
290
  return false;
291
291
  const newVersion = currentVersion + 1;
292
292
  const changes = await db.run(`