om-memory-system 4.19.1 → 4.22.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 (47) hide show
  1. package/dist/adapters/opencode/profile-learning.d.ts +0 -1
  2. package/dist/adapters/opencode/profile-learning.js +9 -35
  3. package/dist/adapters/pi/profile.js +17 -0
  4. package/dist/config.d.ts +1 -1
  5. package/dist/config.js +5 -4
  6. package/dist/importer/claude-hook-api.js +4 -66
  7. package/dist/importer/claude-profile-learning.d.ts +7 -0
  8. package/dist/importer/claude-profile-learning.js +78 -0
  9. package/dist/services/ai-cleanup-handlers.d.ts +31 -0
  10. package/dist/services/ai-cleanup-handlers.js +126 -0
  11. package/dist/services/api-handlers.d.ts +0 -22
  12. package/dist/services/api-handlers.js +0 -239
  13. package/dist/services/auto-cleanup-handlers.d.ts +22 -0
  14. package/dist/services/auto-cleanup-handlers.js +71 -0
  15. package/dist/services/cleanup-service.js +9 -0
  16. package/dist/services/global-config-writer.js +15 -1
  17. package/dist/services/language-detector.js +14 -0
  18. package/dist/services/profile-cleanup-routes.d.ts +6 -0
  19. package/dist/services/profile-cleanup-routes.js +32 -0
  20. package/dist/services/request-timeouts.d.ts +2 -0
  21. package/dist/services/request-timeouts.js +2 -0
  22. package/dist/services/settings-snapshot.js +5 -0
  23. package/dist/services/user-profile/ai-cleanup-apply.d.ts +47 -0
  24. package/dist/services/user-profile/ai-cleanup-apply.js +108 -0
  25. package/dist/services/user-profile/ai-cleanup-batches.d.ts +35 -0
  26. package/dist/services/user-profile/ai-cleanup-batches.js +179 -0
  27. package/dist/services/user-profile/ai-cleanup-job.d.ts +52 -0
  28. package/dist/services/user-profile/ai-cleanup-job.js +112 -0
  29. package/dist/services/user-profile/ai-cleanup-undo.d.ts +15 -0
  30. package/dist/services/user-profile/ai-cleanup-undo.js +63 -0
  31. package/dist/services/user-profile/ai-cleanup.d.ts +12 -11
  32. package/dist/services/user-profile/ai-cleanup.js +10 -83
  33. package/dist/services/user-profile/auto-cleanup-store.d.ts +32 -0
  34. package/dist/services/user-profile/auto-cleanup-store.js +97 -0
  35. package/dist/services/user-profile/auto-cleanup-trigger.d.ts +10 -0
  36. package/dist/services/user-profile/auto-cleanup-trigger.js +12 -0
  37. package/dist/services/user-profile/auto-cleanup.d.ts +32 -0
  38. package/dist/services/user-profile/auto-cleanup.js +102 -0
  39. package/dist/services/user-profile/profile-edit-save.d.ts +35 -0
  40. package/dist/services/user-profile/profile-edit-save.js +26 -0
  41. package/dist/services/web-server.js +9 -17
  42. package/dist/web/assets/index-7385lY75.css +2 -0
  43. package/dist/web/assets/index-C9y78EH_.js +127 -0
  44. package/dist/web/index.html +2 -2
  45. package/package.json +1 -1
  46. package/dist/web/assets/index-BkHg0H-_.js +0 -127
  47. package/dist/web/assets/index-D2yC6v1B.css +0 -2
@@ -884,194 +884,6 @@ export async function handleRefreshProfile(userId) {
884
884
  return { success: false, error: String(error) };
885
885
  }
886
886
  }
