om-memory-system 4.17.0-next.124 → 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.
Files changed (31) hide show
  1. package/dist/adapters/opencode/profile-learning.js +7 -6
  2. package/dist/adapters/opencode/tui-status.js +2 -1
  3. package/dist/adapters/pi/extension.js +3 -2
  4. package/dist/adapters/pi/profile.js +2 -1
  5. package/dist/cli/profile-cleanup-categories-command.js +1 -2
  6. package/dist/cli/profile-normalise-categories-command.js +1 -1
  7. package/dist/core/memory-operations.js +10 -2
  8. package/dist/importer/claude-hook-api.js +2 -2
  9. package/dist/importer/profile-backlog.d.ts +1 -1
  10. package/dist/importer/profile-backlog.js +3 -2
  11. package/dist/importer/profile-import.d.ts +1 -1
  12. package/dist/services/api-handlers.js +6 -6
  13. package/dist/services/profile-admin.d.ts +1 -1
  14. package/dist/services/profile-admin.js +1 -1
  15. package/dist/services/status-glyphs.d.ts +19 -0
  16. package/dist/services/status-glyphs.js +21 -0
  17. package/dist/services/user-profile/category-cleanup-store.d.ts +11 -0
  18. package/dist/services/user-profile/category-cleanup-store.js +57 -1
  19. package/dist/services/user-profile/category-cleanup.d.ts +16 -7
  20. package/dist/services/user-profile/category-cleanup.js +94 -31
  21. package/dist/services/user-profile/merge-and-save.d.ts +25 -0
  22. package/dist/services/user-profile/merge-and-save.js +38 -0
  23. package/dist/services/user-profile/user-profile-manager.d.ts +2 -2
  24. package/dist/services/user-profile/user-profile-manager.js +2 -2
  25. package/dist/web/assets/index-BkHg0H-_.js +127 -0
  26. package/dist/web/assets/index-D2yC6v1B.css +2 -0
  27. package/dist/web/index.html +2 -2
  28. package/opencode/tui.tsx +3 -3
  29. package/package.json +1 -1
  30. package/dist/web/assets/index-B46sRbG_.js +0 -127
  31. package/dist/web/assets/index-BktRr9cC.css +0 -2
@@ -9,7 +9,7 @@ import { applySafeExtraParams } from "../ai/providers/base-provider.js";
9
9
  import { completeStructured, resolveHostProfileModel } from "./profile-model.js";
10
10
  import { CATEGORY_GROUP_THRESHOLD, collectCategoryLabels, groupCategoryLabels, groupId, recordRenames, restoreRenames, } from "./category-groups.js";
11
11
  import { canonicaliseCategories, categoryKey, tidyCategory } from "./profile-category.js";
12
- import { latestOpenCleanup, markCleanupUndone, recordCleanup } from "./category-cleanup-store.js";
12
+ import { latestOpenCleanup, loadVerdicts, markCleanupUndone, pruneVerdicts, recordCleanup, saveVerdicts, } from "./category-cleanup-store.js";
13
13
  export const GROUPS_PER_MODEL_CALL = 20;
14
14
  export const MAX_EXAMPLES_PER_LABEL = 3;
15
15
  /**
@@ -83,23 +83,43 @@ function applyReply(group, answer) {
83
83
  : labels[0].label;
84
84
  return { id: groupId(labels), labels, name, checked: true };
85
85
  }
86
+ function verdictOf(answer) {
87
+ if (!answer || !Array.isArray(answer.keep))
88
+ return null;
89
+ return {
90
+ keep: answer.keep.filter((name) => typeof name === "string"),
91
+ name: typeof answer.name === "string" ? answer.name : null,
92
+ };
93
+ }
86
94
  /**
87
95
  * The profile model checks each group in batches: it removes names that do not belong
88
- * and proposes one name. A failed batch, a missing reply, or no model falls back to the
89
- * most used label and marks the group unchecked. The log holds counts and codes only.
96
+ * and proposes one name. A group with a saved answer skips the model. A failed batch, a
97
+ * missing reply, or no model falls back to the most used label, marks the group unchecked
98
+ * and saves nothing, so a later preview retries it. The log holds counts and codes only.
90
99
  */
