om-memory-system 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +587 -0
  3. package/dist/adapters/opencode/auto-capture-host.d.ts +3 -0
  4. package/dist/adapters/opencode/auto-capture-host.js +29 -0
  5. package/dist/adapters/opencode/auto-capture-summary.d.ts +4 -0
  6. package/dist/adapters/opencode/auto-capture-summary.js +147 -0
  7. package/dist/adapters/opencode/conversation.d.ts +2 -0
  8. package/dist/adapters/opencode/conversation.js +50 -0
  9. package/dist/adapters/opencode/user-prompt.d.ts +9 -0
  10. package/dist/adapters/opencode/user-prompt.js +26 -0
  11. package/dist/adapters/pi/capture.d.ts +41 -0
  12. package/dist/adapters/pi/capture.js +91 -0
  13. package/dist/adapters/pi/conversation.d.ts +38 -0
  14. package/dist/adapters/pi/conversation.js +118 -0
  15. package/dist/adapters/pi/extension.d.ts +18 -0
  16. package/dist/adapters/pi/extension.js +234 -0
  17. package/dist/adapters/pi/import-command.d.ts +29 -0
  18. package/dist/adapters/pi/import-command.js +232 -0
  19. package/dist/adapters/pi/profile.d.ts +39 -0
  20. package/dist/adapters/pi/profile.js +148 -0
  21. package/dist/adapters/pi/provider.d.ts +50 -0
  22. package/dist/adapters/pi/provider.js +77 -0
  23. package/dist/config.d.ts +290 -0
  24. package/dist/config.js +742 -0
  25. package/dist/core/capture-context.d.ts +4 -0
  26. package/dist/core/capture-context.js +132 -0
  27. package/dist/core/capture.d.ts +15 -0
  28. package/dist/core/capture.js +68 -0
  29. package/dist/core/extraction.d.ts +80 -0
  30. package/dist/core/extraction.js +140 -0
  31. package/dist/core/host.d.ts +52 -0
  32. package/dist/core/host.js +1 -0
  33. package/dist/core/memory-operations.d.ts +27 -0
  34. package/dist/core/memory-operations.js +247 -0
  35. package/dist/core/retrieval.d.ts +12 -0
  36. package/dist/core/retrieval.js +72 -0
  37. package/dist/importer/discovery.d.ts +31 -0
  38. package/dist/importer/discovery.js +88 -0
  39. package/dist/importer/importer.d.ts +87 -0
  40. package/dist/importer/importer.js +260 -0
  41. package/dist/importer/ledger.d.ts +44 -0
  42. package/dist/importer/ledger.js +137 -0
  43. package/dist/importer/session-loader.d.ts +19 -0
  44. package/dist/importer/session-loader.js +12 -0
  45. package/dist/index.d.ts +24 -0
  46. package/dist/index.js +601 -0
  47. package/dist/plugin.d.ts +9 -0
  48. package/dist/plugin.js +11 -0
  49. package/dist/services/ai/ai-provider-factory.d.ts +7 -0
  50. package/dist/services/ai/ai-provider-factory.js +41 -0
  51. package/dist/services/ai/api-key-placeholder.d.ts +1 -0
  52. package/dist/services/ai/api-key-placeholder.js +14 -0
  53. package/dist/services/ai/internal-capture-sessions.d.ts +6 -0
  54. package/dist/services/ai/internal-capture-sessions.js +33 -0
  55. package/dist/services/ai/opencode-diagnostics.d.ts +7 -0
  56. package/dist/services/ai/opencode-diagnostics.js +36 -0
  57. package/dist/services/ai/opencode-host-config.d.ts +10 -0
  58. package/dist/services/ai/opencode-host-config.js +51 -0
  59. package/dist/services/ai/opencode-provider-loader.d.ts +3 -0
  60. package/dist/services/ai/opencode-provider-loader.js +4 -0
  61. package/dist/services/ai/opencode-provider.d.ts +92 -0
  62. package/dist/services/ai/opencode-provider.js +419 -0
  63. package/dist/services/ai/opencode-sdk-client.d.ts +6 -0
  64. package/dist/services/ai/opencode-sdk-client.js +38 -0
  65. package/dist/services/ai/profile-llm-client.d.ts +2 -0
  66. package/dist/services/ai/profile-llm-client.js +27 -0
  67. package/dist/services/ai/provider-config.d.ts +17 -0
  68. package/dist/services/ai/provider-config.js +30 -0
  69. package/dist/services/ai/providers/anthropic-messages.d.ts +34 -0
  70. package/dist/services/ai/providers/anthropic-messages.js +220 -0
  71. package/dist/services/ai/providers/base-provider.d.ts +24 -0
  72. package/dist/services/ai/providers/base-provider.js +24 -0
  73. package/dist/services/ai/providers/google-gemini.d.ts +15 -0
  74. package/dist/services/ai/providers/google-gemini.js +228 -0
  75. package/dist/services/ai/providers/minimax.d.ts +35 -0
  76. package/dist/services/ai/providers/minimax.js +55 -0
  77. package/dist/services/ai/providers/openai-chat-completion.d.ts +22 -0
  78. package/dist/services/ai/providers/openai-chat-completion.js +387 -0
  79. package/dist/services/ai/providers/openai-responses.d.ts +13 -0
  80. package/dist/services/ai/providers/openai-responses.js +182 -0
  81. package/dist/services/ai/providers/orcarouter.d.ts +51 -0
  82. package/dist/services/ai/providers/orcarouter.js +66 -0
  83. package/dist/services/ai/session/ai-session-manager.d.ts +24 -0
  84. package/dist/services/ai/session/ai-session-manager.js +211 -0
  85. package/dist/services/ai/session/session-types.d.ts +42 -0
  86. package/dist/services/ai/session/session-types.js +1 -0
  87. package/dist/services/ai/tools/tool-schema.d.ts +40 -0
  88. package/dist/services/ai/tools/tool-schema.js +24 -0
  89. package/dist/services/ai/validators/user-profile-validator.d.ts +12 -0
  90. package/dist/services/ai/validators/user-profile-validator.js +114 -0
  91. package/dist/services/api-handlers.d.ts +186 -0
  92. package/dist/services/api-handlers.js +1284 -0
  93. package/dist/services/auth-token.d.ts +14 -0
  94. package/dist/services/auth-token.js +59 -0
  95. package/dist/services/auto-capture.d.ts +3 -0
  96. package/dist/services/auto-capture.js +127 -0
  97. package/dist/services/cleanup-service.d.ts +22 -0
  98. package/dist/services/cleanup-service.js +109 -0
  99. package/dist/services/client.d.ts +134 -0
  100. package/dist/services/client.js +327 -0
  101. package/dist/services/context.d.ts +10 -0
  102. package/dist/services/context.js +27 -0
  103. package/dist/services/cors.d.ts +12 -0
  104. package/dist/services/cors.js +52 -0
  105. package/dist/services/deduplication-service.d.ts +28 -0
  106. package/dist/services/deduplication-service.js +111 -0
  107. package/dist/services/embedding.d.ts +36 -0
  108. package/dist/services/embedding.js +200 -0
  109. package/dist/services/jsonc.d.ts +6 -0
  110. package/dist/services/jsonc.js +76 -0
  111. package/dist/services/language-detector.d.ts +2 -0
  112. package/dist/services/language-detector.js +33 -0
  113. package/dist/services/legacy-migration.d.ts +77 -0
  114. package/dist/services/legacy-migration.js +343 -0
  115. package/dist/services/logger.d.ts +1 -0
  116. package/dist/services/logger.js +119 -0
  117. package/dist/services/memory-portability-service.d.ts +37 -0
  118. package/dist/services/memory-portability-service.js +340 -0
  119. package/dist/services/memory-scope.d.ts +16 -0
  120. package/dist/services/memory-scope.js +43 -0
  121. package/dist/services/migration-service.d.ts +42 -0
  122. package/dist/services/migration-service.js +307 -0
  123. package/dist/services/onnxruntime-resolve.d.ts +24 -0
  124. package/dist/services/onnxruntime-resolve.js +174 -0
  125. package/dist/services/privacy.d.ts +26 -0
  126. package/dist/services/privacy.js +69 -0
  127. package/dist/services/request-timeouts.d.ts +9 -0
  128. package/dist/services/request-timeouts.js +11 -0
  129. package/dist/services/runtime-require.d.ts +17 -0
  130. package/dist/services/runtime-require.js +75 -0
  131. package/dist/services/secret-resolver.d.ts +1 -0
  132. package/dist/services/secret-resolver.js +65 -0
  133. package/dist/services/shard-inventory-service.d.ts +69 -0
  134. package/dist/services/shard-inventory-service.js +315 -0
  135. package/dist/services/shard-path-migration-service.d.ts +31 -0
  136. package/dist/services/shard-path-migration-service.js +367 -0
  137. package/dist/services/tag-prefix-migration.d.ts +60 -0
  138. package/dist/services/tag-prefix-migration.js +439 -0
  139. package/dist/services/tag-prefix-scan.d.ts +26 -0
  140. package/dist/services/tag-prefix-scan.js +91 -0
  141. package/dist/services/tags.d.ts +29 -0
  142. package/dist/services/tags.js +251 -0
  143. package/dist/services/turso/connection-manager.d.ts +15 -0
  144. package/dist/services/turso/connection-manager.js +163 -0
  145. package/dist/services/turso/cross-process-write-lock.d.ts +1 -0
  146. package/dist/services/turso/cross-process-write-lock.js +89 -0
  147. package/dist/services/turso/legacy-migrator.d.ts +1 -0
  148. package/dist/services/turso/legacy-migrator.js +540 -0
  149. package/dist/services/turso/lifecycle.d.ts +1 -0
  150. package/dist/services/turso/lifecycle.js +14 -0
  151. package/dist/services/turso/operation-lock.d.ts +2 -0
  152. package/dist/services/turso/operation-lock.js +61 -0
  153. package/dist/services/turso/ready.d.ts +2 -0
  154. package/dist/services/turso/ready.js +36 -0
  155. package/dist/services/turso/shard-manager.d.ts +38 -0
  156. package/dist/services/turso/shard-manager.js +496 -0
  157. package/dist/services/turso/sqlite-handle-release.d.ts +16 -0
  158. package/dist/services/turso/sqlite-handle-release.js +56 -0
  159. package/dist/services/turso/turso-db.d.ts +18 -0
  160. package/dist/services/turso/turso-db.js +53 -0
  161. package/dist/services/turso/types.d.ts +42 -0
  162. package/dist/services/turso/types.js +1 -0
  163. package/dist/services/turso/vector-search.d.ts +41 -0
  164. package/dist/services/turso/vector-search.js +384 -0
  165. package/dist/services/turso/vector-utils.d.ts +7 -0
  166. package/dist/services/turso/vector-utils.js +67 -0
  167. package/dist/services/user-memory-learning.d.ts +97 -0
  168. package/dist/services/user-memory-learning.js +673 -0
  169. package/dist/services/user-profile/ai-cleanup.d.ts +58 -0
  170. package/dist/services/user-profile/ai-cleanup.js +486 -0
  171. package/dist/services/user-profile/profile-context.d.ts +1 -0
  172. package/dist/services/user-profile/profile-context.js +105 -0
  173. package/dist/services/user-profile/profile-utils.d.ts +2 -0
  174. package/dist/services/user-profile/profile-utils.js +45 -0
  175. package/dist/services/user-profile/types.d.ts +92 -0
  176. package/dist/services/user-profile/types.js +1 -0
  177. package/dist/services/user-profile/user-profile-manager.d.ts +65 -0
  178. package/dist/services/user-profile/user-profile-manager.js +1660 -0
  179. package/dist/services/user-prompt/user-prompt-manager.d.ts +53 -0
  180. package/dist/services/user-prompt/user-prompt-manager.js +303 -0
  181. package/dist/services/web-api-auth.d.ts +3 -0
  182. package/dist/services/web-api-auth.js +25 -0
  183. package/dist/services/web-auth.d.ts +46 -0
  184. package/dist/services/web-auth.js +105 -0
  185. package/dist/services/web-server.d.ts +57 -0
  186. package/dist/services/web-server.js +659 -0
  187. package/dist/shared/api/index.d.ts +4 -0
  188. package/dist/shared/api/index.js +2 -0
  189. package/dist/shared/api/portability-schemas.d.ts +56 -0
  190. package/dist/shared/api/portability-schemas.js +39 -0
  191. package/dist/shared/api/schemas.d.ts +295 -0
  192. package/dist/shared/api/schemas.js +134 -0
  193. package/dist/shared/api/types.d.ts +28 -0
  194. package/dist/shared/api/types.js +1 -0
  195. package/dist/types/index.d.ts +27 -0
  196. package/dist/types/index.js +1 -0
  197. package/dist/utils/context-limit.d.ts +10 -0
  198. package/dist/utils/context-limit.js +43 -0
  199. package/dist/utils/math.d.ts +7 -0
  200. package/dist/utils/math.js +48 -0
  201. package/dist/utils/profile.d.ts +2 -0
  202. package/dist/utils/profile.js +15 -0
  203. package/dist/v2/adapter.d.ts +5 -0
  204. package/dist/v2/adapter.js +218 -0
  205. package/dist/v2/legacy-client.d.ts +15 -0
  206. package/dist/v2/legacy-client.js +257 -0
  207. package/dist/v2/memory-bridge.d.ts +18 -0
  208. package/dist/v2/memory-bridge.js +29 -0
  209. package/dist/v2/plugin.d.ts +3 -0
  210. package/dist/v2/plugin.js +24 -0
  211. package/dist/web/assets/index-DPjEpD1h.css +2 -0
  212. package/dist/web/assets/index-do26c4JJ.js +122 -0
  213. package/dist/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  214. package/dist/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  215. package/dist/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  216. package/dist/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  217. package/dist/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  218. package/dist/web/favicon.ico +0 -0
  219. package/dist/web/index.html +14 -0
  220. package/dist/web/omms-icon.png +0 -0
  221. package/package.json +138 -0