887
- // Temporary storage for pending AI cleanup results (userId → result)
888
- const pendingCleanups = new Map();
889
- export async function handleAICleanup(userId, includeIds, profileVersion) {
890
- try {
891
- const { userProfileManager } = await import("./user-profile/user-profile-manager.js");
892
- const { aiCleanupProfile, aiCleanupProfileFromIndexed, filterProfileForCleanup } = await import("./user-profile/ai-cleanup.js");
893
- let targetUserId = userId;
894
- if (!targetUserId) {
895
- targetUserId = (await resolveWebProfileUserId(CONFIG)) || "unknown";
896
- }
897
- const profile = await userProfileManager.getActiveProfile(targetUserId);
898
- if (!profile) {
899
- return { success: false, error: "No profile found to clean up" };
900
- }
901
- if (profileVersion !== undefined && profile.version !== profileVersion) {
902
- return { success: false, error: "Profile changed. Reload it before running AI cleanup." };
903
- }
904
- const profileData = JSON.parse(profile.profileData);
905
- profileData.preferences = sortProfileItems(profileData.preferences, "confidence");
906
- profileData.patterns = sortProfileItems(profileData.patterns, "frequency");
907
- profileData.workflows = sortProfileItems(profileData.workflows, "frequency");
908
- let indexed;
909
- let result;
910
- const scopedIds = includeIds && includeIds.length > 0 ? includeIds : undefined;
911
- if (scopedIds) {
912
- indexed = filterProfileForCleanup(profileData, scopedIds);
913
- result = await aiCleanupProfileFromIndexed(indexed);
914
- }
915
- else {
916
- result = await aiCleanupProfile(profileData);
917
- }
918
- pendingCleanups.set(targetUserId, {
919
- cleaned: result.cleaned,
920
- oldProfileData: profileData,
921
- diff: result.diff,
922
- allMerged: result.diff?.merged || [],
923
- allRemovedIds: (result.diff?.removed || []).map((r) => r.id),
924
- includeIds: scopedIds,
925
- profileVersion: profile.version,
926
- expiresAt: Date.now() + 30 * 60 * 1000,
927
- });
928
- return {
929
- success: true,
930
- data: {
931
- old: profileData,
932
- new: result.cleaned,
933
- changes: result.diff,
934
- },
935
- };
936
- }
937
- catch (error) {
938
- log("handleAICleanup: error", { error: String(error) });
939
- return { success: false, error: String(error) };
940
- }
941
- }
942
- /**
943
- * Merge an AI-cleanup result into a full profile.
944
- * When includeIds is set, only scoped items are mutated; everything else is preserved.
945
- */
946
- export function mergeCleanupIntoProfile(args) {
947
- const { currentProfile, oldProfileData, cleanedData, includeIds, acceptedMerged = [], acceptedRemoved = [], allMerged = [], allRemovedIds = [], explicitAcceptance = false, } = args;
948
- // Start from cleaned data, then restore any rejected removals/merges.
949
- const scopedResult = {
950
- preferences: [...cleanedData.preferences],
951
- patterns: [...cleanedData.patterns],
952
- workflows: [...cleanedData.workflows],
953
- };
954
- if (explicitAcceptance) {
955
- for (const id of acceptedRemoved) {
956
- removeItemFromProfile(scopedResult, oldProfileData, id);
957
- }
958
- for (const ids of acceptedMerged) {
959
- for (let i = 1; i < ids.length; i++) {
960
- removeItemFromProfile(scopedResult, oldProfileData, ids[i] ?? "");
961
- }
962
- }
963
- const acceptedTargetIds = new Set(acceptedMerged.map((g) => g[0]));
964
- for (const merge of allMerged) {
965
- const groupIds = merge.ids;
966
- if (groupIds.length <= 1)
967
- continue;
968
- if (acceptedTargetIds.has(groupIds[0]))
969
- continue;
970
- removeOneByDescription(scopedResult, merge.result, itemTypeFromId(groupIds[0] ?? ""));
971
- for (const id of groupIds) {
972
- if (id)
973
- pushItemFromProfile(scopedResult, oldProfileData, id);
974
- }
975
- }
976
- const acceptedRemovedSet = new Set(acceptedRemoved);
977
- for (const removedId of allRemovedIds) {
978
- if (acceptedRemovedSet.has(removedId))
979
- continue;
980
- pushItemFromProfile(scopedResult, oldProfileData, removedId);
981
- }
982
- }
983
- // Full-profile cleanup: cleaned (+ acceptance) replaces the whole profile.
984
- if (!includeIds || includeIds.length === 0) {
985
- return scopedResult;
986
- }
987
- // Partial selection: mutate only the analyzed scope inside the current full profile.
988
- const result = {
989
- preferences: [...currentProfile.preferences],
990
- patterns: [...currentProfile.patterns],
991
- workflows: [...currentProfile.workflows],
992
- };
993
- for (const id of includeIds) {
994
- removeItemFromProfile(result, oldProfileData, id);
995
- }
996
- result.preferences.push(...scopedResult.preferences);
997
- result.patterns.push(...scopedResult.patterns);
998
- result.workflows.push(...scopedResult.workflows);
999
- return result;
1000
- }
1001
- function pushItemFromProfile(target, source, id) {
1002
- const srcItem = findItemById(source, id);
1003
- if (!srcItem)
1004
- return;
1005
- const { id: _id, ...rest } = srcItem;
1006
- if (id.startsWith("pref_"))
1007
- target.preferences.push(rest);
1008
- else if (id.startsWith("pat_"))
1009
- target.patterns.push(rest);
1010
- else if (id.startsWith("wf_"))
1011
- target.workflows.push(rest);
1012
- }
1013
- export async function handleApplyCleanup(userId, body) {
1014
- try {
1015
- const { userProfileManager } = await import("./user-profile/user-profile-manager.js");
1016
- let targetUserId = userId;
1017
- if (!targetUserId) {
1018
- targetUserId = (await resolveWebProfileUserId(CONFIG)) || "unknown";
1019
- }
1020
- const pending = pendingCleanups.get(targetUserId);
1021
- if (!pending) {
1022
- return { success: false, error: "No pending cleanup found. Run AI cleanup first." };
1023
- }
1024
- if (Date.now() > pending.expiresAt) {
1025
- pendingCleanups.delete(targetUserId);
1026
- return { success: false, error: "Cleanup session expired. Run AI cleanup again." };
1027
- }
1028
- const profile = await userProfileManager.getActiveProfile(targetUserId);
1029
- if (!profile) {
1030
- return { success: false, error: "Profile not found" };
1031
- }
1032
- if (profile.version !== pending.profileVersion) {
1033
- pendingCleanups.delete(targetUserId);
1034
- return { success: false, error: "Profile changed. Run AI cleanup again." };
1035
- }
1036
- const cleanedData = body?.profile || pending.cleaned;
1037
- const acceptedMerged = Array.isArray(body?.acceptedMerged)
1038
- ? body.acceptedMerged
1039
- : [];
1040
- const acceptedRemoved = Array.isArray(body?.acceptedRemoved)
1041
- ? body.acceptedRemoved
1042
- : [];
1043
- const explicitAcceptance = Array.isArray(body?.acceptedMerged) || Array.isArray(body?.acceptedRemoved);
1044
- const existingData = JSON.parse(profile.profileData);
1045
- const result = mergeCleanupIntoProfile({
1046
- currentProfile: existingData,
1047
- oldProfileData: pending.oldProfileData,
1048
- cleanedData,
1049
- includeIds: pending.includeIds,
1050
- acceptedMerged,
1051
- acceptedRemoved,
1052
- allMerged: pending.allMerged,
1053
- allRemovedIds: pending.allRemovedIds,
1054
- explicitAcceptance,
1055
- });
1056
- const partial = (pending.includeIds && pending.includeIds.length > 0) || explicitAcceptance;
1057
- const success = await saveProfile(profile, result, partial ? "AI cleanup applied (partial)" : "AI cleanup applied");
1058
- if (!success) {
1059
- return { success: false, error: "Profile was modified by another session. Please retry." };
1060
- }
1061
- pendingCleanups.delete(targetUserId);
1062
- return {
1063
- success: true,
1064
- data: {
1065
- message: partial ? "Partial cleanup applied" : "Cleanup applied successfully",
1066
- version: profile.version + 1,
1067
- },
1068
- };
1069
- }
1070
- catch (error) {
1071
- log("handleApplyCleanup: error", { error: String(error) });
1072
- return { success: false, error: String(error) };
1073
- }
1074
- }
1075
887
  async function categoryCleanupUser(userId) {
1076
888
  return userId || (await resolveWebProfileUserId(CONFIG)) || "unknown";
1077
889
  }
