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.
- package/dist/adapters/opencode/profile-learning.js +6 -25
- package/dist/cli/index.js +22 -0
- package/dist/cli/profile-catch-up-command.js +12 -0
- package/dist/cli/profile-cleanup-categories-command.d.ts +3 -0
- package/dist/cli/profile-cleanup-categories-command.js +92 -0
- package/dist/cli/profile-normalise-categories-command.d.ts +2 -0
- package/dist/cli/profile-normalise-categories-command.js +85 -0
- package/dist/core/extraction.d.ts +15 -0
- package/dist/core/extraction.js +36 -0
- package/dist/core/profile-analysis.js +9 -5
- package/dist/core/retrieval.js +2 -1
- package/dist/importer/import-runs.js +9 -3
- package/dist/services/api-handlers.d.ts +7 -0
- package/dist/services/api-handlers.js +72 -0
- package/dist/services/context.d.ts +1 -0
- package/dist/services/context.js +3 -1
- package/dist/services/user-profile/category-cleanup-job.d.ts +30 -0
- package/dist/services/user-profile/category-cleanup-job.js +50 -0
- package/dist/services/user-profile/category-cleanup-store.d.ts +8 -0
- package/dist/services/user-profile/category-cleanup-store.js +35 -0
- package/dist/services/user-profile/category-cleanup.d.ts +74 -0
- package/dist/services/user-profile/category-cleanup.js +310 -0
- package/dist/services/user-profile/category-groups.d.ts +60 -0
- package/dist/services/user-profile/category-groups.js +171 -0
- package/dist/services/user-profile/profile-candidate-matching.d.ts +19 -0
- package/dist/services/user-profile/profile-candidate-matching.js +67 -0
- package/dist/services/user-profile/profile-category.d.ts +13 -0
- package/dist/services/user-profile/profile-category.js +43 -0
- package/dist/services/user-profile/user-profile-manager.d.ts +9 -1
- package/dist/services/user-profile/user-profile-manager.js +46 -65
- package/dist/services/web-server.js +16 -1
- package/dist/web/assets/index-B46sRbG_.js +127 -0
- package/dist/web/assets/index-BktRr9cC.css +2 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/skills/omms-memory/SKILL.md +11 -2
- package/dist/web/assets/index-C9EubnKm.js +0 -127
- 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
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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 ||
|
|
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"
|
|
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"
|
|
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
|
-
|
|
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);
|