om-memory-system 4.14.1 → 4.16.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 (38) hide show
  1. package/dist/adapters/opencode/profile-learning.js +6 -25
  2. package/dist/cli/index.js +22 -0
  3. package/dist/cli/profile-catch-up-command.js +12 -0
  4. package/dist/cli/profile-cleanup-categories-command.d.ts +3 -0
  5. package/dist/cli/profile-cleanup-categories-command.js +92 -0
  6. package/dist/cli/profile-normalise-categories-command.d.ts +2 -0
  7. package/dist/cli/profile-normalise-categories-command.js +85 -0
  8. package/dist/core/extraction.d.ts +15 -0
  9. package/dist/core/extraction.js +36 -0
  10. package/dist/core/profile-analysis.js +9 -5
  11. package/dist/core/retrieval.js +2 -1
  12. package/dist/importer/import-runs.js +9 -3
  13. package/dist/services/api-handlers.d.ts +7 -0
  14. package/dist/services/api-handlers.js +72 -0
  15. package/dist/services/context.d.ts +1 -0
  16. package/dist/services/context.js +3 -1
  17. package/dist/services/user-profile/category-cleanup-job.d.ts +30 -0
  18. package/dist/services/user-profile/category-cleanup-job.js +50 -0
  19. package/dist/services/user-profile/category-cleanup-store.d.ts +8 -0
  20. package/dist/services/user-profile/category-cleanup-store.js +35 -0
  21. package/dist/services/user-profile/category-cleanup.d.ts +74 -0
  22. package/dist/services/user-profile/category-cleanup.js +310 -0
  23. package/dist/services/user-profile/category-groups.d.ts +60 -0
  24. package/dist/services/user-profile/category-groups.js +171 -0
  25. package/dist/services/user-profile/profile-candidate-matching.d.ts +19 -0
  26. package/dist/services/user-profile/profile-candidate-matching.js +67 -0
  27. package/dist/services/user-profile/profile-category.d.ts +13 -0
  28. package/dist/services/user-profile/profile-category.js +43 -0
  29. package/dist/services/user-profile/user-profile-manager.d.ts +9 -1
  30. package/dist/services/user-profile/user-profile-manager.js +46 -65
  31. package/dist/services/web-server.js +16 -1
  32. package/dist/web/assets/index-B46sRbG_.js +127 -0
  33. package/dist/web/assets/index-BktRr9cC.css +2 -0
  34. package/dist/web/index.html +2 -2
  35. package/package.json +1 -1
  36. package/skills/omms-memory/SKILL.md +11 -2
  37. package/dist/web/assets/index-C9EubnKm.js +0 -127
  38. package/dist/web/assets/index-xoKkyBBV.css +0 -2