@@ -1144,57 +956,6 @@ export async function handleCategoryCleanupUndo(userId, deps) {
1144
956
  return categoryCleanupError("undo", error);
1145
957
  }
1146
958
  }
1147
- function itemTypeFromId(id) {
1148
- if (id.startsWith("pref_"))
1149
- return "preferences";
1150
- if (id.startsWith("pat_"))
1151
- return "patterns";
1152
- return "workflows";
1153
- }
1154
- function findItemById(profile, id) {
1155
- if (typeof id !== "string" || !id.includes("_"))
1156
- return null;
1157
- const parts = id.split("_");
1158
- const prefix = parts[0];
1159
- const idx = parseInt(parts[1] || "", 10);
1160
- if (isNaN(idx))
1161
- return null;
1162
- if (prefix === "pref")
1163
- return profile.preferences[idx] || null;
1164
- if (prefix === "pat")
1165
- return profile.patterns[idx] || null;
1166
- if (prefix === "wf")
1167
- return profile.workflows[idx] || null;
1168
- return null;
1169
- }
1170
- function removeItemFromProfile(target, source, id) {
1171
- const sourceItem = findItemById(source, id);
1172
- if (!sourceItem)
1173
- return false;
1174
- const itemType = itemTypeFromId(id);
1175
- const items = target[itemType];
1176
- const sourceKey = profileItemIdentityKey(sourceItem);
1177
- const index = items.findIndex((item) => profileItemIdentityKey(item) === sourceKey);
1178
- if (index < 0)
1179
- return false;
1180
- items.splice(index, 1);
1181
- return true;
1182
- }
1183
- function profileItemIdentityKey(item) {
1184
- return JSON.stringify({
1185
- category: item.category ?? null,
1186
- description: item.description ?? null,
1187
- steps: Array.isArray(item.steps) ? item.steps : null,
1188
- });
1189
- }
1190
- function removeOneByDescription(profile, desc, itemType) {
1191
- const items = profile[itemType];
1192
- const index = items.findIndex((item) => item.description === desc);
1193
- if (index < 0)
1194
- return false;
1195
- items.splice(index, 1);
1196
- return true;
1197
- }
1198
959
  export async function handleUpdateProfileItem(body) {
1199
960
  try {
1200
961
  const { userProfileManager } = await import("./user-profile/user-profile-manager.js");
@@ -0,0 +1,22 @@
1
+ import type { UserProfileManager } from "./user-profile/user-profile-manager.js";
2
+ import type { AutoCleanupSummary } from "./user-profile/auto-cleanup-store.js";
3
+ interface ApiResponse<T = any> {
4
+ success: boolean;
5
+ data?: T;
6
+ error?: string;
7
+ }
8
+ export interface AutoCleanupHandlerDeps {
9
+ manager?: Pick<UserProfileManager, "getActiveProfile" | "updateProfile">;
10
+ }
11
+ /** The newest automatic cleanup that can still be undone, without its items. */
12
+ export declare function handleAutoCleanupStatus(userId?: string, deps?: AutoCleanupHandlerDeps): Promise<ApiResponse<AutoCleanupSummary | null>>;
13
+ /**
14
+ * Undoes the newest automatic cleanup that is not undone, in the newest profile, with the
15
+ * version check. A save conflict is tried again at most three times; after that the profile
16
+ * and the record stay as they were.
17
+ */
18
+ export declare function handleAutoCleanupUndo(userId?: string, deps?: AutoCleanupHandlerDeps): Promise<ApiResponse<{
19
+ restored: number;
20
+ notFound: number;
21
+ }>>;
22
+ export {};
@@ -0,0 +1,71 @@
1
+ import { log } from "./logger.js";
2
+ async function cleanupUser(userId) {
3
+ if (userId)
4
+ return userId;
5
+ const { CONFIG } = await import("../config.js");
6
+ const { resolveWebProfileUserId } = await import("./profile-identity.js");
7
+ return (await resolveWebProfileUserId(CONFIG)) || "unknown";
8
+ }
9
+ async function managerOf(deps) {
10
+ return (deps.manager ?? (await import("./user-profile/user-profile-manager.js")).userProfileManager);
11
+ }
12
+ function handlerError(step, error) {
13
+ // The log never holds profile text, so only the error's kind is kept.
14
+ log(`Automatic cleanup ${step}: error`, {
15
+ code: error instanceof Error ? error.name : "unknown",
16
+ });
17
+ return { success: false, error: String(error) };
18
+ }
19
+ /** The newest automatic cleanup that can still be undone, without its items. */
20
+ export async function handleAutoCleanupStatus(userId, deps = {}) {
21
+ try {
22
+ const { latestOpenAutoCleanupSummary } = await import("./user-profile/auto-cleanup-store.js");
23
+ const profile = await (await managerOf(deps)).getActiveProfile(await cleanupUser(userId));
24
+ if (!profile)
25
+ return { success: true, data: null };
26
+ return { success: true, data: await latestOpenAutoCleanupSummary(profile.id) };
27
+ }
28
+ catch (error) {
29
+ return handlerError("status", error);
30
+ }
31
+ }
32
+ /**
33
+ * Undoes the newest automatic cleanup that is not undone, in the newest profile, with the
34
+ * version check. A save conflict is tried again at most three times; after that the profile
35
+ * and the record stay as they were.
36
+ */
37
+ export async function handleAutoCleanupUndo(userId, deps = {}) {
38
+ try {
39
+ const { latestOpenAutoCleanup, markAutoCleanupUndone } = await import("./user-profile/auto-cleanup-store.js");
40
+ const { undoCleanupEdits } = await import("./user-profile/ai-cleanup-undo.js");
41
+ const { applyToNewestProfile } = await import("./user-profile/profile-edit-save.js");
42
+ const user = await cleanupUser(userId);
43
+ const manager = await managerOf(deps);
44
+ const profile = await manager.getActiveProfile(user);
45
+ const record = profile ? await latestOpenAutoCleanup(profile.id) : null;
46
+ if (!record)
47
+ return { success: false, error: "Nothing to undo." };
48
+ const outcome = await applyToNewestProfile(user, (data) => {
49
+ const undone = undoCleanupEdits(data, record.entries);
50
+ return {
51
+ profile: undone.profile,
52
+ changed: undone.restored > 0,
53
+ result: { restored: undone.restored, notFound: undone.notFound },
54
+ };
55
+ }, { summary: "Automatic cleanup undone", store: manager });
56
+ if (outcome.status === "no-profile")
57
+ return { success: false, error: "Nothing to undo." };
58
+ if (outcome.status === "conflict") {
59
+ return { success: false, error: "Profile saved at the same moment. Try Undo again." };
60
+ }
61
+ await markAutoCleanupUndone(record.id);
62
+ log("Automatic cleanup undone", {
63
+ restored: outcome.result.restored,
64
+ notFound: outcome.result.notFound,
65
+ });
66
+ return { success: true, data: outcome.result };
67
+ }
68
+ catch (error) {
69
+ return handlerError("undo", error);
70
+ }
71
+ }
@@ -95,6 +95,15 @@ export class CleanupService {
95
95
  code: error instanceof Error ? error.name : "unknown",
96
96
  });
97
97
  }