91
100
  export async function checkAndNameGroups(groups, examples, model,
92
- /** Called with batches done and the total, before the first batch and after each one. */
93
- onProgress) {
101
+ /** Called with batches to run and the total, before the first batch and after each one. */
102
+ onProgress, cache) {
103
+ const named = new Map();
104
+ const pending = [];
105
+ for (const group of groups) {
106
+ const saved = cache?.saved.get(group.id);
107
+ if (saved)
108
+ named.set(group.id, applyReply(group, saved));
109
+ else
110
+ pending.push(group);
111
+ }
94
112
  const batches = [];
95
- for (let start = 0; start < groups.length; start += GROUPS_PER_MODEL_CALL) {
96
- batches.push(groups.slice(start, start + GROUPS_PER_MODEL_CALL));
113
+ for (let start = 0; start < pending.length; start += GROUPS_PER_MODEL_CALL) {
114
+ batches.push(pending.slice(start, start + GROUPS_PER_MODEL_CALL));
97
115
  }
98
116
  let failedBatches = 0;
99
- let dropped = 0;
100
117
  const checkBatch = async (batch) => {
101
- if (!model)
102
- return batch.map(fallback);
118
+ if (!model) {
119
+ for (const group of batch)
120
+ named.set(group.id, fallback(group));
121
+ return;
122
+ }
103
123
  let replies;
104
124
  try {
105
125
  replies = readReply(await model(SYSTEM_PROMPT, buildPrompt(batch, examples)));
@@ -110,34 +130,50 @@ onProgress) {
110
130
  groups: batch.length,
111
131
  code: error instanceof Error ? error.name : "unknown",
112
132
  });
113
- return batch.map(fallback);
133
+ for (const group of batch)
134
+ named.set(group.id, fallback(group));
135
+ return;
114
136
  }
115
- const results = [];
137
+ const verdicts = new Map();
116
138
  for (const group of batch) {
117
- const result = applyReply(group, replies.get(group.id));
118
- if (result)
119
- results.push(result);
120
- else
121
- dropped++;
139
+ const answer = replies.get(group.id);
140
+ named.set(group.id, applyReply(group, answer));
141
+ const verdict = verdictOf(answer);
142
+ if (verdict)
143
+ verdicts.set(group.id, verdict);
144
+ }
145
+ if (cache && verdicts.size > 0) {
146
+ try {
147
+ await cache.save(verdicts);
148
+ }
149
+ catch (error) {
150
+ log("category cleanup: verdict save failed", {
151
+ groups: verdicts.size,
152
+ code: error instanceof Error ? error.name : "unknown",
153
+ });
154
+ }
122
155
  }
123
- return results;
124
156
  };
125
157
  // One call at a time: a provider that refuses parallel calls still finishes (TDR-044).
126
- const named = [];
127
158
  onProgress?.(0, batches.length);
128
159
  for (const [index, batch] of batches.entries()) {
129
- named.push(...(await checkBatch(batch)));
160
+ await checkBatch(batch);
130
161
  onProgress?.(index + 1, batches.length);
131
162
  }
163
+ const result = groups
164
+ .map((group) => named.get(group.id))
165
+ .filter((group) => !!group);
132
166
  log("category cleanup: groups checked", {
133
167
  groups: groups.length,
134
- named: named.length,
135
- dropped,
136
- unchecked: named.filter((group) => !group.checked).length,
168
+ reused: groups.length - pending.length,
169
+ sent: pending.length,
170
+ named: result.length,
171
+ dropped: groups.length - result.length,
172
+ unchecked: result.filter((group) => !group.checked).length,
137
173
  failedBatches,
138
174
  model: model ? "available" : "none",
139
175
  });
140
- return named;
176
+ return result;
141
177
  }
142
178
  /** The host model when one is registered, else the external API, else none. */
143
179
  export async function resolveCleanupModel() {
@@ -219,28 +255,53 @@ function countAfter(labels, groups) {
219
255
  }
220
256
  return new Set(labels.map((entry) => categoryKey(rename.get(entry.label) ?? entry.label))).size;
221
257
  }