@@ -0,0 +1,1660 @@
1
+ import { join } from "node:path";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { tursoConnectionManager } from "../turso/connection-manager.js";
4
+ import { CONFIG } from "../../config.js";
5
+ import { safeArray } from "./profile-utils.js";
6
+ import { EmbeddingService } from "../embedding.js";
7
+ import { log } from "../logger.js";
8
+ import { cosineSimilarityNumbers, l2Normalize } from "../../utils/math.js";
9
+ import { loadOpencodeProvider } from "../ai/opencode-provider-loader.js";
10
+ const CENTROID_EMA_WEIGHT = 0.85;
11
+ const CENTROID_EMA_WEIGHT_COMPLEMENT = 0.15;
12
+ const THREE_WAY_CENTROID_W1 = 0.45;
13
+ const THREE_WAY_CENTROID_W2 = 0.45;
14
+ const THREE_WAY_CENTROID_W3 = 0.1;
15
+ const DIRECTION_VALIDATION_TOLERANCE = 0.03;
16
+ /**
17
+ * Gamma sampler (Marsaglia-Tsang 2000).
18
+ * Used by sampleBeta for Thompson Sampling weak-hit upgrades.
19
+ */
20
+ function sampleGamma(shape) {
21
+ if (shape < 1) {
22
+ const u = Math.random();
23
+ return sampleGamma(shape + 1) * Math.pow(u, 1 / shape);
24
+ }
25
+ const d = shape - 1 / 3;
26
+ const c = 1 / Math.sqrt(9 * d);
27
+ while (true) {
28
+ let x, v;
29
+ do {
30
+ x = randn();
31
+ v = 1 + c * x;
32
+ } while (v <= 0);
33
+ v = v * v * v;
34
+ const u = Math.random();
35
+ if (u < 1 - 0.0331 * x * x * x * x)
36
+ return d * v;
37
+ if (Math.log(u) < 0.5 * x * x + d * (1 - v + Math.log(v)))
38
+ return d * v;
39
+ }
40
+ }
41
+ function randn() {
42
+ let u = 0, v = 0;
43
+ while (u === 0)
44
+ u = Math.random();
45
+ while (v === 0)
46
+ v = Math.random();
47
+ return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
48
+ }
49
+ function sampleBeta(alpha, beta) {
50
+ const x = sampleGamma(alpha);
51
+ const y = sampleGamma(beta);
52
+ return x / (x + y);
53
+ }
54
+ /**
55
+ * Language-agnostic text normalization for embedding comparison.
56
+ * Strips punctuation and collapses whitespace — the embedding model
57
+ * handles semantic similarity naturally without word-level rules.
58
+ */
59
+ function normalizeDescription(text) {
60
+ return text
61
+ .trim()
62
+ .replace(/[,,。!?;;::、\n\r.]/g, " ")
63
+ .replace(/\s+/g, " ")
64
+ .trim();
65
+ }
66
+ const USER_PROFILES_DB_NAME = "user-profiles.db";
67
+ const COLD_BUFFER_DEFAULT_KEY = "__unattributed__";
68
+ export class UserProfileManager {
69
+ db = null;
70
+ dbPath;
71
+ initPromise = null;
72
+ // Cold-start buffers are keyed by profileId so items observed for one user never
73
+ // drain into another user's merge (cross-user contamination). The unattributed bucket
74
+ // (COLD_BUFFER_DEFAULT_KEY) only holds items from merges that ran without a profileId.
75
+ coldBuffers;
76
+ coldBufferPath;
77
+ dedupCheckedCache = new Set();
78
+ constructor() {
79
+ this.dbPath = join(CONFIG.storagePath || "", USER_PROFILES_DB_NAME);
80
+ this.coldBufferPath = join(CONFIG.storagePath || "", "cold-buffer.json");
81
+ this.coldBuffers = this.loadColdBuffers();
82
+ }
83
+ reset() {
84
+ this.db = null;
85
+ this.initPromise = null;
86
+ this.dbPath = join(CONFIG.storagePath || "", USER_PROFILES_DB_NAME);
87
+ this.coldBufferPath = join(CONFIG.storagePath || "", "cold-buffer.json");
88
+ }
89
+ async initialize() {
90
+ if (this.initPromise) {
91
+ return this.initPromise;
92
+ }
93
+ this.initPromise = (async () => {
94
+ try {
95
+ this.dbPath = join(CONFIG.storagePath || "", USER_PROFILES_DB_NAME);
96
+ this.coldBufferPath = join(CONFIG.storagePath || "", "cold-buffer.json");
97
+ this.db = await tursoConnectionManager.getConnection(this.dbPath);
98
+ await this.initDatabase();
99
+ }
100
+ catch (error) {
101
+ this.initPromise = null;
102
+ this.db = null;
103
+ log("user-profile-manager: db init failed", { error: String(error) });
104
+ throw error;
105
+ }
106
+ })();
107
+ return this.initPromise;
108
+ }
109
+ async ready() {
110
+ if (!this.db || !this.initPromise) {
111
+ await this.initialize();
112
+ }
113
+ else {
114
+ await this.initPromise;
115
+ }
116
+ if (!this.db) {
117
+ throw new Error("UserProfileManager: database not initialized");
118
+ }
119
+ return this.db;
120
+ }
121
+ emptyColdBuffer() {
122
+ return { preferences: [], patterns: [], workflows: [] };
123
+ }
124
+ getColdBuffer(profileId) {
125
+ const key = profileId || COLD_BUFFER_DEFAULT_KEY;
126
+ let buf = this.coldBuffers.get(key);
127
+ if (!buf) {
128
+ buf = this.emptyColdBuffer();
129
+ this.coldBuffers.set(key, buf);
130
+ }
131
+ return buf;
132
+ }
133
+ loadColdBuffers() {
134
+ const map = new Map();
135
+ try {
136
+ if (existsSync(this.coldBufferPath)) {
137
+ const raw = readFileSync(this.coldBufferPath, "utf-8");
138
+ const data = JSON.parse(raw);
139
+ // Legacy flat format ({ preferences, patterns, workflows }) is cross-user
140
+ // contaminated and cannot be attributed to a profile, so it is dropped rather
141
+ // than replayed. New format is keyed by profileId.
142
+ const isLegacyFlat = data &&
143
+ typeof data === "object" &&
144
+ !Array.isArray(data) &&
145
+ ("preferences" in data || "patterns" in data || "workflows" in data);
146
+ if (data && typeof data === "object" && !Array.isArray(data) && !isLegacyFlat) {
147
+ let loaded = 0;
148
+ for (const [pid, v] of Object.entries(data)) {
149
+ map.set(pid, {
150
+ preferences: Array.isArray(v?.preferences) ? v.preferences : [],
151
+ patterns: Array.isArray(v?.patterns) ? v.patterns : [],
152
+ workflows: Array.isArray(v?.workflows) ? v.workflows : [],
153
+ });
154
+ loaded++;
155
+ }
156
+ if (loaded > 0) {
157
+ log("profile cold buffer: loaded from disk", { profiles: loaded });
158
+ }
159
+ }
160
+ else if (isLegacyFlat) {
161
+ log("profile cold buffer: dropping legacy unattributed buffer");
162
+ }
163
+ }
164
+ }
165
+ catch {
166
+ // Corrupt or missing file — start with an empty buffer set.
167
+ }
168
+ return map;
169
+ }
170
+ saveColdBuffers() {
171
+ try {
172
+ const obj = {};
173
+ for (const [pid, v] of this.coldBuffers.entries()) {
174
+ if (v.preferences.length || v.patterns.length || v.workflows.length) {
175
+ obj[pid] = v;
176
+ }
177
+ }
178
+ writeFileSync(this.coldBufferPath, JSON.stringify(obj), "utf-8");
179
+ }
180
+ catch {
181
+ // Silently ignore disk-full / permission errors.
182
+ }
183
+ }
184
+ async initDatabase() {
185
+ const db = this.db;
186
+ await db.batch([
187
+ {
188
+ sql: `
189
+ CREATE TABLE IF NOT EXISTS user_profiles (
190
+ id TEXT PRIMARY KEY,
191
+ user_id TEXT NOT NULL UNIQUE,
192
+ display_name TEXT NOT NULL,
193
+ user_name TEXT NOT NULL,
194
+ user_email TEXT NOT NULL,
195
+ profile_data TEXT NOT NULL,
196
+ version INTEGER NOT NULL DEFAULT 1,
197
+ created_at INTEGER NOT NULL,
198
+ last_analyzed_at INTEGER NOT NULL,
199
+ total_prompts_analyzed INTEGER NOT NULL DEFAULT 0,
200
+ is_active BOOLEAN NOT NULL DEFAULT 1
201
+ )
202
+ `,
203
+ },
204
+ {
205
+ sql: `
206
+ CREATE TABLE IF NOT EXISTS user_profile_changelogs (
207
+ id TEXT PRIMARY KEY,
208
+ profile_id TEXT NOT NULL,
209
+ version INTEGER NOT NULL,
210
+ change_type TEXT NOT NULL,
211
+ change_summary TEXT NOT NULL,
212
+ profile_data_snapshot TEXT NOT NULL,
213
+ created_at INTEGER NOT NULL,
214
+ FOREIGN KEY (profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE
215
+ )
216
+ `,
217
+ },
218
+ { sql: "CREATE INDEX IF NOT EXISTS idx_user_profiles_user_id ON user_profiles(user_id)" },
219
+ {
220
+ sql: "CREATE INDEX IF NOT EXISTS idx_user_profiles_is_active ON user_profiles(is_active)",
221
+ },
222
+ {
223
+ sql: "CREATE INDEX IF NOT EXISTS idx_user_profile_changelogs_profile_id ON user_profile_changelogs(profile_id)",
224
+ },
225
+ {
226
+ sql: "CREATE INDEX IF NOT EXISTS idx_user_profile_changelogs_version ON user_profile_changelogs(version DESC)",
227
+ },
228
+ ]);
229
+ }
230
+ async getActiveProfile(userId) {
231
+ const db = await this.ready();
232
+ const row = await db.get(`
233
+ SELECT * FROM user_profiles
234
+ WHERE user_id = ? AND is_active = 1
235
+ LIMIT 1
236
+ `, [userId]);
237
+ if (!row)
238
+ return null;
239
+ return this.rowToProfile(row);
240
+ }
241
+ async createProfile(userId, displayName, userName, userEmail, profileData, promptsAnalyzed) {
242
+ const db = await this.ready();
243
+ const id = `profile_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
244
+ const now = Date.now();
245
+ const cleanedData = {
246
+ preferences: safeArray(profileData.preferences),
247
+ patterns: safeArray(profileData.patterns),
248
+ workflows: safeArray(profileData.workflows),
249
+ ...(profileData.learning_paths ? { learning_paths: profileData.learning_paths } : {}),
250
+ };
251
+ await db.run(`
252
+ INSERT INTO user_profiles (
253
+ id, user_id, display_name, user_name, user_email,
254
+ profile_data, version, created_at, last_analyzed_at,
255
+ total_prompts_analyzed, is_active
256
+ )
257
+ VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, 1)
258
+ `, [
259
+ id,
260
+ userId,
261
+ displayName,
262
+ userName,
263
+ userEmail,
264
+ JSON.stringify(cleanedData),
265
+ now,
266
+ now,
267
+ promptsAnalyzed,
268
+ ]);
269
+ await this.addChangelog(id, 1, "create", "Initial profile creation", cleanedData);
270
+ return id;
271
+ }
272
+ async updateProfile(profileId, profileData, additionalPromptsAnalyzed, changeSummary) {
273
+ const db = await this.ready();
274
+ const now = Date.now();
275
+ const cleanedData = {
276
+ preferences: safeArray(profileData.preferences),
277
+ patterns: safeArray(profileData.patterns),
278
+ workflows: safeArray(profileData.workflows),
279
+ ...(profileData.learning_paths ? { learning_paths: profileData.learning_paths } : {}),
280
+ };
281
+ const versionRow = await db.get(`SELECT version FROM user_profiles WHERE id = ?`, [profileId]);
282
+ const currentVersion = Number(versionRow?.version ?? 0);
283
+ const newVersion = currentVersion + 1;
284
+ const changes = await db.run(`
285
+ UPDATE user_profiles
286
+ SET profile_data = ?,
287
+ version = ?,
288
+ last_analyzed_at = ?,
289
+ total_prompts_analyzed = total_prompts_analyzed + ?
290
+ WHERE id = ? AND version = ?
291
+ `, [
292
+ JSON.stringify(cleanedData),
293
+ newVersion,
294
+ now,
295
+ additionalPromptsAnalyzed,
296
+ profileId,
297
+ currentVersion,
298
+ ]);
299
+ if (changes === 0) {
300
+ return false;
301
+ }
302
+ await this.addChangelog(profileId, newVersion, "update", changeSummary, cleanedData);
303
+ await this.cleanupOldChangelogs(profileId);
304
+ return true;
305
+ }
306
+ async addChangelog(profileId, version, changeType, changeSummary, profileData) {
307
+ const db = await this.ready();
308
+ const id = `changelog_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
309
+ const now = Date.now();
310
+ await db.run(`
311
+ INSERT INTO user_profile_changelogs (
312
+ id, profile_id, version, change_type, change_summary,
313
+ profile_data_snapshot, created_at
314
+ )
315
+ VALUES (?, ?, ?, ?, ?, ?, ?)
316
+ `, [id, profileId, version, changeType, changeSummary, JSON.stringify(profileData), now]);
317
+ }
318
+ async cleanupOldChangelogs(profileId) {
319
+ const db = await this.ready();
320
+ const retentionCount = CONFIG.userProfileChangelogRetentionCount;
321
+ await db.run(`
322
+ DELETE FROM user_profile_changelogs
323
+ WHERE profile_id = ?
324
+ AND id NOT IN (
325
+ SELECT id FROM user_profile_changelogs
326
+ WHERE profile_id = ?
327
+ ORDER BY version DESC
328
+ LIMIT ?
329
+ )
330
+ `, [profileId, profileId, retentionCount]);
331
+ }
332
+ async getProfileChangelogs(profileId, limit = 10) {
333
+ const db = await this.ready();
334
+ const rows = await db.all(`
335
+ SELECT * FROM user_profile_changelogs
336
+ WHERE profile_id = ?
337
+ ORDER BY version DESC
338
+ LIMIT ?
339
+ `, [profileId, limit]);
340
+ return rows.map((row) => this.rowToChangelog(row));
341
+ }
342
+ async getChangelogById(id) {
343
+ const db = await this.ready();
344
+ const row = await db.get(`SELECT * FROM user_profile_changelogs WHERE id = ?`, [id]);
345
+ if (!row)
346
+ return undefined;
347
+ return this.rowToChangelog(row);
348
+ }
349
+ decayInMemory(data) {
350
+ const now = Date.now();
351
+ const prefResult = this.decayItems(data.preferences, now);
352
+ const patResult = this.decayItems(data.patterns, now);
353
+ const wfResult = this.decayItems(data.workflows, now);
354
+ return {
355
+ data: {
356
+ ...data,
357
+ preferences: prefResult.items,
358
+ patterns: patResult.items,
359
+ workflows: wfResult.items,
360
+ },
361
+ hasChanges: prefResult.hasChanges || patResult.hasChanges || wfResult.hasChanges,
362
+ };
363
+ }
364
+ decayItems(items, now) {
365
+ const before = items.length;
366
+ let hasChanges = false;
367
+ const filtered = items.filter((item) => {
368
+ this.lazyMigrateAlpha(item);
369
+ if (item.lastSeen === undefined)
370
+ item.lastSeen = now;
371
+ if (item.evidence === undefined)
372
+ item.evidence = [];
373
+ const oldConf = item.confidence;
374
+ this.syncConfidence(item);
375
+ if (item.confidence !== oldConf)
376
+ hasChanges = true;
377
+ const age = now - (item.lastSeen || now);
378
+ const ageDays = age / (24 * 60 * 60 * 1000);
379
+ const staleDays = CONFIG.userProfileStaleDays ?? 2;
380
+ const minEvidence = CONFIG.userProfileMinEvidenceForRetention ?? 3;
381
+ const evidenceCount = Array.isArray(item.evidence)
382
+ ? item.evidence.length
383
+ : 0;
384
+ // Remove inactive, low-evidence items using configured retention thresholds.
385
+ if (ageDays > staleDays && evidenceCount < minEvidence) {
386
+ log("profile decay: removed stale", {
387
+ cat: item.category || item.description?.substring(0, 30),
388
+ evidenceCount,
389
+ minEvidence,
390
+ ageDays: Math.round(ageDays),
391
+ staleDays,
392
+ });
393
+ hasChanges = true;
394
+ return false;
395
+ }
396
+ return true;
397
+ });
398
+ return { items: filtered, hasChanges, before, removed: before - filtered.length };
399
+ }
400
+ async deleteProfile(profileId) {
401
+ const db = await this.ready();
402
+ await db.run(`DELETE FROM user_profiles WHERE id = ?`, [profileId]);
403
+ if (this.coldBuffers.delete(profileId)) {
404
+ this.saveColdBuffers();
405
+ }
406
+ }
407
+ async getProfileById(profileId) {
408
+ const db = await this.ready();
409
+ const row = await db.get(`SELECT * FROM user_profiles WHERE id = ?`, [profileId]);
410
+ if (!row)
411
+ return null;
412
+ return this.rowToProfile(row);
413
+ }
414
+ async getAllActiveProfiles() {
415
+ const db = await this.ready();
416
+ const rows = await db.all(`SELECT * FROM user_profiles WHERE is_active = 1`);
417
+ return rows.map((row) => this.rowToProfile(row));
418
+ }
419
+ rowToProfile(row) {
420
+ return {
421
+ id: row.id,
422
+ userId: row.user_id,
423
+ displayName: row.display_name,
424
+ userName: row.user_name,
425
+ userEmail: row.user_email,
426
+ profileData: row.profile_data,
427
+ version: row.version,
428
+ createdAt: row.created_at,
429
+ lastAnalyzedAt: row.last_analyzed_at,
430
+ totalPromptsAnalyzed: row.total_prompts_analyzed,
431
+ isActive: row.is_active === 1,
432
+ };
433
+ }
434
+ rowToChangelog(row) {
435
+ return {
436
+ id: row.id,
437
+ profileId: row.profile_id,
438
+ version: row.version,
439
+ changeType: row.change_type,
440
+ changeSummary: row.change_summary,
441
+ profileDataSnapshot: row.profile_data_snapshot,
442
+ createdAt: row.created_at,
443
+ };
444
+ }
445
+ async mergeProfileData(existing, updates, embedService, profileId) {
446
+ const merged = {
447
+ preferences: this.ensureArray(existing?.preferences),
448
+ patterns: this.ensureArray(existing?.patterns),
449
+ workflows: this.ensureArray(existing?.workflows),
450
+ };
451
+ if (updates.preferences) {
452
+ merged.preferences = await this.mergeItems(merged.preferences, this.ensureArray(updates.preferences), "preference", embedService, profileId);
453
+ }
454
+ if (updates.patterns) {
455
+ merged.patterns = await this.mergeItems(merged.patterns, this.ensureArray(updates.patterns), "pattern", embedService, profileId);
456
+ }
457
+ if (updates.workflows) {
458
+ merged.workflows = await this.mergeItems(merged.workflows, this.ensureArray(updates.workflows), "workflow", embedService, profileId);
459
+ }
460
+ if (profileId) {
461
+ if (merged.preferences.length >= 2) {
462
+ await this.detectConflicts(merged.preferences, "preference", profileId);
463
+ await this.deduplicateItems(merged.preferences, "preference", profileId);
464
+ }
465
+ if (merged.patterns.length >= 2) {
466
+ await this.detectConflicts(merged.patterns, "pattern", profileId);
467
+ await this.deduplicateItems(merged.patterns, "pattern", profileId);
468
+ }
469
+ if (merged.workflows.length >= 2) {
470
+ await this.detectConflicts(merged.workflows, "workflow", profileId);
471
+ await this.deduplicateItems(merged.workflows, "workflow", profileId);
472
+ }
473
+ }
474
+ return merged;
475
+ }
476
+ async mergeItems(existing, incoming, itemType, embedService, profileId) {
477
+ const embed = embedService ?? EmbeddingService.getInstance();
478
+ const useEmbedding = embed.isWarmedUp;
479
+ const minDescLen = CONFIG.userProfileEmbeddingMinDescriptionLength;
480
+ const sameCatStrong = CONFIG.userProfileEmbeddingThresholdSameCat;
481
+ const sameCatWeak = CONFIG.userProfileEmbeddingThresholdSameCatWeak;
482
+ const crossCatStrong = CONFIG.userProfileEmbeddingThresholdCrossCat;
483
+ const crossCatWeak = CONFIG.userProfileEmbeddingThresholdCrossCatWeak;
484
+ const driftThreshold = CONFIG.userProfileCentroidDriftThreshold;
485
+ if (!useEmbedding) {
486
+ log("profile embedding skipped: model not warmed up");
487
+ }
488
+ for (const item of existing) {
489
+ this.lazyMigrateAlpha(item);
490
+ if (item.lastSeen === undefined)
491
+ item.lastSeen = item.lastUpdated || Date.now();
492
+ if (item.evidence === undefined)
493
+ item.evidence = [];
494
+ if (useEmbedding && item.description.length >= minDescLen && !item.centroid) {
495
+ try {
496
+ const emb = await embed.embed(normalizeDescription(item.description), {
497
+ task: "document",
498
+ });
499
+ const arr = Array.from(emb);
500
+ item.centroid = arr;
501
+ item.anchor = arr;
502
+ log("profile centroid migrated", { desc: item.description.substring(0, 30) });
503
+ }
504
+ catch (e) {
505
+ log("profile centroid migration failed", {
506
+ desc: item.description.substring(0, 30),
507
+ error: String(e),
508
+ });
509
+ }
510
+ }
511
+ }
512
+ let matchCount = 0;
513
+ let newCount = 0;
514
+ // Scope the cold buffer to this profile so we only drain items observed for the
515
+ // same user (see COLD_BUFFER_DEFAULT_KEY for the unattributed case).
516
+ const coldBuffer = this.getColdBuffer(profileId);
517
+ if (useEmbedding && coldBuffer[itemType + "s"].length > 0) {
518
+ const buffered = [...coldBuffer[itemType + "s"]];
519
+ coldBuffer[itemType + "s"] = [];
520
+ this.saveColdBuffers();
521
+ log("profile cold start: draining buffer", {
522
+ type: itemType,
523
+ profileId: profileId || COLD_BUFFER_DEFAULT_KEY,
524
+ bufferSize: buffered.length,
525
+ });
526
+ incoming = [...buffered, ...incoming];
527
+ }
528
+ log("profile merge start", {
529
+ type: itemType,
530
+ existingCount: existing.length,
531
+ incomingCount: incoming.length,
532
+ embeddingReady: useEmbedding,
533
+ });
534
+ for (const newItem of incoming) {
535
+ const exactIdx = existing.findIndex((e) => e.category === newItem.category && e.description === newItem.description);
536
+ if (exactIdx >= 0) {
537
+ const oldFreq = existing[exactIdx].frequency || 1;
538
+ existing[exactIdx] = this.mergeConfirmedMatch(existing[exactIdx], newItem, itemType, true);
539
+ matchCount++;
540
+ log("profile matched: exact", {
541
+ type: itemType,
542
+ idx: exactIdx,
543
+ cat: newItem.category,
544
+ frequency: `${oldFreq}→${existing[exactIdx].frequency || "?"}`,
545
+ });
546
+ continue;
547
+ }
548
+ if (useEmbedding && newItem.description.length >= minDescLen) {
549
+ let top1Score = 0;
550
+ let top1Idx = -1;
551
+ let top1SameCat = false;
552
+ let top1Band = null;
553
+ let top2Score = 0;
554
+ let top2Idx = -1;
555
+ let top2SameCat = false;
556
+ let top2Band = null;
557
+ const newEmb = await embed.embed(normalizeDescription(newItem.description), {
558
+ task: "document",
559
+ });
560
+ for (let i = 0; i < existing.length; i++) {
561
+ const existingItem = existing[i];
562
+ if (!existingItem)
563
+ continue;
564
+ const centroid = existingItem.centroid;
565
+ if (!centroid)
566
+ continue;
567
+ const score = cosineSimilarityNumbers(Array.from(newEmb), centroid);
568
+ const sameCat = existingItem.category === newItem.category;
569
+ const strongThreshold = sameCat ? sameCatStrong : crossCatStrong;
570
+ const weakThreshold = sameCat ? sameCatWeak : crossCatWeak;
571
+ let band;
572
+ if (score >= strongThreshold) {
573
+ band = "strong";
574
+ }
575
+ else if (score >= weakThreshold) {
576
+ band = "weak";
577
+ }
578
+ else {
579
+ continue;
580
+ }
581
+ if (score > top1Score) {
582
+ top2Score = top1Score;
583
+ top2Idx = top1Idx;
584
+ top2SameCat = top1SameCat;
585
+ top2Band = top1Band;
586
+ top1Score = score;
587
+ top1Idx = i;
588
+ top1SameCat = sameCat;
589
+ top1Band = band;
590
+ }
591
+ else if (score > top2Score) {
592
+ top2Score = score;
593
+ top2Idx = i;
594
+ top2SameCat = sameCat;
595
+ top2Band = band;
596
+ }
597
+ }
598
+ if (top1Idx >= 0 &&
599
+ top1Band === "strong" &&
600
+ top2Idx >= 0 &&
601
+ top2Band === "strong" &&
602
+ top1SameCat &&
603
+ top2SameCat) {
604
+ const item1 = existing[top1Idx];
605
+ const item2 = existing[top2Idx];
606
+ const newEmbArr = Array.from(newEmb);
607
+ const threeWayResult = this.combineThree(item1, item2, newItem, itemType, newEmbArr, top1Score);
608
+ existing[top1Idx] = threeWayResult;
609
+ const removeIdx = top2Idx;
610
+ existing.splice(removeIdx, 1);
611
+ matchCount += 2;
612
+ log("profile matched: three-way merge", {
613
+ type: itemType,
614
+ idx1: top1Idx,
615
+ idx2: removeIdx,
616
+ cat: item1.category,
617
+ score1: Math.round(top1Score * 100) / 100,
618
+ score2: Math.round(top2Score * 100) / 100,
619
+ freq1: item1.frequency,
620
+ freq2: item2.frequency,
621
+ combinedFreq: threeWayResult.frequency,
622
+ });
623
+ if (this.isMilestone(threeWayResult.frequency || 1) &&
624
+ threeWayResult.evidence?.length >= this.minEvidenceForEvolve(itemType)) {
625
+ await this.evolveAndUpdate(threeWayResult, itemType, profileId);
626
+ }
627
+ continue;
628
+ }
629
+ if (top1Idx >= 0 && top1Band === "strong") {
630
+ const existingItem = existing[top1Idx];
631
+ const oldFreq = existingItem.frequency || 1;
632
+ const centroid = existingItem.centroid;
633
+ const anchor = existingItem.anchor;
634
+ const newEmbArr = Array.from(newEmb);
635
+ const updatedCentroid = l2Normalize(centroid.map((v, i) => CENTROID_EMA_WEIGHT * v + CENTROID_EMA_WEIGHT_COMPLEMENT * (newEmbArr[i] ?? 0)));
636
+ let driftBelowCount = existingItem.driftBelowCount || 0;
637
+ if (anchor && anchor.length === updatedCentroid.length) {
638
+ const driftScore = cosineSimilarityNumbers(updatedCentroid, anchor);
639
+ if (driftScore < driftThreshold) {
640
+ driftBelowCount++;
641
+ }
642
+ else {
643
+ driftBelowCount = 0;
644
+ }
645
+ }
646
+ if (driftBelowCount >= 2) {
647
+ const driftCentroid = Array.from(newEmb);
648
+ const driftAnchor = driftCentroid;
649
+ const frozenItem = { ...existingItem };
650
+ frozenItem.centroid = centroid;
651
+ frozenItem.driftBelowCount = driftBelowCount;
652
+ existing[top1Idx] = frozenItem;
653
+ existing.push(this.initItem(newItem, itemType, driftCentroid, driftAnchor));
654
+ newCount++;
655
+ log("profile drift fuse: frozen existing, new item created", {
656
+ type: itemType,
657
+ idx: top1Idx,
658
+ driftBelowCount,
659
+ driftScore: anchor
660
+ ? Math.round(cosineSimilarityNumbers(updatedCentroid, anchor) * 100) / 100
661
+ : null,
662
+ });
663
+ continue;
664
+ }
665
+ const combined = this.mergeConfirmedMatch(existingItem, newItem, itemType, top1SameCat);
666
+ combined.centroid = updatedCentroid;
667
+ combined.anchor = anchor;
668
+ combined.driftBelowCount = driftBelowCount;
669
+ combined.weakHitCount = 0;
670
+ combined.lastWeakHitAt = null;
671
+ if (top1Score > 0.9 &&
672
+ top1SameCat &&
673
+ newItem.description.length < existingItem.description?.length) {
674
+ combined.description = newItem.description;
675
+ }
676
+ existing[top1Idx] = combined;
677
+ matchCount++;
678
+ if (top1SameCat) {
679
+ const mergeIndices = [];
680
+ for (let j = 0; j < existing.length; j++) {
681
+ if (j === top1Idx)
682
+ continue;
683
+ const other = existing[j];
684
+ if (!other || other.category !== existingItem.category)
685
+ continue;
686
+ const otherCentroid = other.centroid;
687
+ if (!otherCentroid)
688
+ continue;
689
+ const crossScore = cosineSimilarityNumbers(Array.from(newEmb), otherCentroid);
690
+ if (crossScore >= sameCatStrong) {
691
+ mergeIndices.push(j);
692
+ this.lazyMigrateAlpha(combined);
693
+ this.lazyMigrateAlpha(other);
694
+ combined.alpha += other.alpha || 1;
695
+ combined.beta = (combined.beta || 1) + (other.beta || 1);
696
+ const oldFreq = combined.frequency || 1;
697
+ combined.frequency += other.frequency || 1;
698
+ combined.evidence = [
699
+ ...new Set([
700
+ ...this.ensureArray(combined.evidence),
701
+ ...this.ensureArray(other.evidence),
702
+ ]),
703
+ ].slice(0, 10);
704
+ const combinedCentroid = combined.centroid;
705
+ const w1 = oldFreq / (oldFreq + (other.frequency || 1));
706
+ const w2 = (other.frequency || 1) / (oldFreq + (other.frequency || 1));
707
+ const mergedCentroid = l2Normalize(combinedCentroid.map((v, i) => w1 * v + w2 * (otherCentroid[i] ?? 0)));
708
+ combined.centroid = mergedCentroid;
709
+ this.syncConfidence(combined);
710
+ matchCount++;
711
+ log("profile matched: cross-validation merge", {
712
+ type: itemType,
713
+ idx: top1Idx,
714
+ mergedIdx: j,
715
+ cat: other.category,
716
+ crossScore: Math.round(crossScore * 100) / 100,
717
+ mergedAlpha: Math.round(combined.alpha),
718
+ combinedFreq: combined.frequency,
719
+ });
720
+ }
721
+ }
722
+ for (let i = mergeIndices.length - 1; i >= 0; i--) {
723
+ existing.splice(mergeIndices[i], 1);
724
+ }
725
+ }
726
+ const driftInfo = anchor && anchor.length === updatedCentroid.length
727
+ ? {
728
+ driftScore: Math.round(cosineSimilarityNumbers(updatedCentroid, anchor) * 100) / 100,
729
+ driftBelowCount,
730
+ }
731
+ : {};
732
+ log("profile matched: embedding strong", {
733
+ type: itemType,
734
+ idx: top1Idx,
735
+ cat: existingItem.category,
736
+ score: Math.round(top1Score * 100) / 100,
737
+ sameCat: top1SameCat,
738
+ frequency: `${oldFreq}→${combined.frequency || "?"}`,
739
+ ...driftInfo,
740
+ });
741
+ const newFreq = combined.frequency || 1;
742
+ if (this.isMilestone(newFreq) &&
743
+ combined.evidence?.length >= this.minEvidenceForEvolve(itemType)) {
744
+ await this.evolveAndUpdate(combined, itemType, profileId);
745
+ }
746
+ continue;
747
+ }
748
+ if (top1Idx >= 0 && top1Band === "weak") {
749
+ const existingItem = existing[top1Idx];
750
+ if (top1SameCat) {
751
+ const existingDriftBelow = existingItem.driftBelowCount || 0;
752
+ const centroid = existingItem.centroid;
753
+ const anchor = existingItem.anchor;
754
+ const newEmbArr = Array.from(newEmb);
755
+ const updatedCentroid = l2Normalize(centroid.map((v, i) => CENTROID_EMA_WEIGHT * v + CENTROID_EMA_WEIGHT_COMPLEMENT * (newEmbArr[i] ?? 0)));
756
+ let driftBelowCount = existingDriftBelow;
757
+ if (anchor && anchor.length === updatedCentroid.length) {
758
+ const driftScore = cosineSimilarityNumbers(updatedCentroid, anchor);
759
+ if (driftScore < driftThreshold) {
760
+ driftBelowCount = existingDriftBelow + 1;
761
+ }
762
+ else {
763
+ driftBelowCount = 0;
764
+ }
765
+ }
766
+ if (driftBelowCount >= 2) {
767
+ const driftCentroid = Array.from(newEmb);
768
+ const driftAnchor = driftCentroid;
769
+ const frozenItem = { ...existingItem };
770
+ frozenItem.centroid = centroid;
771
+ frozenItem.driftBelowCount = driftBelowCount;
772
+ existing[top1Idx] = frozenItem;
773
+ existing.push(this.initItem(newItem, itemType, driftCentroid, driftAnchor));
774
+ newCount++;
775
+ log("profile drift fuse: forced merge path, frozen existing", {
776
+ type: itemType,
777
+ idx: top1Idx,
778
+ driftBelowCount,
779
+ driftScore: anchor
780
+ ? Math.round(cosineSimilarityNumbers(updatedCentroid, anchor) * 100) / 100
781
+ : null,
782
+ });
783
+ continue;
784
+ }
785
+ const combined = { ...existingItem };
786
+ combined.alpha = (existingItem.alpha || 1) + top1Score * 1.0;
787
+ combined.frequency = (existingItem.frequency || 1) + 1;
788
+ combined.evidence = [
789
+ ...new Set([
790
+ newItem.description || newItem.description,
791
+ ...this.ensureArray(newItem.evidence),
792
+ ...this.ensureArray(existingItem.evidence),
793
+ ]),
794
+ ].slice(0, 10);
795
+ combined.weakAlpha = 1;
796
+ combined.weakBeta = 1;
797
+ combined.weakHitCount = 0;
798
+ combined.lastWeakHitAt = null;
799
+ combined.lastMatchTime = Date.now();
800
+ combined.centroid = updatedCentroid;
801
+ combined.anchor = anchor;
802
+ combined.driftBelowCount = driftBelowCount;
803
+ this.syncConfidence(combined);
804
+ existing[top1Idx] = combined;
805
+ matchCount++;
806
+ const driftInfo = anchor && anchor.length === updatedCentroid.length
807
+ ? {
808
+ driftScore: Math.round(cosineSimilarityNumbers(updatedCentroid, anchor) * 100) / 100,
809
+ driftBelowCount,
810
+ }
811
+ : {};
812
+ log("profile matched: same-cat forced merge", {
813
+ type: itemType,
814
+ idx: top1Idx,
815
+ cat: existingItem.category,
816
+ score: Math.round(top1Score * 100) / 100,
817
+ confidence: `${Math.round((existingItem.confidence || 0) * 100) / 100}→${Math.round(combined.confidence * 100) / 100}`,
818
+ frequency: `${existingItem.frequency || 1}→${combined.frequency || "?"}`,
819
+ ...driftInfo,
820
+ });
821
+ const newFreq = combined.frequency || 1;
822
+ if (this.isMilestone(newFreq) &&
823
+ combined.evidence?.length >= this.minEvidenceForEvolve(itemType)) {
824
+ await this.evolveAndUpdate(combined, itemType, profileId);
825
+ }
826
+ continue;
827
+ }
828
+ const weakAlpha = (existingItem.weakAlpha || 1) + top1Score;
829
+ const weakBeta = (existingItem.weakBeta || 1) + (1 - top1Score);
830
+ const effectiveAlpha = weakAlpha;
831
+ const effectiveBeta = weakBeta;
832
+ const upgraded = effectiveAlpha + effectiveBeta > 7
833
+ ? effectiveAlpha / (effectiveAlpha + effectiveBeta) >= 0.45
834
+ : sampleBeta(effectiveAlpha, effectiveBeta) >= 0.5;
835
+ if (upgraded) {
836
+ const oldFreq = existingItem.frequency || 1;
837
+ const centroid = existingItem.centroid;
838
+ const anchor = existingItem.anchor;
839
+ const newEmbArr = Array.from(newEmb);
840
+ const updatedCentroid = l2Normalize(centroid.map((v, i) => CENTROID_EMA_WEIGHT * v + CENTROID_EMA_WEIGHT_COMPLEMENT * (newEmbArr[i] ?? 0)));
841
+ const combined = this.mergeConfirmedMatch(existingItem, newItem, itemType, false);
842
+ combined.centroid = updatedCentroid;
843
+ combined.anchor = anchor;
844
+ combined.alpha += 0.5;
845
+ combined.weakAlpha = 1;
846
+ combined.weakBeta = 1;
847
+ combined.driftBelowCount = existingItem.driftBelowCount || 0;
848
+ this.syncConfidence(combined);
849
+ existing[top1Idx] = combined;
850
+ matchCount++;
851
+ log("profile matched: weak upgrade (thompson)", {
852
+ type: itemType,
853
+ idx: top1Idx,
854
+ cat: existingItem.category,
855
+ score: Math.round(top1Score * 100) / 100,
856
+ weakAlpha: Math.round(weakAlpha * 100) / 100,
857
+ weakBeta: Math.round(weakBeta * 100) / 100,
858
+ forced: effectiveAlpha + effectiveBeta > 7,
859
+ frequency: `${oldFreq}→${combined.frequency || "?"}`,
860
+ incomingDesc: (newItem.description || "").substring(0, 40),
861
+ });
862
+ const newFreq = combined.frequency || 1;
863
+ if (this.isMilestone(newFreq) &&
864
+ combined.evidence?.length >= this.minEvidenceForEvolve(itemType)) {
865
+ await this.evolveAndUpdate(combined, itemType, profileId);
866
+ }
867
+ continue;
868
+ }
869
+ if (effectiveAlpha + effectiveBeta <= 7) {
870
+ existingItem.weakAlpha = effectiveAlpha;
871
+ existingItem.weakBeta = effectiveBeta;
872
+ }
873
+ else {
874
+ existingItem.weakAlpha = 1;
875
+ existingItem.weakBeta = 1;
876
+ }
877
+ existingItem.lastSeen = Date.now();
878
+ existingItem.lastWeakHitAt = Date.now();
879
+ log("profile weak hit (thompson)", {
880
+ type: itemType,
881
+ idx: top1Idx,
882
+ cat: existingItem.category,
883
+ score: Math.round(top1Score * 100) / 100,
884
+ weakAlpha: Math.round(weakAlpha * 100) / 100,
885
+ weakBeta: Math.round(weakBeta * 100) / 100,
886
+ forcedReset: effectiveAlpha + effectiveBeta > 7,
887
+ incomingDesc: (newItem.description || "").substring(0, 40),
888
+ });
889
+ continue;
890
+ }
891
+ }
892
+ if (!useEmbedding && newItem.description.length >= minDescLen) {
893
+ const isExplicit = newItem.category === "explicit" ||
894
+ (Array.isArray(newItem.evidence) &&
895
+ newItem.evidence.includes("manual-write"));
896
+ if (!isExplicit) {
897
+ coldBuffer[itemType + "s"].push(newItem);
898
+ if (coldBuffer[itemType + "s"].length > 50) {
899
+ coldBuffer[itemType + "s"].shift();
900
+ }
901
+ this.saveColdBuffers();
902
+ log("profile cold start: buffered", {
903
+ type: itemType,
904
+ profileId: profileId || COLD_BUFFER_DEFAULT_KEY,
905
+ cat: newItem.category,
906
+ bufferSize: coldBuffer[itemType + "s"].length,
907
+ });
908
+ continue;
909
+ }
910
+ }
911
+ let initCentroid;
912
+ let initAnchor;
913
+ if (useEmbedding && newItem.description.length >= minDescLen) {
914
+ try {
915
+ const emb = await embed.embed(normalizeDescription(newItem.description), {
916
+ task: "document",
917
+ });
918
+ initCentroid = Array.from(emb);
919
+ initAnchor = initCentroid;
920
+ }
921
+ catch {
922
+ // keep the item without an embedding centroid
923
+ }
924
+ }
925
+ existing.push(this.initItem(newItem, itemType, initCentroid, initAnchor));
926
+ newCount++;
927
+ log("profile no match: appended new", {
928
+ type: itemType,
929
+ cat: newItem.category,
930
+ desc: (newItem.description || "").substring(0, 40),
931
+ });
932
+ }
933
+ log("profile merge done", {
934
+ type: itemType,
935
+ matched: matchCount,
936
+ appended: newCount,
937
+ existingAfter: existing.length,
938
+ });
939
+ return existing;
940
+ }
941
+ /**
942
+ * Merge a new confirmed observation into an existing profile entry.
943
+ * ONLY call for confirmed matches (exact, strong sameCat/crossCat, Thompson upgrade).
944
+ * Do NOT call for forced merge or cross-validation — those handle fields directly.
945
+ */
946
+ mergeConfirmedMatch(existing, newItem, itemType, sameCat) {
947
+ this.lazyMigrateAlpha(existing);
948
+ const evidence = [
949
+ ...new Set([
950
+ newItem.description,
951
+ ...this.ensureArray(newItem.evidence),
952
+ ...this.ensureArray(existing.evidence),
953
+ ]),
954
+ ].slice(0, 10);
955
+ if (sameCat) {
956
+ const result = {
957
+ ...existing,
958
+ alpha: existing.alpha + 1,
959
+ beta: existing.beta ?? 1,
960
+ weakAlpha: 1,
961
+ weakBeta: 1,
962
+ frequency: (existing.frequency || 1) + 1,
963
+ evidence,
964
+ lastSeen: Date.now(),
965
+ lastMatchTime: Date.now(),
966
+ ...(itemType === "workflow" && newItem.steps?.length ? { steps: newItem.steps } : {}),
967
+ };
968
+ this.syncConfidence(result);
969
+ return result;
970
+ }
971
+ const result = {
972
+ ...existing,
973
+ alpha: existing.alpha + 0.5,
974
+ beta: existing.beta ?? 1,
975
+ weakAlpha: 1,
976
+ weakBeta: 1,
977
+ evidence,
978
+ lastSeen: Date.now(),
979
+ lastMatchTime: Date.now(),
980
+ ...(itemType === "workflow" && newItem.steps?.length ? { steps: newItem.steps } : {}),
981
+ };
982
+ this.syncConfidence(result);
983
+ return result;
984
+ }
985
+ initItem(newItem, itemType, centroid, anchor) {
986
+ const hasEvidence = Array.isArray(newItem.evidence) && newItem.evidence.length > 0;
987
+ const item = {
988
+ ...newItem,
989
+ alpha: hasEvidence ? 1 : 0.3,
990
+ beta: hasEvidence ? 1 : 1.5,
991
+ weakAlpha: 1,
992
+ weakBeta: 1,
993
+ confidence: hasEvidence ? (newItem.confidence ?? 0.5) : 0.2,
994
+ frequency: 1,
995
+ lastSeen: Date.now(),
996
+ lastMatchTime: Date.now(),
997
+ firstSeen: Date.now(),
998
+ evidence: newItem.evidence ?? [],
999
+ weakHitCount: 0,
1000
+ driftBelowCount: 0,
1001
+ pendingValidation: true,
1002
+ centroid,
1003
+ anchor,
1004
+ ...(itemType === "workflow" && newItem.steps?.length ? { steps: newItem.steps } : {}),
1005
+ };
1006
+ this.syncConfidence(item);
1007
+ return item;
1008
+ }
1009
+ /**
1010
+ * Three-way merge: item1 + item2 + newItem → single combined entry.
1011
+ * Called when top-1 and top-2 both strongly match the same new observation
1012
+ * within the same category, confirming they describe the same behavior.
1013
+ */
1014
+ combineThree(item1, item2, newItem, itemType, newEmb, top1Score) {
1015
+ this.lazyMigrateAlpha(item1);
1016
+ this.lazyMigrateAlpha(item2);
1017
+ const freq = (item1.frequency || 1) + (item2.frequency || 1) + 1;
1018
+ const evidenceSources = [
1019
+ ...this.ensureArray(newItem.evidence),
1020
+ ...this.ensureArray(item1.evidence),
1021
+ ...this.ensureArray(item2.evidence),
1022
+ ];
1023
+ const evidence = [...new Set(evidenceSources)].slice(0, 10);
1024
+ const centroid = l2Normalize(item1.centroid.map((v, i) => THREE_WAY_CENTROID_W1 * v +
1025
+ THREE_WAY_CENTROID_W2 * (item2.centroid[i] ?? 0) +
1026
+ THREE_WAY_CENTROID_W3 * (newEmb[i] ?? 0)));
1027
+ const anchor = item1.anchor;
1028
+ const newIsShorter = newItem.description.length < (item1.description?.length || Infinity);
1029
+ const description = top1Score > 0.9 && newIsShorter ? newItem.description : item1.description;
1030
+ const steps = itemType === "workflow" && (item1.steps || item2.steps)
1031
+ ? item1.steps?.length && item1.steps.length >= (item2.steps?.length || 0)
1032
+ ? item1.steps
1033
+ : item2.steps
1034
+ : newItem.steps;
1035
+ const result = {
1036
+ ...item1,
1037
+ description,
1038
+ frequency: freq,
1039
+ evidence,
1040
+ centroid,
1041
+ anchor,
1042
+ alpha: (item1.alpha || 1) + (item2.alpha || 1) + 1,
1043
+ beta: (item1.beta || 1) + (item2.beta || 1),
1044
+ weakAlpha: 1,
1045
+ weakBeta: 1,
1046
+ weakHitCount: 0,
1047
+ driftBelowCount: 0,
1048
+ lastSeen: Date.now(),
1049
+ lastMatchTime: Date.now(),
1050
+ ...(steps ? { steps } : {}),
1051
+ };
1052
+ this.syncConfidence(result);
1053
+ return result;
1054
+ }
1055
+ ensureArray(val) {
1056
+ if (typeof val === "string") {
1057
+ try {
1058
+ const parsed = JSON.parse(val);
1059
+ return Array.isArray(parsed) ? parsed : [];
1060
+ }
1061
+ catch (e) {
1062
+ log("ensureArray: failed to parse JSON string, returning []", {
1063
+ val: String(val).substring(0, 100),
1064
+ error: String(e),
1065
+ });
1066
+ return [];
1067
+ }
1068
+ }
1069
+ return Array.isArray(val) ? val : [];
1070
+ }
1071
+ isMilestone(frequency) {
1072
+ if (frequency <= 20)
1073
+ return [2, 5, 10, 20].includes(frequency);
1074
+ return frequency % 20 === 0;
1075
+ }
1076
+ minEvidenceForEvolve(_itemType) {
1077
+ return 4;
1078
+ }
1079
+ syncConfidence(item) {
1080
+ const alpha = item.alpha ?? 1;
1081
+ const beta = item.beta ?? 1;
1082
+ const betaMean = alpha / (alpha + beta);
1083
+ const decayThreshold = CONFIG.userProfileConfidenceDecayDays * 24 * 60 * 60 * 1000;
1084
+ const freq = item.frequency || 1;
1085
+ const halfLife = decayThreshold * (1 + Math.log2(1 + freq));
1086
+ const age = Date.now() - (item.lastSeen || Date.now());
1087
+ const ageMs = Math.max(0, age - decayThreshold);
1088
+ const timeFactor = Math.exp((-Math.LN2 * ageMs) / halfLife);
1089
+ const matchTime = item.lastMatchTime || item.lastSeen || Date.now();
1090
+ const matchAge = (Date.now() - matchTime) / (24 * 60 * 60 * 1000);
1091
+ const trendMultiplier = 0.8 + 0.2 * Math.exp(-matchAge / 30);
1092
+ item.confidence = betaMean * Math.min(timeFactor, trendMultiplier);
1093
+ }
1094
+ lazyMigrateAlpha(item) {
1095
+ const needsAlphaMigration = item.alpha === undefined || (item.alpha === 0.5 && item.beta === 1.5);
1096
+ if (needsAlphaMigration) {
1097
+ const conf = Math.max(0, Math.min(1, item.confidence ?? 1.0));
1098
+ item.alpha = conf / (1 - conf + 0.01);
1099
+ item.beta = 1;
1100
+ }
1101
+ item.weakAlpha = item.weakAlpha ?? 1;
1102
+ item.weakBeta = item.weakBeta ?? 1;
1103
+ item.lastMatchTime = item.lastMatchTime ?? item.lastSeen ?? Date.now();
1104
+ item.firstSeen = item.firstSeen ?? item.lastSeen ?? Date.now();
1105
+ if (item.pendingValidation === undefined) {
1106
+ item.pendingValidation = false;
1107
+ }
1108
+ this.syncConfidence(item);
1109
+ }
1110
+ async detectConflicts(items, itemType, _profileId) {
1111
+ const candidates = [];
1112
+ const limit = items.length;
1113
+ for (let i = 0; i < limit; i++) {
1114
+ for (let j = i + 1; j < limit; j++) {
1115
+ if (items[i].category !== items[j].category)
1116
+ continue;
1117
+ const c1 = items[i].centroid;
1118
+ const c2 = items[j].centroid;
1119
+ if (!c1 || !c2)
1120
+ continue;
1121
+ const cos = cosineSimilarityNumbers(c1, c2);
1122
+ if (cos >= 0.65 && cos < 0.9) {
1123
+ candidates.push({ a: items[i], b: items[j], cos });
1124
+ }
1125
+ }
1126
+ }
1127
+ if (candidates.length === 0)
1128
+ return;
1129
+ const maxChecks = 3;
1130
+ candidates.sort((x, y) => y.cos - x.cos);
1131
+ let checked = 0;
1132
+ const removeIndices = [];
1133
+ for (const { a, b, cos } of candidates) {
1134
+ if (checked >= maxChecks)
1135
+ break;
1136
+ if (removeIndices.includes(items.indexOf(a)) || removeIndices.includes(items.indexOf(b)))
1137
+ continue;
1138
+ const conflict = await this.checkConflict(a.description, b.description);
1139
+ if (conflict) {
1140
+ checked++;
1141
+ const keeper = (a.frequency || 0) >= (b.frequency || 0) ? a : b;
1142
+ const removed = keeper === a ? b : a;
1143
+ this.lazyMigrateAlpha(keeper);
1144
+ this.lazyMigrateAlpha(removed);
1145
+ const oldAlpha = keeper.alpha || 1;
1146
+ keeper.alpha *= 0.75;
1147
+ keeper.beta = (keeper.beta || 1) + 0.25 * oldAlpha;
1148
+ keeper.alpha += removed.alpha || 0;
1149
+ keeper.frequency = (keeper.frequency || 0) + (removed.frequency || 0);
1150
+ keeper.evidence = [
1151
+ ...new Set([...this.ensureArray(keeper.evidence), ...this.ensureArray(removed.evidence)]),
1152
+ ].slice(0, 10);
1153
+ if (itemType === "workflow") {
1154
+ keeper.steps =
1155
+ keeper.steps?.length >= (removed.steps?.length || 0) ? keeper.steps : removed.steps;
1156
+ }
1157
+ const removedIdx = items.indexOf(removed);
1158
+ if (removedIdx >= 0)
1159
+ removeIndices.push(removedIdx);
1160
+ this.syncConfidence(keeper);
1161
+ log("profile conflict detected: resolved", {
1162
+ type: itemType,
1163
+ keeper: (keeper.description || "").substring(0, 40),
1164
+ removed: (removed.description || "").substring(0, 40),
1165
+ cos: Math.round(cos * 100) / 100,
1166
+ alphaDrop: `${Math.round(oldAlpha)}→${Math.round(keeper.alpha)}`,
1167
+ });
1168
+ }
1169
+ }
1170
+ for (let i = removeIndices.length - 1; i >= 0; i--) {
1171
+ items.splice(removeIndices[i], 1);
1172
+ }
1173
+ }
1174
+ async checkSemanticDuplicate(descA, descB) {
1175
+ const prompt = `Do these two descriptions refer to the same user behavior, preference, or pattern? Answer only whether they are semantically equivalent (same meaning, different wording).
1176
+
1177
+ A: "${descA}"
1178
+ B: "${descB}"
1179
+
1180
+ Answer JSON only: { "duplicate": true|false, "reason": "one sentence explanation" }`;
1181
+ if (CONFIG.opencodeProvider && CONFIG.opencodeModel) {
1182
+ try {
1183
+ const { z } = await import("zod");
1184
+ const { generateStructuredOutput } = await loadOpencodeProvider();
1185
+ const { getOpenCodeClient } = await import("../ai/profile-llm-client.js");
1186
+ let v2Client;
1187
+ try {
1188
+ v2Client = await getOpenCodeClient();
1189
+ }
1190
+ catch (e) {
1191
+ log("profile dedup check: native provider not connected", { error: String(e) });
1192
+ }
1193
+ if (v2Client) {
1194
+ const result = await Promise.race([
1195
+ generateStructuredOutput({
1196
+ client: v2Client,
1197
+ providerID: CONFIG.opencodeProvider,
1198
+ modelID: CONFIG.opencodeModel,
1199
+ systemPrompt: "You are a semantic duplicate detector. Output valid JSON.",
1200
+ userPrompt: prompt,
1201
+ schema: z.object({ duplicate: z.boolean(), reason: z.string() }),
1202
+ }),
1203
+ new Promise((_, reject) => setTimeout(() => reject(new Error("dedup check timeout")), 30000)),
1204
+ ]);
1205
+ return result.duplicate || false;
1206
+ }
1207
+ }
1208
+ catch (e) {
1209
+ log("profile dedup check: native provider failed", { error: String(e) });
1210
+ }
1211
+ }
1212
+ if (CONFIG.memoryModel && CONFIG.memoryApiUrl) {
1213
+ try {
1214
+ const response = await fetch(`${CONFIG.memoryApiUrl}/chat/completions`, {
1215
+ method: "POST",
1216
+ headers: {
1217
+ "Content-Type": "application/json",
1218
+ Authorization: `Bearer ${CONFIG.memoryApiKey || ""}`,
1219
+ },
1220
+ body: JSON.stringify({
1221
+ model: CONFIG.memoryModel,
1222
+ messages: [
1223
+ {
1224
+ role: "system",
1225
+ content: "You are a semantic duplicate detector. Output valid JSON.",
1226
+ },
1227
+ { role: "user", content: prompt },
1228
+ ],
1229
+ temperature: 0,
1230
+ response_format: { type: "json_object" },
1231
+ }),
1232
+ signal: AbortSignal.timeout(30000),
1233
+ });
1234
+ if (!response.ok)
1235
+ return false;
1236
+ const data = await response.json();
1237
+ const content = data.choices?.[0]?.message?.content;
1238
+ if (!content)
1239
+ return false;
1240
+ const parsed = JSON.parse(content);
1241
+ return parsed.duplicate || false;
1242
+ }
1243
+ catch (e) {
1244
+ log("profile dedup check: external API failed", { error: String(e) });
1245
+ }
1246
+ }
1247
+ return false;
1248
+ }
1249
+ async deduplicateItems(items, itemType, profileId) {
1250
+ if (!profileId || items.length < 2)
1251
+ return;
1252
+ const checkedPairs = this.dedupCheckedCache;
1253
+ const maxLLMCalls = 5;
1254
+ let llmCalls = 0;
1255
+ let skippedCached = 0;
1256
+ const byCategory = new Map();
1257
+ for (const item of items) {
1258
+ const cat = item.category || "_uncategorized";
1259
+ if (!byCategory.has(cat))
1260
+ byCategory.set(cat, []);
1261
+ byCategory.get(cat).push(item);
1262
+ }
1263
+ for (const [, group] of byCategory) {
1264
+ if (group.length < 2)
1265
+ continue;
1266
+ const candidates = [];
1267
+ for (let i = 0; i < group.length; i++) {
1268
+ for (let j = i + 1; j < group.length; j++) {
1269
+ const c1 = group[i].centroid;
1270
+ const c2 = group[j].centroid;
1271
+ if (!c1 || !c2)
1272
+ continue;
1273
+ const cos = cosineSimilarityNumbers(c1, c2);
1274
+ if (cos >= 0.5) {
1275
+ candidates.push({ a: group[i], b: group[j], cos });
1276
+ }
1277
+ }
1278
+ }
1279
+ if (candidates.length === 0)
1280
+ continue;
1281
+ candidates.sort((x, y) => y.cos - x.cos);
1282
+ const removeIndices = [];
1283
+ let candidateIdx = 0;
1284
+ for (const { a, b, cos } of candidates) {
1285
+ candidateIdx++;
1286
+ if (llmCalls >= maxLLMCalls) {
1287
+ log("profile dedup: LLM call limit reached", {
1288
+ itemType,
1289
+ skipped: candidates.length - candidateIdx + 1,
1290
+ });
1291
+ break;
1292
+ }
1293
+ if (removeIndices.includes(items.indexOf(a)) || removeIndices.includes(items.indexOf(b)))
1294
+ continue;
1295
+ const pairKey = [a.description, b.description].sort().join("||").substring(0, 100);
1296
+ if (checkedPairs.has(pairKey)) {
1297
+ skippedCached++;
1298
+ continue;
1299
+ }
1300
+ llmCalls++;
1301
+ const isDuplicate = await this.checkSemanticDuplicate(a.description, b.description);
1302
+ if (isDuplicate) {
1303
+ const keeper = (a.frequency || 0) >= (b.frequency || 0) ? a : b;
1304
+ const removed = keeper === a ? b : a;
1305
+ keeper.frequency = (keeper.frequency || 0) + (removed.frequency || 0);
1306
+ this.lazyMigrateAlpha(keeper);
1307
+ this.lazyMigrateAlpha(removed);
1308
+ keeper.alpha += removed.alpha || 0;
1309
+ keeper.beta = (keeper.beta || 1) + (removed.beta || 1);
1310
+ keeper.weakAlpha = (keeper.weakAlpha || 1) + ((removed.weakAlpha || 1) - 1);
1311
+ keeper.weakBeta = (keeper.weakBeta || 1) + ((removed.weakBeta || 1) - 1);
1312
+ keeper.lastSeen = Math.max(keeper.lastSeen || 0, removed.lastSeen || 0);
1313
+ keeper.lastMatchTime = Math.max(keeper.lastMatchTime || 0, removed.lastMatchTime || 0);
1314
+ this.syncConfidence(keeper);
1315
+ keeper.evidence = [
1316
+ ...new Set([
1317
+ ...this.ensureArray(keeper.evidence),
1318
+ ...this.ensureArray(removed.evidence),
1319
+ ]),
1320
+ ].slice(0, 10);
1321
+ keeper.pendingValidation = !!keeper.pendingValidation && !!removed.pendingValidation;
1322
+ if (itemType === "workflow") {
1323
+ keeper.steps =
1324
+ keeper.steps?.length >= (removed.steps?.length || 0) ? keeper.steps : removed.steps;
1325
+ }
1326
+ const removedIdx = items.indexOf(removed);
1327
+ if (removedIdx >= 0)
1328
+ removeIndices.push(removedIdx);
1329
+ checkedPairs.delete(pairKey);
1330
+ log("profile dedup: merged duplicate", {
1331
+ type: itemType,
1332
+ keeper: (keeper.description || "").substring(0, 40),
1333
+ removed: (removed.description || "").substring(0, 40),
1334
+ cos: Math.round(cos * 100) / 100,
1335
+ mergedFreq: keeper.frequency,
1336
+ });
1337
+ }
1338
+ else {
1339
+ checkedPairs.add(pairKey);
1340
+ log("profile dedup: no duplicate confirmed", {
1341
+ type: itemType,
1342
+ cos: Math.round(cos * 100) / 100,
1343
+ });
1344
+ }
1345
+ }
1346
+ for (let i = removeIndices.length - 1; i >= 0; i--) {
1347
+ items.splice(removeIndices[i], 1);
1348
+ }
1349
+ }
1350
+ log("profile dedup complete", {
1351
+ type: itemType,
1352
+ llmCalls,
1353
+ skippedCached,
1354
+ itemsAfter: items.length,
1355
+ });
1356
+ if (this.dedupCheckedCache.size > 1000) {
1357
+ const entries = [...this.dedupCheckedCache];
1358
+ this.dedupCheckedCache = new Set(entries.slice(500));
1359
+ }
1360
+ }
1361
+ async checkConflict(descA, descB) {
1362
+ const prompt = `Are these two user preferences contradictory (opposing, incompatible)?
1363
+
1364
+ A: "${descA}"
1365
+ B: "${descB}"
1366
+
1367
+ Answer JSON only: { "conflict": true|false, "reason": "one sentence explanation" }`;
1368
+ if (CONFIG.opencodeProvider && CONFIG.opencodeModel) {
1369
+ try {
1370
+ const { z } = await import("zod");
1371
+ const { generateStructuredOutput } = await loadOpencodeProvider();
1372
+ const { getOpenCodeClient } = await import("../ai/profile-llm-client.js");
1373
+ let v2Client;
1374
+ try {
1375
+ v2Client = await getOpenCodeClient();
1376
+ }
1377
+ catch (e) {
1378
+ log("profile conflict check: native provider not connected", { error: String(e) });
1379
+ }
1380
+ if (v2Client) {
1381
+ const result = await Promise.race([
1382
+ generateStructuredOutput({
1383
+ client: v2Client,
1384
+ providerID: CONFIG.opencodeProvider,
1385
+ modelID: CONFIG.opencodeModel,
1386
+ systemPrompt: "You are a preference contradiction detector. Output valid JSON.",
1387
+ userPrompt: prompt,
1388
+ schema: z.object({ conflict: z.boolean(), reason: z.string() }),
1389
+ }),
1390
+ new Promise((_, reject) => setTimeout(() => reject(new Error("conflict check timeout")), 30000)),
1391
+ ]);
1392
+ return result.conflict || false;
1393
+ }
1394
+ }
1395
+ catch (e) {
1396
+ log("profile conflict check: native provider failed", { error: String(e) });
1397
+ }
1398
+ }
1399
+ if (CONFIG.memoryModel && CONFIG.memoryApiUrl) {
1400
+ try {
1401
+ const response = await fetch(`${CONFIG.memoryApiUrl}/chat/completions`, {
1402
+ method: "POST",
1403
+ headers: {
1404
+ "Content-Type": "application/json",
1405
+ Authorization: `Bearer ${CONFIG.memoryApiKey || ""}`,
1406
+ },
1407
+ body: JSON.stringify({
1408
+ model: CONFIG.memoryModel,
1409
+ messages: [
1410
+ {
1411
+ role: "system",
1412
+ content: "You are a preference contradiction detector. Output valid JSON.",
1413
+ },
1414
+ { role: "user", content: prompt },
1415
+ ],
1416
+ temperature: 0,
1417
+ response_format: { type: "json_object" },
1418
+ }),
1419
+ signal: AbortSignal.timeout(30000),
1420
+ });
1421
+ if (!response.ok)
1422
+ return false;
1423
+ const data = await response.json();
1424
+ const content = data.choices?.[0]?.message?.content;
1425
+ if (!content)
1426
+ return false;
1427
+ const parsed = (() => {
1428
+ try {
1429
+ return JSON.parse(content);
1430
+ }
1431
+ catch {
1432
+ const repaired = content
1433
+ .replace(/([\u4e00-\u9fff\u3000-\u303f\uff00-\uffef])"([\u4e00-\u9fff\u3000-\u303f\uff00-\uffef])/g, '$1\\"$2')
1434
+ .replace(/([\u4e00-\u9fff\u3000-\u303f\uff00-\uffef])"(?=\s*[,}\]])/g, '$1\\"');
1435
+ return JSON.parse(repaired);
1436
+ }
1437
+ })();
1438
+ return parsed.conflict || false;
1439
+ }
1440
+ catch (e) {
1441
+ log("profile conflict check: external API failed", { error: String(e) });
1442
+ }
1443
+ }
1444
+ return false;
1445
+ }
1446
+ async evolveDescription(item, itemType, profileId) {
1447
+ if (!profileId) {
1448
+ log("profile description evolution blocked: no profileId");
1449
+ return null;
1450
+ }
1451
+ const evidence = item.evidence;
1452
+ if (!Array.isArray(evidence) || evidence.length < this.minEvidenceForEvolve(itemType)) {
1453
+ if (Array.isArray(evidence) && evidence.length > 0) {
1454
+ log("profile description evolution skipped: insufficient evidence", {
1455
+ type: itemType,
1456
+ evidenceCount: evidence.length,
1457
+ required: this.minEvidenceForEvolve(itemType),
1458
+ });
1459
+ }
1460
+ return null;
1461
+ }
1462
+ log("profile description evolution attempt", {
1463
+ type: itemType,
1464
+ desc: (item.description || "").substring(0, 40),
1465
+ frequency: item.frequency,
1466
+ evidenceCount: evidence.length,
1467
+ });
1468
+ const evidenceList = evidence.map((e, i) => `${i + 1}. ${e}`).join("\n");
1469
+ const systemPrompt = `You are a user profile description optimizer. Return ONLY a JSON object with a single "description" field: {"description": "..."}
1470
+ Based on multiple independent observations of the same user behavior, generate a more precise description.
1471
+ Rules:
1472
+ - Describe the user's behavioral tendency in general terms
1473
+ - Natural length — not artificially shortened or inflated
1474
+ - Same language as the observations
1475
+ - Do not over-infer beyond what the evidence shows
1476
+ - Do not include technical implementation details, parameter values, algorithm names, tool names, product names, library names, file paths, error messages, or transient conversation content`;
1477
+ const userPrompt = `Current description: ${item.description}
1478
+
1479
+ Independent observations:
1480
+ ${evidenceList}
1481
+
1482
+ Generate a concise, abstract description of the user's general behavioral tendency.`;
1483
+ let newDescription = null;
1484
+ if (CONFIG.opencodeProvider && CONFIG.opencodeModel) {
1485
+ try {
1486
+ newDescription = await this.callOpencodeProvider(systemPrompt, userPrompt);
1487
+ }
1488
+ catch (e) {
1489
+ log("profile description evolution: native provider failed, trying external API", {
1490
+ error: String(e),
1491
+ });
1492
+ }
1493
+ }
1494
+ if (!newDescription && CONFIG.memoryModel && CONFIG.memoryApiUrl) {
1495
+ try {
1496
+ newDescription = await this.callExternalAPI(systemPrompt, userPrompt);
1497
+ }
1498
+ catch (e) {
1499
+ log("profile description evolution: external API failed", { error: String(e) });
1500
+ return null;
1501
+ }
1502
+ }
1503
+ if (!newDescription || newDescription === item.description) {
1504
+ if (!newDescription) {
1505
+ log("profile description evolution blocked: no provider available");
1506
+ }
1507
+ else {
1508
+ log("profile description evolution skipped: no change", { type: itemType });
1509
+ }
1510
+ return null;
1511
+ }
1512
+ return newDescription;
1513
+ }
1514
+ async evolveAndUpdate(item, itemType, profileId) {
1515
+ if (!profileId)
1516
+ return;
1517
+ try {
1518
+ const evolved = await this.evolveDescription(item, itemType, profileId);
1519
+ if (evolved && evolved !== item.description) {
1520
+ const embed = EmbeddingService.getInstance();
1521
+ if (embed.isWarmedUp) {
1522
+ const evidence = this.ensureArray(item.evidence)
1523
+ .filter((e) => typeof e === "string" && e.length >= 10)
1524
+ .slice(0, 8);
1525
+ if (evidence.length >= 3) {
1526
+ const oldDesc = item.description;
1527
+ const evEmbs = await Promise.all(evidence.map((e) => embed.embed(normalizeDescription(e), { task: "document" })));
1528
+ const sumVec = evEmbs.reduce((acc, e) => {
1529
+ const arr = Array.from(e);
1530
+ return acc.map((v, i) => v + (arr[i] ?? 0));
1531
+ }, new Array(evEmbs[0].length).fill(0));
1532
+ const evCentroid = l2Normalize(sumVec);
1533
+ let adopted = false;
1534
+ const oldEmb = await embed.embed(normalizeDescription(item.description), {
1535
+ task: "document",
1536
+ });
1537
+ const newEmb = await embed.embed(normalizeDescription(evolved), { task: "document" });
1538
+ const cosOld = cosineSimilarityNumbers(Array.from(oldEmb), evCentroid);
1539
+ const cosNew = cosineSimilarityNumbers(Array.from(newEmb), evCentroid);
1540
+ if (cosNew < cosOld - DIRECTION_VALIDATION_TOLERANCE) {
1541
+ log("profile description evolution rejected: direction drift", {
1542
+ type: itemType,
1543
+ cosOld: Math.round(cosOld * 1000) / 1000,
1544
+ cosNew: Math.round(cosNew * 1000) / 1000,
1545
+ });
1546
+ }
1547
+ else {
1548
+ item.description = evolved;
1549
+ adopted = true;
1550
+ }
1551
+ if (adopted) {
1552
+ const newCentroid = evCentroid;
1553
+ const oldCentroid = item.centroid;
1554
+ item.centroid = newCentroid;
1555
+ item.anchor = newCentroid;
1556
+ item.driftBelowCount = 0;
1557
+ if (oldCentroid) {
1558
+ log("profile centroid rebuilt from evidence after evolve", {
1559
+ type: itemType,
1560
+ cosShift: Math.round(cosineSimilarityNumbers(oldCentroid, newCentroid) * 1000) / 1000,
1561
+ evidenceCount: evidence.length,
1562
+ validated: evidence.length >= 3,
1563
+ });
1564
+ }
1565
+ }
1566
+ log("profile description evolved", {
1567
+ type: itemType,
1568
+ oldDescription: oldDesc,
1569
+ newDescription: item.description,
1570
+ adopted: oldDesc !== item.description,
1571
+ frequency: item.frequency,
1572
+ });
1573
+ return;
1574
+ }
1575
+ }
1576
+ else {
1577
+ log("profile description evolution: embedding not warmed up, skipping validation", {
1578
+ type: itemType,
1579
+ });
1580
+ }
1581
+ const oldDesc = item.description;
1582
+ item.description = evolved;
1583
+ log("profile description evolved", {
1584
+ type: itemType,
1585
+ oldDescription: oldDesc,
1586
+ newDescription: evolved,
1587
+ adopted: true,
1588
+ frequency: item.frequency,
1589
+ });
1590
+ }
1591
+ }
1592
+ catch (e) {
1593
+ log("profile description evolve error", { type: itemType, error: String(e) });
1594
+ }
1595
+ }
1596
+ async callOpencodeProvider(systemPrompt, userPrompt) {
1597
+ const { generateStructuredOutput } = await loadOpencodeProvider();
1598
+ const { getOpenCodeClient } = await import("../ai/profile-llm-client.js");
1599
+ let v2Client;
1600
+ try {
1601
+ v2Client = await getOpenCodeClient();
1602
+ }
1603
+ catch (e) {
1604
+ log("profile description evolution: native provider not connected", {
1605
+ provider: CONFIG.opencodeProvider,
1606
+ error: String(e),
1607
+ });
1608
+ return null;
1609
+ }
1610
+ const { z } = await import("zod");
1611
+ const schema = z.object({ description: z.string() });
1612
+ const result = await Promise.race([
1613
+ generateStructuredOutput({
1614
+ client: v2Client,
1615
+ providerID: CONFIG.opencodeProvider,
1616
+ modelID: CONFIG.opencodeModel,
1617
+ systemPrompt,
1618
+ userPrompt,
1619
+ schema,
1620
+ }),
1621
+ new Promise((_, reject) => setTimeout(() => reject(new Error("evolve description timeout")), 120000)),
1622
+ ]);
1623
+ return result.description || null;
1624
+ }
1625
+ async callExternalAPI(systemPrompt, userPrompt) {
1626
+ const t0 = Date.now();
1627
+ const response = await fetch(`${CONFIG.memoryApiUrl}/chat/completions`, {
1628
+ method: "POST",
1629
+ headers: {
1630
+ "Content-Type": "application/json",
1631
+ Authorization: `Bearer ${CONFIG.memoryApiKey || ""}`,
1632
+ },
1633
+ body: JSON.stringify({
1634
+ model: CONFIG.memoryModel,
1635
+ messages: [
1636
+ { role: "system", content: systemPrompt },
1637
+ { role: "user", content: userPrompt },
1638
+ ],
1639
+ temperature: 0.3,
1640
+ response_format: { type: "json_object" },
1641
+ }),
1642
+ signal: AbortSignal.timeout(60000),
1643
+ });
1644
+ log("profile description evolution: external API http done", {
1645
+ httpMs: Date.now() - t0,
1646
+ status: response.status,
1647
+ });
1648
+ if (!response.ok) {
1649
+ const text = await response.text().catch(() => "");
1650
+ throw new Error(`External API error: ${response.status} ${text}`);
1651
+ }
1652
+ const data = await response.json();
1653
+ const content = data.choices?.[0]?.message?.content;
1654
+ if (!content)
1655
+ throw new Error("No content in API response");
1656
+ const parsed = JSON.parse(content);
1657
+ return parsed.description || null;
1658
+ }
1659
+ }
1660
+ export const userProfileManager = new UserProfileManager();