@@ -0,0 +1,171 @@
1
+ import { createHash } from "node:crypto";
2
+ import { canonicaliseCategories, categoryKey } from "./profile-category.js";
3
+ /** Average cosine similarity at which two groups of category names count as one meaning. */
4
+ export const CATEGORY_GROUP_THRESHOLD = 0.8;
5
+ const RESERVED_EXPLICIT = "explicit";
6
+ /** Distinct labels across preferences and patterns after the spelling rules, most used first. */
7
+ export function collectCategoryLabels(profile) {
8
+ const canonical = canonicaliseCategories(profile);
9
+ const counts = new Map();
10
+ for (const item of [...canonical.preferences, ...canonical.patterns]) {
11
+ counts.set(item.category, (counts.get(item.category) ?? 0) + 1);
12
+ }
13
+ return [...counts]
14
+ .map(([label, count]) => ({ label, count }))
15
+ .sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
16
+ }
17
+ function cosine(a, b) {
18
+ let dot = 0;
19
+ let normA = 0;
20
+ let normB = 0;
21
+ for (let i = 0; i < a.length; i++) {
22
+ dot += a[i] * b[i];
23
+ normA += a[i] * a[i];
24
+ normB += b[i] * b[i];
25
+ }
26
+ return normA && normB ? dot / Math.sqrt(normA * normB) : 0;
27
+ }
28
+ export function groupId(labels) {
29
+ const keys = labels.map((entry) => categoryKey(entry.label)).sort();
30
+ return createHash("sha256").update(JSON.stringify(keys)).digest("hex").slice(0, 12);
31
+ }
32
+ /**
33
+ * Average-linkage clustering of one connected component. Two clusters merge while
34
+ * the mean similarity over all their member pairs reaches the threshold, so a
35
+ * chain A-B-C does not pull A and C together through B.
36
+ */
37
+ function clusterComponent(members, sim, threshold) {
38
+ let clusters = members.map((member) => [member]);
39
+ const pairSim = new Map();
40
+ const key = (a, b) => (a < b ? `${a}:${b}` : `${b}:${a}`);
41
+ for (let a = 0; a < clusters.length; a++) {
42
+ for (let b = a + 1; b < clusters.length; b++) {
43
+ pairSim.set(key(a, b), sim(members[a], members[b]));
44
+ }
45
+ }
46
+ const ids = clusters.map((_, index) => index);
47
+ const byId = new Map(ids.map((id, index) => [id, clusters[index]]));
48
+ let nextId = ids.length;
49
+ for (;;) {
50
+ let best = -Infinity;
51
+ let pair = null;
52
+ const live = [...byId.keys()];
53
+ for (let i = 0; i < live.length; i++) {
54
+ for (let j = i + 1; j < live.length; j++) {
55
+ const value = pairSim.get(key(live[i], live[j]));
56
+ if (value > best) {
57
+ best = value;
58
+ pair = [live[i], live[j]];
59
+ }
60
+ }
61
+ }
62
+ if (!pair || best < threshold)
63
+ break;
64
+ const [a, b] = pair;
65
+ const sizeA = byId.get(a).length;
66
+ const sizeB = byId.get(b).length;
67
+ const merged = nextId++;
68
+ for (const other of live) {
69
+ if (other === a || other === b)
70
+ continue;
71
+ const value = (sizeA * pairSim.get(key(a, other)) + sizeB * pairSim.get(key(b, other))) /
72
+ (sizeA + sizeB);
73
+ pairSim.set(key(merged, other), value);
74
+ }
75
+ byId.set(merged, [...byId.get(a), ...byId.get(b)]);
76
+ byId.delete(a);
77
+ byId.delete(b);
78
+ }
79
+ clusters = [...byId.values()];
80
+ return clusters.filter((cluster) => cluster.length >= 2);
81
+ }
82
+ /**
83
+ * Groups category names that mean the same thing. Only labels joined by at least one
84
+ * pair at or above the threshold can share a group, so clustering runs per connected
85
+ * component of that graph. A group needs two labels; `explicit` never joins one.
86
+ */
87
+ export function groupCategoryLabels(labels, threshold = CATEGORY_GROUP_THRESHOLD) {
88
+ const usable = labels.filter((entry) => categoryKey(entry.label) !== RESERVED_EXPLICIT);
89
+ const cache = new Map();
90
+ const sim = (i, j) => {
91
+ const cacheKey = i < j ? `${i}:${j}` : `${j}:${i}`;
92
+ let value = cache.get(cacheKey);
93
+ if (value === undefined) {
94
+ value = cosine(usable[i].vector, usable[j].vector);
95
+ cache.set(cacheKey, value);
96
+ }
97
+ return value;
98
+ };
99
+ const parent = usable.map((_, index) => index);
100
+ const find = (i) => (parent[i] === i ? i : (parent[i] = find(parent[i])));
101
+ for (let i = 0; i < usable.length; i++) {
102
+ for (let j = i + 1; j < usable.length; j++) {
103
+ if (sim(i, j) >= threshold)
104
+ parent[find(i)] = find(j);
105
+ }
106
+ }
107
+ const components = new Map();
108
+ usable.forEach((_, index) => {
109
+ const root = find(index);
110
+ components.set(root, [...(components.get(root) ?? []), index]);
111
+ });
112
+ const groups = [];
113
+ for (const members of components.values()) {
114
+ if (members.length < 2)
115
+ continue;
116
+ for (const cluster of clusterComponent(members, sim, threshold)) {
117
+ const entries = cluster
118
+ .map((index) => ({ label: usable[index].label, count: usable[index].count }))
119
+ .sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
120
+ groups.push({ id: groupId(entries), labels: entries });
121
+ }
122
+ }
123
+ const total = (group) => group.labels.reduce((sum, entry) => sum + entry.count, 0);
124
+ return groups.sort((a, b) => total(b) - total(a) || a.labels[0].label.localeCompare(b.labels[0].label));
125
+ }
126
+ const SECTIONS = ["preferences", "patterns"];
127
+ export function descriptionHash(description) {
128
+ return createHash("sha256").update(description).digest("hex");
129
+ }
130
+ /** Renames between two versions of one profile whose items are in the same order. */
131
+ export function recordRenames(before, after) {
132
+ const renames = [];
133
+ for (const section of SECTIONS) {
134
+ before[section].forEach((item, index) => {
135
+ const next = after[section][index];
136
+ if (next && next.category !== item.category) {
137
+ renames.push({
138
+ section,
139
+ descriptionSha256: descriptionHash(item.description),
140
+ from: item.category,
141
+ to: next.category,
142
+ });
143
+ }
144
+ });
145
+ }
146
+ return renames;
147
+ }
148
+ /**
149
+ * Puts the old category back on each renamed item that still exists with the
150
+ * category the cleanup gave it. Items added or merged later are left alone.
151
+ */
152
+ export function restoreRenames(current, renames) {
153
+ const profile = {
154
+ ...current,
155
+ preferences: current.preferences.map((item) => ({ ...item })),
156
+ patterns: current.patterns.map((item) => ({ ...item })),
157
+ };
158
+ const used = new Set();
159
+ let restored = 0;
160
+ for (const rename of renames) {
161
+ const index = profile[rename.section].findIndex((item, position) => !used.has(`${rename.section}:${position}`) &&
162
+ item.category === rename.to &&
163
+ descriptionHash(item.description) === rename.descriptionSha256);
164
+ if (index < 0)
165
+ continue;
166
+ used.add(`${rename.section}:${index}`);
167
+ profile[rename.section][index].category = rename.from;
168
+ restored++;
169
+ }
170
+ return { profile, restored, notFound: renames.length - restored };
171
+ }
@@ -0,0 +1,19 @@
1
+ /** Rank embedding matches without changing profile items or applying merge actions. */
2
+ export declare function rankProfileCandidates(existing: readonly {
3
+ category?: string;
4
+ centroid?: number[];
5
+ }[], category: string | undefined, embedding: number[], isWorkflow: boolean, config: {
6
+ userProfileEmbeddingThresholdSameCat: number;
7
+ userProfileEmbeddingThresholdSameCatWeak: number;
8
+ userProfileEmbeddingThresholdCrossCat: number;
9
+ userProfileEmbeddingThresholdCrossCatWeak: number;
10
+ }): {
11
+ top1Score: number;
12
+ top1Idx: number;
13
+ top1SameCat: boolean;
14
+ top1Band: "strong" | "weak" | null;
15
+ top2Score: number;
16
+ top2Idx: number;
17
+ top2SameCat: boolean;
18
+ top2Band: "strong" | "weak" | null;
19
+ };
@@ -0,0 +1,67 @@
1
+ import { cosineSimilarityNumbers } from "../../utils/math.js";
2
+ import { categoryKey } from "./profile-category.js";
3
+ /** Rank embedding matches without changing profile items or applying merge actions. */
4
+ export function rankProfileCandidates(existing, category, embedding, isWorkflow, config) {
5
+ let top1Score = 0;
6
+ let top1Idx = -1;
7
+ let top1SameCat = false;
8
+ let top1Band = null;
9
+ let top2Score = 0;
10
+ let top2Idx = -1;
11
+ let top2SameCat = false;
12
+ let top2Band = null;
13
+ for (let i = 0; i < existing.length; i++) {
14
+ const existingItem = existing[i];
15
+ if (!existingItem)
16
+ continue;
17
+ const centroid = existingItem.centroid;
18
+ if (!centroid)
19
+ continue;
20
+ const score = cosineSimilarityNumbers(embedding, centroid);
21
+ const sameCat = isWorkflow
22
+ ? existingItem.category === category
23
+ : categoryKey(existingItem.category ?? "") === categoryKey(category ?? "");
24
+ const strongThreshold = sameCat
25
+ ? config.userProfileEmbeddingThresholdSameCat
26
+ : config.userProfileEmbeddingThresholdCrossCat;
27
+ const weakThreshold = sameCat
28
+ ? config.userProfileEmbeddingThresholdSameCatWeak
29
+ : config.userProfileEmbeddingThresholdCrossCatWeak;
30
+ let band;
31
+ if (score >= strongThreshold) {
32
+ band = "strong";
33
+ }
34
+ else if (score >= weakThreshold && !isWorkflow) {
35
+ band = "weak";
36
+ }
37
+ else {
38
+ continue;
39
+ }
40
+ if (score > top1Score) {
41
+ top2Score = top1Score;
42
+ top2Idx = top1Idx;
43
+ top2SameCat = top1SameCat;
44
+ top2Band = top1Band;
45
+ top1Score = score;
46
+ top1Idx = i;
47
+ top1SameCat = sameCat;
48
+ top1Band = band;
49
+ }
50
+ else if (score > top2Score) {
51
+ top2Score = score;
52
+ top2Idx = i;
53
+ top2SameCat = sameCat;
54
+ top2Band = band;
55
+ }
56
+ }
57
+ return {
58
+ top1Score,
59
+ top1Idx,
60
+ top1SameCat,
61
+ top1Band,
62
+ top2Score,
63
+ top2Idx,
64
+ top2SameCat,
65
+ top2Band,
66
+ };
67
+ }
@@ -0,0 +1,13 @@
1
+ /** Hidden identity for category spellings; never used as a visible label. */
2
+ export declare function categoryKey(label: string): string;
3
+ /** Tidy spacing without losing capitals in acronyms or proper names. */
4
+ export declare function tidyCategory(label: string): string;
5
+ /** Choose one label per key across preferences and patterns, keeping all other data. */
6
+ export declare function canonicaliseCategories<T extends {
7
+ preferences: {
8
+ category: string;
9
+ }[];
10
+ patterns: {
11
+ category: string;
12
+ }[];
13
+ }>(profile: T): T;
@@ -0,0 +1,43 @@
1
+ const RESERVED_EXPLICIT = "explicit";
2
+ /** Hidden identity for category spellings; never used as a visible label. */
3
+ export function categoryKey(label) {
4
+ return label.replace(/[_-]/g, " ").replace(/\s+/g, " ").trim().toLowerCase() || "general";
5
+ }
6
+ /** Tidy spacing without losing capitals in acronyms or proper names. */
7
+ export function tidyCategory(label) {
8
+ const tidy = label.replace(/_/g, " ").replace(/\s+/g, " ").trim();
9
+ if (!tidy)
10
+ return "General";
11
+ if (tidy === "explicit" || /\p{Lu}/u.test(tidy))
12
+ return tidy;
13
+ return tidy.charAt(0).toUpperCase() + tidy.slice(1);
14
+ }
15
+ /** Choose one label per key across preferences and patterns, keeping all other data. */
16
+ export function canonicaliseCategories(profile) {
17
+ const spellings = new Map();
18
+ for (const item of [...profile.preferences, ...profile.patterns]) {
19
+ const key = categoryKey(item.category);
20
+ const counts = spellings.get(key) ?? new Map();
21
+ counts.set(item.category, (counts.get(item.category) ?? 0) + 1);
22
+ spellings.set(key, counts);
23
+ }
24
+ const labels = new Map();
25
+ for (const [key, counts] of spellings) {
26
+ // "explicit" is a reserved discriminator in mergeItems; no spelling may displace it.
27
+ if (key === RESERVED_EXPLICIT) {
28
+ labels.set(key, RESERVED_EXPLICIT);
29
+ continue;
30
+ }
31
+ // Stable sorting keeps the first seen spelling when all tie-breaks are equal.
32
+ const ranked = [...counts].sort(([a, aCount], [b, bCount]) => Number(/[_-]/.test(a)) - Number(/[_-]/.test(b)) ||
33
+ bCount - aCount ||
34
+ Number(/^\p{Lu}/u.test(b)) - Number(/^\p{Lu}/u.test(a)));
35
+ labels.set(key, tidyCategory(ranked[0][0]));
36
+ }
37
+ const rewrite = (items) => items.map((item) => ({ ...item, category: labels.get(categoryKey(item.category)) }));
38
+ return {
39
+ ...profile,
40
+ preferences: rewrite(profile.preferences),
41
+ patterns: rewrite(profile.patterns),
42
+ };
43
+ }
@@ -1,5 +1,6 @@
1
1
  import type { UserProfile, UserProfileChangelog, UserProfileData } from "./types.js";