98
+ try {
99
+ const { pruneAutoCleanupRecords } = await import("./user-profile/auto-cleanup-store.js");
100
+ await pruneAutoCleanupRecords();
101
+ }
102
+ catch (error) {
103
+ log("Automatic cleanup record cleanup failed", {
104
+ code: error instanceof Error ? error.name : "unknown",
105
+ });
106
+ }
98
107
  try {
99
108
  await userPromptManager.vacuum();
100
109
  log("Cleanup: VACUUM done", { db: "user-prompts.db" });
@@ -18,6 +18,8 @@ const keys = new Set([
18
18
  "opencodeBackfillModel",
19
19
  "piBackfillModel",
20
20
  "webServerAutoStart",
21
+ "userProfileAutoCleanupEnabled",
22
+ "userProfileAutoCleanupInterval",
21
23
  "memoryProvider",
22
24
  "memoryApiUrl",
23
25
  "memoryModel",
@@ -25,6 +27,7 @@ const keys = new Set([
25
27
  "importPathMaps",
26
28
  "importIgnoredDirectories",
27
29
  "claudeConfigDir",
30
+ "autoCaptureLanguage",
28
31
  "maxMemories",
29
32
  "chatMessage.maxMemories",
30
33
  "autoCaptureMaxContextBytes",
@@ -69,9 +72,17 @@ function isValidEdit(key, value) {
69
72
  if (key === "embeddingDimensions") {
70
73
  return Number.isInteger(value) && value > 0 && value <= 65536;
71
74
  }
72
- if (key === "captureTrace" || key === "autoBackfill" || key === "webServerAutoStart") {
75
+ if (key === "captureTrace" ||
76
+ key === "autoBackfill" ||
77
+ key === "webServerAutoStart" ||
78
+ key === "userProfileAutoCleanupEnabled") {
73
79
  return typeof value === "boolean";
74
80
  }
81
+ // Each automatic cleanup is paid for, so the page refuses an interval that would run one
82
+ // after almost every learning pass. A hand edit of the file has no floor.
83
+ if (key === "userProfileAutoCleanupInterval") {
84
+ return Number.isSafeInteger(value) && value >= 50 && value <= 100000;
85
+ }
75
86
  if (key.endsWith("RetentionDays"))
76
87
  return Number.isSafeInteger(value) && value >= 1;
77
88
  if (key === "captureRetryRetentionHours") {
@@ -81,6 +92,9 @@ function isValidEdit(key, value) {
81
92
  return isSecretReference(value);
82
93
  if (key === "memoryProvider")
83
94
  return MEMORY_PROVIDERS.includes(value);
95
+ // "auto", or an ISO 639 code (en, id, zh, fil).
96
+ if (key === "autoCaptureLanguage")
97
+ return typeof value === "string" && /^(auto|[a-z]{2,3})$/.test(value);
84
98
  // Entries are checked by the startup validation below.
85
99
  if (key === "importPathMaps" || key === "importIgnoredDirectories")
86
100
  return Array.isArray(value);
@@ -7,10 +7,24 @@ const FALLBACK_MAP = {
7
7
  arz: "ar", // Egyptian Arabic
8
8
  hbs: "sr", // Serbo-Croatian
9
9
  };
10
+ // franc guesses wrongly on short Latin-script text (for example "ok go ahead" as Somali),
11
+ // and the capture prompt then asks the model to write the memory in that language.
12
+ // Below this length, Latin-script text counts as English. Non-Latin scripts stay reliable.
13
+ const MIN_LATIN_DETECT_LENGTH = 100;
14
+ function isMostlyLatin(text) {
15
+ const letters = text.match(/\p{L}/gu) ?? [];
16
+ if (letters.length === 0)
17
+ return true;
18
+ const latin = letters.filter((char) => /\p{Script=Latin}/u.test(char)).length;
19
+ return latin / letters.length >= 0.5;
20
+ }
10
21
  export function detectLanguage(text) {
11
22
  if (!text || text.trim().length === 0) {
12
23
  return "en";
13
24
  }
25
+ if (text.trim().length < MIN_LATIN_DETECT_LENGTH && isMostlyLatin(text)) {
26
+ return "en";
27
+ }
14
28
  const detected = franc(text, { minLength: 5 });
15
29
  if (detected === "und") {
16
30
  return "en";
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The profile cleanup routes: AI Cleanup (start, status, stop, apply) and the undo of the
3
+ * last automatic cleanup. Returns the JSON body, or undefined when the path and method are
4
+ * not a cleanup route. The body is read only for the routes that need it.
5
+ */
6
+ export declare function routeProfileCleanup(path: string, method: string, url: URL, readBody: () => Promise<any>): Promise<unknown | undefined>;
@@ -0,0 +1,32 @@
1
+ import * as aiCleanup from "./ai-cleanup-handlers.js";
2
+ import * as autoCleanup from "./auto-cleanup-handlers.js";
3
+ /**
4
+ * The profile cleanup routes: AI Cleanup (start, status, stop, apply) and the undo of the
5
+ * last automatic cleanup. Returns the JSON body, or undefined when the path and method are
6
+ * not a cleanup route. The body is read only for the routes that need it.
7
+ */
8
+ export async function routeProfileCleanup(path, method, url, readBody) {
9
+ const queryUser = url.searchParams.get("userId") || undefined;
10
+ if (path === "/api/user-profile/ai-cleanup") {
11
+ if (method === "POST") {
12
+ const body = await readBody();
13
+ return aiCleanup.handleAiCleanupStart(body.userId, body);
14
+ }
15
+ if (method === "GET")
16
+ return aiCleanup.handleAiCleanupStatus(queryUser);
17
+ if (method === "DELETE")
18
+ return aiCleanup.handleAiCleanupStop(queryUser);
19
+ }
20
+ if (path === "/api/user-profile/ai-cleanup/apply" && method === "POST") {
21
+ const body = await readBody();
22
+ return aiCleanup.handleAiCleanupApply(body.userId, body);
23
+ }
24
+ if (path === "/api/user-profile/auto-cleanup" && method === "GET") {
25
+ return autoCleanup.handleAutoCleanupStatus(queryUser);
26
+ }
27
+ if (path === "/api/user-profile/auto-cleanup/undo" && method === "POST") {
28
+ const body = await readBody();
29
+ return autoCleanup.handleAutoCleanupUndo(body.userId || undefined);
30
+ }
31
+ return undefined;
32
+ }
@@ -1,5 +1,7 @@
1
1
  export declare const EXTERNAL_PROFILE_CLEANUP_TIMEOUT_MS = 60000;
2
2
  export declare const OPENCODE_PROFILE_CLEANUP_TIMEOUT_MS = 300000;
3
+ /** The most items one web AI cleanup request sends, so each request ends inside its time limit. */
4
+ export declare const AI_CLEANUP_BATCH_SIZE = 100;
3
5
  export declare const NODE_HTTP_IDLE_TIMEOUT_MS: number;
4
6
  /**
5
7
  * Upper bound an OpenCode v2 model request waits for the per-prompt memory
@@ -1,5 +1,7 @@
1
1
  export const EXTERNAL_PROFILE_CLEANUP_TIMEOUT_MS = 60_000;
2
2
  export const OPENCODE_PROFILE_CLEANUP_TIMEOUT_MS = 300_000;
3
+ /** The most items one web AI cleanup request sends, so each request ends inside its time limit. */
4
+ export const AI_CLEANUP_BATCH_SIZE = 100;
3
5
  const HTTP_TIMEOUT_GRACE_MS = 30_000;
4
6
  export const NODE_HTTP_IDLE_TIMEOUT_MS = Math.max(EXTERNAL_PROFILE_CLEANUP_TIMEOUT_MS, OPENCODE_PROFILE_CLEANUP_TIMEOUT_MS) +
5
7
  HTTP_TIMEOUT_GRACE_MS;
@@ -20,12 +20,15 @@ const editable = [
20
20
  "opencodeBackfillModel",
21
21
  "piBackfillModel",
22
22
  "webServerAutoStart",
23
+ "userProfileAutoCleanupEnabled",
24
+ "userProfileAutoCleanupInterval",
23
25
  "memoryProvider",
24
26
  "memoryApiUrl",
25
27
  "memoryModel",
26
28
  "importPathMaps",
27
29
  "importIgnoredDirectories",
28
30
  "claudeConfigDir",
31
+ "autoCaptureLanguage",
29
32
  ...MEMORY_LIMIT_SETTINGS,
30
33
  ];
31
34
  /** Keys a project config cannot override, so the page always shows the global value. */
@@ -35,6 +38,8 @@ const globalOnly = [
35
38
  "opencodeBackfillModel",
36
39
  "piBackfillModel",
37
40
  "webServerAutoStart",
41
+ "userProfileAutoCleanupEnabled",
42
+ "userProfileAutoCleanupInterval",
38
43
  "memoryProvider",
39
44
  "memoryApiUrl",
40
45
  "importPathMaps",
@@ -0,0 +1,47 @@
1
+ import type { CleanupDiff } from "./ai-cleanup.js";
2
+ import type { UserProfileData } from "./types.js";
3
+ export type Section = "preferences" | "patterns" | "workflows";
4
+ /** What a finished cleanup job keeps for Apply. Ids are positions in `old`. */
5
+ export interface CleanupJobResult {
6
+ old: UserProfileData;
7
+ changes: CleanupDiff;
8
+ mergedItems: Record<string, unknown>;
9
+ }
10
+ export interface AcceptedCleanup {
11
+ merged?: string[][];
12
+ removed?: string[];
13
+ }
14
+ /**
15
+ * What one applied change replaced, as stored. Undo reverses it. A merge holds the original
16
+ * items and the merged item that was written. A removal holds the original item.
17
+ */
18
+ export type RestoreEntry = {
19
+ kind: "merge";
20
+ section: Section;
21
+ originals: unknown[];
22
+ merged: unknown;
23
+ } | {
24
+ kind: "remove";
25
+ section: Section;
26
+ original: unknown;
27
+ };
28
+ export interface AppliedCleanup {
29
+ profile: UserProfileData;
30
+ applied: number;
31
+ skipped: number;
32
+ restore: RestoreEntry[];
33
+ }
34
+ /**
35
+ * Puts the ticked changes of a job into `current`, which may be newer than the profile
36
+ * the job read. Each item is found by section, category, description and steps, so
37
+ * learning's counters do not break a match. A change is skipped when one of its items is
38
+ * gone. Every other item stays exactly as stored. Ticked ids that are not changes of
39
+ * the job are ignored.
40
+ */
41
+ export declare function applyCleanupEdits(current: UserProfileData, job: CleanupJobResult, accepted: AcceptedCleanup): AppliedCleanup;
42
+ /**
43
+ * The fields that identify a profile item. Counters and vectors are left out. The category
44
+ * counts by its key, because every save keeps one visible spelling per category and may
45
+ * rewrite the spelling of a stored item.
46
+ */
47
+ export declare function profileItemIdentityKey(item: any): string;
@@ -0,0 +1,108 @@
1
+ import { categoryKey } from "./profile-category.js";
2
+ const SECTION_BY_PREFIX = {
3
+ pref: "preferences",
4
+ pat: "patterns",
5
+ wf: "workflows",
6
+ };
7
+ /**
8
+ * Puts the ticked changes of a job into `current`, which may be newer than the profile
9
+ * the job read. Each item is found by section, category, description and steps, so
10
+ * learning's counters do not break a match. A change is skipped when one of its items is
11
+ * gone. Every other item stays exactly as stored. Ticked ids that are not changes of
12
+ * the job are ignored.
13
+ */
14
+ export function applyCleanupEdits(current, job, accepted) {
15
+ const profile = {
16
+ ...current,
17
+ preferences: [...current.preferences],
18
+ patterns: [...current.patterns],
19
+ workflows: [...current.workflows],
20
+ };
21
+ const merges = new Map(job.changes.merged.map((m) => [m.ids.join(","), m.ids]));
22
+ const removals = new Set(job.changes.removed.map((r) => r.id));
23
+ const used = new Set();
24
+ const restore = [];
25
+ let applied = 0;
26
+ let skipped = 0;
27
+ for (const ticked of accepted.merged ?? []) {
28
+ const ids = Array.isArray(ticked) ? merges.get(ticked.join(",")) : undefined;
29
+ if (!ids || ids.length < 2)
30
+ continue;
31
+ const mergedItem = job.mergedItems[ids[0]];
32
+ const found = mergedItem ? locate(profile, job.old, ids, used) : null;
33
+ if (!found) {
34
+ skipped++;
35
+ continue;
36
+ }
37
+ const items = profile[found.section];
38
+ const written = { ...mergedItem };
39
+ restore.push({
40
+ kind: "merge",
41
+ section: found.section,
42
+ originals: found.indices.map((index) => items[index]),
43
+ merged: written,
44
+ });
45
+ items[found.indices[0]] = written;
46
+ for (const index of found.indices.slice(1).sort((a, b) => b - a))
47
+ items.splice(index, 1);
48
+ for (const id of ids)
49
+ used.add(id);
50
+ applied++;
51
+ }
52
+ for (const id of accepted.removed ?? []) {
53
+ if (typeof id !== "string" || !removals.has(id))
54
+ continue;
55
+ const found = locate(profile, job.old, [id], used);
56
+ if (!found) {
57
+ skipped++;
58
+ continue;
59
+ }
60
+ const [removed] = profile[found.section].splice(found.indices[0], 1);
61
+ restore.push({ kind: "remove", section: found.section, original: removed });
62
+ used.add(id);
63
+ applied++;
64
+ }
65
+ return { profile, applied, skipped, restore };
66
+ }
67
+ /**
68
+ * Finds each id's item from `old` in `profile`, at a distinct position, all in one
69
+ * section. Returns null when an item is gone or an earlier change already used it.
70
+ */
71
+ function locate(profile, old, ids, used) {
72
+ const section = sectionOf(ids[0]);
73
+ if (!section)
74
+ return null;
75
+ const items = profile[section];
76
+ const indices = [];
77
+ for (const id of ids) {
78
+ if (used.has(id) || sectionOf(id) !== section)
79
+ return null;
80
+ const source = old[section][positionOf(id)];
81
+ if (!source)
82
+ return null;
83
+ const key = profileItemIdentityKey(source);
84
+ const index = items.findIndex((item, i) => !indices.includes(i) && profileItemIdentityKey(item) === key);
85
+ if (index < 0)
86
+ return null;
87
+ indices.push(index);
88
+ }
89
+ return { section, indices };
90
+ }
91
+ function sectionOf(id) {
92
+ return SECTION_BY_PREFIX[id.split("_")[0] ?? ""];
93
+ }
94
+ function positionOf(id) {
95
+ return Number.parseInt(id.split("_")[1] ?? "", 10);
96
+ }
97
+ /**
98
+ * The fields that identify a profile item. Counters and vectors are left out. The category
99
+ * counts by its key, because every save keeps one visible spelling per category and may
100
+ * rewrite the spelling of a stored item.
101
+ */
102
+ export function profileItemIdentityKey(item) {
103
+ return JSON.stringify({
104
+ category: item.category == null ? null : categoryKey(String(item.category)),
105
+ description: item.description ?? null,
106
+ steps: Array.isArray(item.steps) ? item.steps : null,
107
+ });
108
+ }