258
+ /** Opens the saved model answers for the groups; without the store the preview just asks the model. */
259
+ async function openVerdictCache(profileId, groupIds) {
260
+ try {
261
+ await pruneVerdicts(profileId, groupIds);
262
+ return {
263
+ saved: await loadVerdicts(profileId),
264
+ save: (verdicts) => saveVerdicts(profileId, verdicts),
265
+ };
266
+ }
267
+ catch (error) {
268
+ log("category cleanup: verdict store unavailable", {
269
+ code: error instanceof Error ? error.name : "unknown",
270
+ });
271
+ return undefined;
272
+ }
273
+ }
222
274
  /** Builds the groups for review. Changes nothing in the stored profile. */
223
275
  export async function previewCategoryCleanup(userId, deps = {}) {
224
276
  const manager = await managerOf(deps);
225
277
  const profile = await manager.getActiveProfile(userId);
226
278
  if (!profile)
227
279
  return null;
280
+ const started = Date.now();
228
281
  const data = readProfile(profile.profileData);
229
282
  const labels = collectCategoryLabels(data);
230
283
  const embed = deps.embed ?? (await defaultEmbed());
231
284
  const vectors = [];
232
285
  for (const entry of labels)
233
286
  vectors.push({ ...entry, vector: await embed(entry.label) });
287
+ const embedded = Date.now();
234
288
  const groups = groupCategoryLabels(vectors, deps.threshold ?? CATEGORY_GROUP_THRESHOLD);
289
+ const grouped = Date.now();
290
+ const cache = await openVerdictCache(profile.id, groups.map((group) => group.id));
235
291
  const model = deps.model === undefined ? await resolveCleanupModel() : deps.model;
236
- const named = await checkAndNameGroups(groups, examplesByLabel(data), model, deps.onProgress);
292
+ const named = await checkAndNameGroups(groups, examplesByLabel(data), model, deps.onProgress, cache);
293
+ const reused = groups.filter((group) => cache?.saved.has(group.id)).length;
237
294
  log("category cleanup: preview built", {
238
295
  labels: labels.length,
239
296
  groups: named.length,
297
+ reusedGroups: reused,
298
+ sentGroups: groups.length - reused,
299
+ embedMs: embedded - started,
300
+ groupMs: grouped - embedded,
301
+ checkMs: Date.now() - grouped,
240
302
  });
241
303
  return {
242
304
  profileId: profile.id,
243
- version: profile.version,
244
305
  before: labels.length,
245
306
  after: countAfter(labels, named),
246
307
  groups: named,
@@ -255,16 +316,18 @@ function validGroups(groups) {
255
316
  typeof group.name === "string" &&
256
317
  group.name.trim().length > 0));
257
318
  }
258
- /** Renames the categories of the accepted groups; refuses when the profile changed since the preview. */
319
+ /**
320
+ * Renames the categories of the accepted groups on the profile as stored now. Learning may
321
+ * have saved newer versions since the preview; the rename goes by label, so that is safe.
322
+ * It refuses only when a save lands between the read and the write below.
323
+ */
259
324
  export async function applyCategoryCleanup(userId, input, deps = {}) {
260
- if (!Number.isInteger(input?.version) || !validGroups(input.groups))
325
+ if (!validGroups(input?.groups))
261
326
  return { ok: false, error: "invalid" };
262
327
  const manager = await managerOf(deps);
263
328
  const profile = await manager.getActiveProfile(userId);
264
329
  if (!profile)
265
330
  return { ok: false, error: "no_profile" };
266
- if (profile.version !== input.version)
267
- return { ok: false, error: "stale" };
268
331
  const data = readProfile(profile.profileData);
269
332
  const rename = new Map();
270
333
  for (const group of input.groups) {
@@ -281,7 +344,7 @@ export async function applyCategoryCleanup(userId, input, deps = {}) {
281
344
  if (renames.length === 0)
282
345
  return { ok: true, renamed: 0 };
283
346
  const summary = `Category cleanup: ${renames.length} items renamed in ${input.groups.length} groups`;
284
- if (!(await manager.updateProfile(profile.id, next, 0, summary, input.version))) {
347
+ if (!(await manager.updateProfile(profile.id, next, 0, summary, profile.version))) {
285
348
  return { ok: false, error: "stale" };
286
349
  }
287
350
  await recordCleanup(profile.id, renames);
@@ -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(`