2
2
  import { EmbeddingService } from "../embedding.js";
3
+ export declare const USER_PROFILES_DB_NAME = "user-profiles.db";
3
4
  export declare class UserProfileManager {
4
5
  private db;
5
6
  private dbPath;
@@ -18,7 +19,9 @@ export declare class UserProfileManager {
18
19
  private initDatabase;
19
20
  getActiveProfile(userId: string): Promise<UserProfile | null>;
20
21
  createProfile(userId: string, displayName: string, userName: string, userEmail: string, profileData: UserProfileData, promptsAnalyzed: number): Promise<string>;
21
- updateProfile(profileId: string, profileData: UserProfileData, additionalPromptsAnalyzed: number, changeSummary: string): Promise<boolean>;
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>;
22
25
  private addChangelog;
23
26
  private cleanupOldChangelogs;
24
27
  getProfileChangelogs(profileId: string, limit?: number): Promise<UserProfileChangelog[]>;
@@ -43,6 +46,11 @@ export declare class UserProfileManager {
43
46
  * Do NOT call for forced merge or cross-validation — those handle fields directly.
44
47
  */
45
48
  private mergeConfirmedMatch;
49
+ /**
50
+ * A match keeps the fullest known step list: the new steps win only when they hold more
51
+ * concrete (non-blank) steps. Blank entries never count, and a winning list drops them.
52
+ */
53
+ private longerSteps;
46
54
  private initItem;
47
55
  /**
48
56
  * Three-way merge: item1 + item2 + newItem → single combined entry.
@@ -2,8 +2,11 @@ import { join } from "node:path";
2
2
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { tursoConnectionManager } from "../turso/connection-manager.js";
4
4
  import { CONFIG } from "../../config.js";
5
+ import { hasEnoughWorkflowSteps } from "../../core/extraction.js";
5
6
  import { completeStructured, resolveHostProfileModel } from "./profile-model.js";
6
7
  import { safeArray } from "./profile-utils.js";
8
+ import { rankProfileCandidates } from "./profile-candidate-matching.js";
9
+ import { canonicaliseCategories, categoryKey } from "./profile-category.js";
7
10
  import { EmbeddingService } from "../embedding.js";
8
11
  import { log } from "../logger.js";
9
12
  import { cosineSimilarityNumbers, l2Normalize } from "../../utils/math.js";
@@ -63,7 +66,7 @@ function normalizeDescription(text) {
63
66
  .replace(/\s+/g, " ")
64
67
  .trim();
65
68
  }
66
- const USER_PROFILES_DB_NAME = "user-profiles.db";
69
+ export const USER_PROFILES_DB_NAME = "user-profiles.db";
67
70
  const COLD_BUFFER_DEFAULT_KEY = "__unattributed__";
68
71
  export class UserProfileManager {
69
72
  db = null;
@@ -242,12 +245,13 @@ export class UserProfileManager {
242
245
  const db = await this.ready();
243
246
  const id = `profile_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
244
247
  const now = Date.now();
245
- const cleanedData = {
248
+ // A first profile skips mergeProfileData, so the step minimum is applied here.
249
+ const cleanedData = canonicaliseCategories({
246
250
  preferences: safeArray(profileData.preferences),
247
251
  patterns: safeArray(profileData.patterns),
248
- workflows: safeArray(profileData.workflows),
252
+ workflows: safeArray(profileData.workflows).filter(hasEnoughWorkflowSteps),
249
253
  ...(profileData.learning_paths ? { learning_paths: profileData.learning_paths } : {}),
250
- };
254
+ });
251
255
  await db.run(`
252
256
  INSERT INTO user_profiles (
253
257
  id, user_id, display_name, user_name, user_email,
@@ -269,17 +273,21 @@ export class UserProfileManager {
269
273
  await this.addChangelog(id, 1, "create", "Initial profile creation", cleanedData);
270
274
  return id;
271
275
  }
272
- async updateProfile(profileId, profileData, additionalPromptsAnalyzed, changeSummary) {
276
+ async updateProfile(profileId, profileData, additionalPromptsAnalyzed, changeSummary,
277
+ /** When given, refuse the save unless the stored version still equals it. */
278
+ expectedVersion) {
273
279
  const db = await this.ready();
274
280
  const now = Date.now();
275
- const cleanedData = {
281
+ const cleanedData = canonicaliseCategories({
276
282
  preferences: safeArray(profileData.preferences),
277
283
  patterns: safeArray(profileData.patterns),
278
284
  workflows: safeArray(profileData.workflows),
279
285
  ...(profileData.learning_paths ? { learning_paths: profileData.learning_paths } : {}),
280
- };
286
+ });
281
287
  const versionRow = await db.get(`SELECT version FROM user_profiles WHERE id = ?`, [profileId]);
282
288
  const currentVersion = Number(versionRow?.version ?? 0);
289
+ if (expectedVersion !== undefined && currentVersion !== expectedVersion)
290
+ return false;
283
291
  const newVersion = currentVersion + 1;
284
292
  const changes = await db.run(`
285
293
  UPDATE user_profiles
@@ -472,7 +480,7 @@ export class UserProfileManager {
472
480
  merged.patterns = await this.mergeItems(merged.patterns, this.ensureArray(updates.patterns), "pattern", embedService, profileId);
473
481
  }
474
482
  if (updates.workflows) {
475
- merged.workflows = await this.mergeItems(merged.workflows, this.ensureArray(updates.workflows), "workflow", embedService, profileId);
483
+ merged.workflows = await this.mergeItems(merged.workflows, this.ensureArray(updates.workflows).filter(hasEnoughWorkflowSteps), "workflow", embedService, profileId);
476
484
  }
477
485
  if (profileId) {
478
486
  if (merged.preferences.length >= 2) {
@@ -495,10 +503,10 @@ export class UserProfileManager {
495
503
  const useEmbedding = embed.isWarmedUp;
496
504
  const minDescLen = CONFIG.userProfileEmbeddingMinDescriptionLength;
497
505
  const sameCatStrong = CONFIG.userProfileEmbeddingThresholdSameCat;
498
- const sameCatWeak = CONFIG.userProfileEmbeddingThresholdSameCatWeak;
499
- const crossCatStrong = CONFIG.userProfileEmbeddingThresholdCrossCat;
500
- const crossCatWeak = CONFIG.userProfileEmbeddingThresholdCrossCatWeak;
501
506
  const driftThreshold = CONFIG.userProfileCentroidDriftThreshold;
507
+ // Workflows have no category, so the same-category rules below would apply to every
508
+ // pair. They match only in the strong band and never absorb other workflows.
509
+ const isWorkflow = itemType === "workflow";
502
510
  if (!useEmbedding) {
503
511
  log("profile embedding skipped: model not warmed up");
504
512
  }
@@ -549,7 +557,10 @@ export class UserProfileManager {
549
557
  embeddingReady: useEmbedding,
550
558
  });
551
559
  for (const newItem of incoming) {
552
- const exactIdx = existing.findIndex((e) => e.category === newItem.category && e.description === newItem.description);
560
+ const exactIdx = existing.findIndex((e) => (isWorkflow
561
+ ? e.category === newItem.category
562
+ : categoryKey(e.category ?? "") === categoryKey(newItem.category ?? "")) &&
563
+ e.description === newItem.description);
553
564
  if (exactIdx >= 0) {
554
565
  const oldFreq = existing[exactIdx].frequency || 1;
555
566
  existing[exactIdx] = this.mergeConfirmedMatch(existing[exactIdx], newItem, itemType, true);
@@ -563,61 +574,17 @@ export class UserProfileManager {
563
574
  continue;
564
575
  }
565
576
  if (useEmbedding && newItem.description.length >= minDescLen) {
566
- let top1Score = 0;
567
- let top1Idx = -1;
568
- let top1SameCat = false;
569
- let top1Band = null;
570
- let top2Score = 0;
571
- let top2Idx = -1;
572
- let top2SameCat = false;
573
- let top2Band = null;
574
577
  const newEmb = await embed.embed(normalizeDescription(newItem.description), {
575
578
  task: "document",
576
579
  });
577
- for (let i = 0; i < existing.length; i++) {
578
- const existingItem = existing[i];
579
- if (!existingItem)
580
- continue;
581
- const centroid = existingItem.centroid;
582
- if (!centroid)
583
- continue;
584
- const score = cosineSimilarityNumbers(Array.from(newEmb), centroid);
585
- const sameCat = existingItem.category === newItem.category;
586
- const strongThreshold = sameCat ? sameCatStrong : crossCatStrong;
587
- const weakThreshold = sameCat ? sameCatWeak : crossCatWeak;
588
- let band;
589
- if (score >= strongThreshold) {
590
- band = "strong";
591
- }
592
- else if (score >= weakThreshold) {
593
- band = "weak";
594
- }
595
- else {
596
- continue;
597
- }
598
- if (score > top1Score) {
599
- top2Score = top1Score;
600
- top2Idx = top1Idx;
601
- top2SameCat = top1SameCat;
602
- top2Band = top1Band;
603
- top1Score = score;
604
- top1Idx = i;
605
- top1SameCat = sameCat;
606
- top1Band = band;
607
- }
608
- else if (score > top2Score) {
609
- top2Score = score;
610
- top2Idx = i;
611
- top2SameCat = sameCat;
612
- top2Band = band;
613
- }
614
- }
580
+ const { top1Score, top1Idx, top1SameCat, top1Band, top2Score, top2Idx, top2SameCat, top2Band, } = rankProfileCandidates(existing, newItem.category, Array.from(newEmb), isWorkflow, CONFIG);
615
581
  if (top1Idx >= 0 &&
616
582
  top1Band === "strong" &&
617
583
  top2Idx >= 0 &&
618
584
  top2Band === "strong" &&
619
585
  top1SameCat &&
620
- top2SameCat) {
586
+ top2SameCat &&
587
+ !isWorkflow) {
621
588
  const item1 = existing[top1Idx];
622
589
  const item2 = existing[top2Idx];
623
590
  const newEmbArr = Array.from(newEmb);
@@ -692,13 +659,14 @@ export class UserProfileManager {
692
659
  }
693
660
  existing[top1Idx] = combined;
694
661
  matchCount++;
695
- if (top1SameCat) {
662
+ if (top1SameCat && !isWorkflow) {
696
663
  const mergeIndices = [];
697
664
  for (let j = 0; j < existing.length; j++) {
698
665
  if (j === top1Idx)
699
666
  continue;
700
667
  const other = existing[j];
701
- if (!other || other.category !== existingItem.category)
668
+ if (!other ||
669
+ categoryKey(other.category ?? "") !== categoryKey(existingItem.category ?? ""))
702
670
  continue;
703
671
  const otherCentroid = other.centroid;
704
672
  if (!otherCentroid)
@@ -980,7 +948,7 @@ export class UserProfileManager {
980
948
  evidence,
981
949
  lastSeen: Date.now(),
982
950
  lastMatchTime: Date.now(),
983
- ...(itemType === "workflow" && newItem.steps?.length ? { steps: newItem.steps } : {}),
951
+ ...(itemType === "workflow" ? this.longerSteps(existing, newItem) : {}),
984
952
  };
985
953
  this.syncConfidence(result);
986
954
  return result;
@@ -994,11 +962,23 @@ export class UserProfileManager {
994
962
  evidence,
995
963
  lastSeen: Date.now(),
996
964
  lastMatchTime: Date.now(),
997
- ...(itemType === "workflow" && newItem.steps?.length ? { steps: newItem.steps } : {}),
965
+ ...(itemType === "workflow" ? this.longerSteps(existing, newItem) : {}),
998
966
  };
999
967
  this.syncConfidence(result);
1000
968
  return result;
1001
969
  }
970
+ /**
971
+ * A match keeps the fullest known step list: the new steps win only when they hold more
972
+ * concrete (non-blank) steps. Blank entries never count, and a winning list drops them.
973
+ */
974
+ longerSteps(existing, newItem) {
975
+ const concrete = (steps) => Array.isArray(steps)
976
+ ? steps.filter((step) => typeof step === "string" && step.trim().length > 0)
977
+ : [];
978
+ const known = concrete(existing.steps).length;
979
+ const incoming = concrete(newItem.steps);
980
+ return incoming.length > known ? { steps: incoming } : {};
981
+ }
1002
982
  initItem(newItem, itemType, centroid, anchor) {
1003
983
  const hasEvidence = Array.isArray(newItem.evidence) && newItem.evidence.length > 0;
1004
984
  const item = {
@@ -1152,9 +1132,9 @@ export class UserProfileManager {
1152
1132
  break;
1153
1133
  if (removeIndices.includes(items.indexOf(a)) || removeIndices.includes(items.indexOf(b)))
1154
1134
  continue;
1135
+ checked++;
1155
1136
  const conflict = await this.checkConflict(a.description, b.description);
1156
1137
  if (conflict) {
1157
- checked++;
1158
1138
  const keeper = (a.frequency || 0) >= (b.frequency || 0) ? a : b;
1159
1139
  const removed = keeper === a ? b : a;
1160
1140
  this.lazyMigrateAlpha(keeper);
@@ -1496,7 +1476,8 @@ Generate a concise, abstract description of the user's general behavioral tenden
1496
1476
  return newDescription;
1497
1477
  }
1498
1478
  async evolveAndUpdate(item, itemType, profileId) {
1499
- if (!profileId)
1479
+ // The rewrite describes a general tendency, which turns a workflow into a persona summary.
1480
+ if (!profileId || itemType === "workflow")
1500
1481
  return;
1501
1482
  try {
1502
1483
  const evolved = await this.evolveDescription(item, itemType, profileId);
@@ -12,7 +12,7 @@ import { isOlderVersion } from "./version-compare.js";
12
12
  import { AUTH_HEADER, getOrCreateAuthToken, isAuthorizedApiRequest } from "./auth-token.js";
13
13
  import { WebAuth } from "./web-auth.js";
14
14
  import { NODE_HTTP_IDLE_TIMEOUT_MS } from "./request-timeouts.js";
15
- import { handleListTags, handleListKeywords, handleListMemories, handleAddMemory, handleDeleteMemory, handleBulkDelete, handleUpdateMemory, handleSearch, handleStats, handlePinMemory, handleUnpinMemory, handleRunCleanup, handleRunDeduplication, handleDetectMigration, handleRunMigration, handleDetectTagMigration, handleRunTagMigrationBatch, handleGetTagMigrationProgress, handleDeletePrompt, handleBulkDeletePrompts, handleGetUserProfile, handleGetProfileChangelog, handleGetProfileSnapshot, handleRefreshProfile, handleAICleanup, handleApplyCleanup, handleUpdateProfileItem, } from "./api-handlers.js";
15
+ import { handleListTags, handleListKeywords, handleListMemories, handleAddMemory, handleDeleteMemory, handleBulkDelete, handleUpdateMemory, handleSearch, handleStats, handlePinMemory, handleUnpinMemory, handleRunCleanup, handleRunDeduplication, handleDetectMigration, handleRunMigration, handleDetectTagMigration, handleRunTagMigrationBatch, handleGetTagMigrationProgress, handleDeletePrompt, handleBulkDeletePrompts, handleGetUserProfile, handleGetProfileChangelog, handleGetProfileSnapshot, handleRefreshProfile, handleAICleanup, handleApplyCleanup, handleCategoryCleanupApply, handleCategoryCleanupStart, handleCategoryCleanupStatus, handleCategoryCleanupUndo, handleUpdateProfileItem, } from "./api-handlers.js";
16
16
  const isBun = typeof globalThis.Bun !== "undefined";
17
17
  const MIN_FAILED_TAKEOVERS = 3;
18
18
  export function attachNodeDisconnectHandlers(req, res, onDisconnect) {
@@ -1357,6 +1357,21 @@ export class WebServer {
1357
1357
  const result = await handleApplyCleanup(userId, body);
1358
1358
  return this.jsonResponse(result);
1359
1359
  }
1360
+ if (path === "/api/user-profile/category-cleanup" && method === "POST") {
1361
+ const body = (await req.json().catch(() => ({})));
1362
+ return this.jsonResponse(await handleCategoryCleanupStart(body.userId || undefined));
1363
+ }
1364
+ if (path === "/api/user-profile/category-cleanup" && method === "GET") {
1365
+ return this.jsonResponse(await handleCategoryCleanupStatus(url.searchParams.get("userId") || undefined));
1366
+ }
1367
+ if (path === "/api/user-profile/category-cleanup/apply" && method === "POST") {
1368
+ const body = (await req.json().catch(() => ({})));
1369
+ return this.jsonResponse(await handleCategoryCleanupApply(body.userId || undefined, body));
1370
+ }
1371
+ if (path === "/api/user-profile/category-cleanup/undo" && method === "POST") {
1372
+ const body = (await req.json().catch(() => ({})));
1373
+ return this.jsonResponse(await handleCategoryCleanupUndo(body.userId || undefined));
1374
+ }
1360
1375
  if (path === "/api/user-profile/item" && method === "PATCH") {
1361
1376
  const body = (await req.json().catch(() => ({})));
1362
1377
  const result = await handleUpdateProfileItem(body);