om-memory-system 4.19.1 → 4.20.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.
- package/dist/services/ai-cleanup-handlers.d.ts +31 -0
- package/dist/services/ai-cleanup-handlers.js +126 -0
- package/dist/services/api-handlers.d.ts +0 -22
- package/dist/services/api-handlers.js +0 -239
- package/dist/services/request-timeouts.d.ts +2 -0
- package/dist/services/request-timeouts.js +2 -0
- package/dist/services/user-profile/ai-cleanup-apply.d.ts +27 -0
- package/dist/services/user-profile/ai-cleanup-apply.js +94 -0
- package/dist/services/user-profile/ai-cleanup-batches.d.ts +34 -0
- package/dist/services/user-profile/ai-cleanup-batches.js +177 -0
- package/dist/services/user-profile/ai-cleanup-job.d.ts +52 -0
- package/dist/services/user-profile/ai-cleanup-job.js +112 -0
- package/dist/services/user-profile/ai-cleanup.d.ts +11 -6
- package/dist/services/user-profile/ai-cleanup.js +5 -40
- package/dist/services/web-server.js +9 -11
- package/dist/web/assets/index-CYcRfxr4.css +2 -0
- package/dist/web/assets/index-DvNL0vOb.js +127 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-BkHg0H-_.js +0 -127
- package/dist/web/assets/index-D2yC6v1B.css +0 -2
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { UserProfileManager } from "./user-profile/user-profile-manager.js";
|
|
2
|
+
import type { AiCleanupJobDeps, AiCleanupJobView } from "./user-profile/ai-cleanup-job.js";
|
|
3
|
+
interface ApiResponse<T = any> {
|
|
4
|
+
success: boolean;
|
|
5
|
+
data?: T;
|
|
6
|
+
error?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface AiCleanupHandlerDeps extends AiCleanupJobDeps {
|
|
9
|
+
manager?: UserProfileManager;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Starts a background cleanup of the selected items, or returns the job already running.
|
|
13
|
+
* A stale `profileVersion` fails at once, because the dialog's item ids are positions in
|
|
14
|
+
* the profile it read.
|
|
15
|
+
*/
|
|
16
|
+
export declare function handleAiCleanupStart(userId?: string, body?: any, deps?: AiCleanupHandlerDeps): Promise<ApiResponse<AiCleanupJobView | null>>;
|
|
17
|
+
/** The state of the user's job, or null when there is none. */
|
|
18
|
+
export declare function handleAiCleanupStatus(userId?: string): Promise<ApiResponse<AiCleanupJobView | null>>;
|
|
19
|
+
/** Stops a running job, or discards a finished one. */
|
|
20
|
+
export declare function handleAiCleanupStop(userId?: string): Promise<ApiResponse<null>>;
|
|
21
|
+
/**
|
|
22
|
+
* Puts the ticked changes of the finished job into the newest profile and saves it with
|
|
23
|
+
* the version check. When another save lands first, it loads the profile and tries
|
|
24
|
+
* again, at most MAX_PROFILE_SAVE_ATTEMPTS times. The request's `profile` field is
|
|
25
|
+
* ignored; the job holds the result.
|
|
26
|
+
*/
|
|
27
|
+
export declare function handleAiCleanupApply(userId?: string, body?: any, deps?: AiCleanupHandlerDeps): Promise<ApiResponse<{
|
|
28
|
+
applied: number;
|
|
29
|
+
skipped: number;
|
|
30
|
+
}>>;
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { log } from "./logger.js";
|
|
2
|
+
import { sortProfileItems, stripProfileVectors } from "../utils/profile.js";
|
|
3
|
+
async function cleanupUser(userId) {
|
|
4
|
+
if (userId)
|
|
5
|
+
return userId;
|
|
6
|
+
const { CONFIG } = await import("../config.js");
|
|
7
|
+
const { resolveWebProfileUserId } = await import("./profile-identity.js");
|
|
8
|
+
return (await resolveWebProfileUserId(CONFIG)) || "unknown";
|
|
9
|
+
}
|
|
10
|
+
async function managerOf(deps) {
|
|
11
|
+
return (deps.manager ?? (await import("./user-profile/user-profile-manager.js")).userProfileManager);
|
|
12
|
+
}
|
|
13
|
+
function handlerError(step, error) {
|
|
14
|
+
// The log never holds profile text, so only the error's kind is kept.
|
|
15
|
+
log(`AI cleanup ${step}: error`, { code: error instanceof Error ? error.name : "unknown" });
|
|
16
|
+
return { success: false, error: String(error) };
|
|
17
|
+
}
|
|
18
|
+
/** The profile in the order the dialog lists it, so item ids match its positions. */
|
|
19
|
+
function sortedProfile(profileData) {
|
|
20
|
+
const data = JSON.parse(profileData);
|
|
21
|
+
data.preferences = sortProfileItems(data.preferences, "confidence");
|
|
22
|
+
data.patterns = sortProfileItems(data.patterns, "frequency");
|
|
23
|
+
data.workflows = sortProfileItems(data.workflows, "frequency");
|
|
24
|
+
return data;
|
|
25
|
+
}
|
|
26
|
+
/** The job as the dialog sees it: no vectors, and no API key in an error. */
|
|
27
|
+
async function clientView(job) {
|
|
28
|
+
if (job?.status.state === "done") {
|
|
29
|
+
const result = { ...job.status.result, old: stripProfileVectors(job.status.result.old) };
|
|
30
|
+
return { ...job, status: { ...job.status, result } };
|
|
31
|
+
}
|
|
32
|
+
if (job?.status.state === "failed") {
|
|
33
|
+
const { CONFIG } = await import("../config.js");
|
|
34
|
+
const { redactTraceText } = await import("./capture-diagnostics.js");
|
|
35
|
+
return { ...job, status: { ...job.status, error: redactTraceText(job.status.error, CONFIG) } };
|
|
36
|
+
}
|
|
37
|
+
return job;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Starts a background cleanup of the selected items, or returns the job already running.
|
|
41
|
+
* A stale `profileVersion` fails at once, because the dialog's item ids are positions in
|
|
42
|
+
* the profile it read.
|
|
43
|
+
*/
|
|
44
|
+
export async function handleAiCleanupStart(userId, body, deps = {}) {
|
|
45
|
+
try {
|
|
46
|
+
const { getAiCleanupJob, startAiCleanupJob } = await import("./user-profile/ai-cleanup-job.js");
|
|
47
|
+
const user = await cleanupUser(userId);
|
|
48
|
+
const current = getAiCleanupJob(user);
|
|
49
|
+
if (current?.status.state === "running")
|
|
50
|
+
return { success: true, data: current };
|
|
51
|
+
const profile = await (await managerOf(deps)).getActiveProfile(user);
|
|
52
|
+
if (!profile)
|
|
53
|
+
return { success: false, error: "No profile found to clean up" };
|
|
54
|
+
if (typeof body?.profileVersion === "number" && profile.version !== body.profileVersion) {
|
|
55
|
+
return { success: false, error: "Profile changed. Reload it before running AI cleanup." };
|
|
56
|
+
}
|
|
57
|
+
const includeIds = Array.isArray(body?.includeIds)
|
|
58
|
+
? body.includeIds.filter((id) => typeof id === "string")
|
|
59
|
+
: undefined;
|
|
60
|
+
const job = startAiCleanupJob(user, { profileData: sortedProfile(profile.profileData), includeIds }, deps);
|
|
61
|
+
return { success: true, data: await clientView(job) };
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
return handlerError("start", error);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** The state of the user's job, or null when there is none. */
|
|
68
|
+
export async function handleAiCleanupStatus(userId) {
|
|
69
|
+
try {
|
|
70
|
+
const { getAiCleanupJob } = await import("./user-profile/ai-cleanup-job.js");
|
|
71
|
+
return { success: true, data: await clientView(getAiCleanupJob(await cleanupUser(userId))) };
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
return handlerError("status", error);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Stops a running job, or discards a finished one. */
|
|
78
|
+
export async function handleAiCleanupStop(userId) {
|
|
79
|
+
try {
|
|
80
|
+
const { stopAiCleanupJob } = await import("./user-profile/ai-cleanup-job.js");
|
|
81
|
+
stopAiCleanupJob(await cleanupUser(userId));
|
|
82
|
+
return { success: true, data: null };
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
return handlerError("stop", error);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Puts the ticked changes of the finished job into the newest profile and saves it with
|
|
90
|
+
* the version check. When another save lands first, it loads the profile and tries
|
|
91
|
+
* again, at most MAX_PROFILE_SAVE_ATTEMPTS times. The request's `profile` field is
|
|
92
|
+
* ignored; the job holds the result.
|
|
93
|
+
*/
|
|
94
|
+
export async function handleAiCleanupApply(userId, body, deps = {}) {
|
|
95
|
+
try {
|
|
96
|
+
const { getAiCleanupResult, stopAiCleanupJob } = await import("./user-profile/ai-cleanup-job.js");
|
|
97
|
+
const { applyCleanupEdits } = await import("./user-profile/ai-cleanup-apply.js");
|
|
98
|
+
const { MAX_PROFILE_SAVE_ATTEMPTS, saveProfile } = await import("./user-profile/merge-and-save.js");
|
|
99
|
+
const user = await cleanupUser(userId);
|
|
100
|
+
const job = getAiCleanupResult(user);
|
|
101
|
+
if (!job)
|
|
102
|
+
return { success: false, error: "No finished cleanup found. Run AI cleanup again." };
|
|
103
|
+
const accepted = {
|
|
104
|
+
merged: Array.isArray(body?.acceptedMerged) ? body.acceptedMerged : [],
|
|
105
|
+
removed: Array.isArray(body?.acceptedRemoved) ? body.acceptedRemoved : [],
|
|
106
|
+
};
|
|
107
|
+
const manager = await managerOf(deps);
|
|
108
|
+
for (let attempt = 1; attempt <= MAX_PROFILE_SAVE_ATTEMPTS; attempt++) {
|
|
109
|
+
const profile = await manager.getActiveProfile(user);
|
|
110
|
+
if (!profile)
|
|
111
|
+
return { success: false, error: "Profile not found" };
|
|
112
|
+
const result = applyCleanupEdits(JSON.parse(profile.profileData), job, accepted);
|
|
113
|
+
const saved = result.applied === 0 ||
|
|
114
|
+
(await saveProfile(profile, result.profile, "AI cleanup applied", 0, manager));
|
|
115
|
+
if (saved) {
|
|
116
|
+
stopAiCleanupJob(user);
|
|
117
|
+
return { success: true, data: { applied: result.applied, skipped: result.skipped } };
|
|
118
|
+
}
|
|
119
|
+
log("AI cleanup apply: save conflict", { attempt, of: MAX_PROFILE_SAVE_ATTEMPTS });
|
|
120
|
+
}
|
|
121
|
+
return { success: false, error: "Profile saved at the same moment. Try Apply again." };
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
return handlerError("apply", error);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { type KeywordCount } from "./keyword-counts.js";
|
|
2
2
|
import type { MemoryType } from "../types/index.js";
|
|
3
|
-
import type { UserProfileData } from "./user-profile/types.js";
|
|
4
3
|
interface ApiResponse<T = any> {
|
|
5
4
|
success: boolean;
|
|
6
5
|
data?: T;
|
|
@@ -148,27 +147,6 @@ export declare function handleGetUserProfile(userId?: string): Promise<ApiRespon
|
|
|
148
147
|
export declare function handleGetProfileChangelog(profileId: string, limit?: number): Promise<ApiResponse<any[]>>;
|
|
149
148
|
export declare function handleGetProfileSnapshot(changelogId: string): Promise<ApiResponse<any>>;
|
|
150
149
|
export declare function handleRefreshProfile(userId?: string): Promise<ApiResponse<any>>;
|
|
151
|
-
export declare function handleAICleanup(userId?: string, includeIds?: string[], profileVersion?: number): Promise<ApiResponse<any>>;
|
|
152
|
-
/**
|
|
153
|
-
* Merge an AI-cleanup result into a full profile.
|
|
154
|
-
* When includeIds is set, only scoped items are mutated; everything else is preserved.
|
|
155
|
-
*/
|
|
156
|
-
export declare function mergeCleanupIntoProfile(args: {
|
|
157
|
-
currentProfile: UserProfileData;
|
|
158
|
-
oldProfileData: UserProfileData;
|
|
159
|
-
cleanedData: UserProfileData;
|
|
160
|
-
includeIds?: string[];
|
|
161
|
-
acceptedMerged?: string[][];
|
|
162
|
-
acceptedRemoved?: string[];
|
|
163
|
-
allMerged?: Array<{
|
|
164
|
-
ids: string[];
|
|
165
|
-
result: string;
|
|
166
|
-
}>;
|
|
167
|
-
allRemovedIds?: string[];
|
|
168
|
-
/** True when the client sent acceptance arrays (even if empty = reject all). */
|
|
169
|
-
explicitAcceptance?: boolean;
|
|
170
|
-
}): UserProfileData;
|
|
171
|
-
export declare function handleApplyCleanup(userId?: string, body?: any): Promise<ApiResponse<any>>;
|
|
172
150
|
type CategoryCleanupDeps = import("./user-profile/category-cleanup.js").CleanupDeps;
|
|
173
151
|
/** Starts a background preview job, or returns the one already running. Changes nothing in the profile. */
|
|
174
152
|
export declare function handleCategoryCleanupStart(userId?: string, deps?: CategoryCleanupDeps): Promise<ApiResponse<any>>;
|
|
@@ -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");
|
|
@@ -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;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CleanupDiff } from "./ai-cleanup.js";
|
|
2
|
+
import type { UserProfileData } from "./types.js";
|
|
3
|
+
/** What a finished cleanup job keeps for Apply. Ids are positions in `old`. */
|
|
4
|
+
export interface CleanupJobResult {
|
|
5
|
+
old: UserProfileData;
|
|
6
|
+
changes: CleanupDiff;
|
|
7
|
+
mergedItems: Record<string, unknown>;
|
|
8
|
+
}
|
|
9
|
+
export interface AcceptedCleanup {
|
|
10
|
+
merged?: string[][];
|
|
11
|
+
removed?: string[];
|
|
12
|
+
}
|
|
13
|
+
export interface AppliedCleanup {
|
|
14
|
+
profile: UserProfileData;
|
|
15
|
+
applied: number;
|
|
16
|
+
skipped: number;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Puts the ticked changes of a job into `current`, which may be newer than the profile
|
|
20
|
+
* the job read. Each item is found by section, category, description and steps, so
|
|
21
|
+
* learning's counters do not break a match. A change is skipped when one of its items is
|
|
22
|
+
* gone. Every other item stays exactly as stored. Ticked ids that are not changes of
|
|
23
|
+
* the job are ignored.
|
|
24
|
+
*/
|
|
25
|
+
export declare function applyCleanupEdits(current: UserProfileData, job: CleanupJobResult, accepted: AcceptedCleanup): AppliedCleanup;
|
|
26
|
+
/** The fields that identify a profile item. Counters and vectors are left out. */
|
|
27
|
+
export declare function profileItemIdentityKey(item: any): string;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
const SECTION_BY_PREFIX = {
|
|
2
|
+
pref: "preferences",
|
|
3
|
+
pat: "patterns",
|
|
4
|
+
wf: "workflows",
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Puts the ticked changes of a job into `current`, which may be newer than the profile
|
|
8
|
+
* the job read. Each item is found by section, category, description and steps, so
|
|
9
|
+
* learning's counters do not break a match. A change is skipped when one of its items is
|
|
10
|
+
* gone. Every other item stays exactly as stored. Ticked ids that are not changes of
|
|
11
|
+
* the job are ignored.
|
|
12
|
+
*/
|
|
13
|
+
export function applyCleanupEdits(current, job, accepted) {
|
|
14
|
+
const profile = {
|
|
15
|
+
...current,
|
|
16
|
+
preferences: [...current.preferences],
|
|
17
|
+
patterns: [...current.patterns],
|
|
18
|
+
workflows: [...current.workflows],
|
|
19
|
+
};
|
|
20
|
+
const merges = new Map(job.changes.merged.map((m) => [m.ids.join(","), m.ids]));
|
|
21
|
+
const removals = new Set(job.changes.removed.map((r) => r.id));
|
|
22
|
+
const used = new Set();
|
|
23
|
+
let applied = 0;
|
|
24
|
+
let skipped = 0;
|
|
25
|
+
for (const ticked of accepted.merged ?? []) {
|
|
26
|
+
const ids = Array.isArray(ticked) ? merges.get(ticked.join(",")) : undefined;
|
|
27
|
+
if (!ids || ids.length < 2)
|
|
28
|
+
continue;
|
|
29
|
+
const mergedItem = job.mergedItems[ids[0]];
|
|
30
|
+
const found = mergedItem ? locate(profile, job.old, ids, used) : null;
|
|
31
|
+
if (!found) {
|
|
32
|
+
skipped++;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const items = profile[found.section];
|
|
36
|
+
items[found.indices[0]] = { ...mergedItem };
|
|
37
|
+
for (const index of found.indices.slice(1).sort((a, b) => b - a))
|
|
38
|
+
items.splice(index, 1);
|
|
39
|
+
for (const id of ids)
|
|
40
|
+
used.add(id);
|
|
41
|
+
applied++;
|
|
42
|
+
}
|
|
43
|
+
for (const id of accepted.removed ?? []) {
|
|
44
|
+
if (typeof id !== "string" || !removals.has(id))
|
|
45
|
+
continue;
|
|
46
|
+
const found = locate(profile, job.old, [id], used);
|
|
47
|
+
if (!found) {
|
|
48
|
+
skipped++;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
profile[found.section].splice(found.indices[0], 1);
|
|
52
|
+
used.add(id);
|
|
53
|
+
applied++;
|
|
54
|
+
}
|
|
55
|
+
return { profile, applied, skipped };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Finds each id's item from `old` in `profile`, at a distinct position, all in one
|
|
59
|
+
* section. Returns null when an item is gone or an earlier change already used it.
|
|
60
|
+
*/
|
|
61
|
+
function locate(profile, old, ids, used) {
|
|
62
|
+
const section = sectionOf(ids[0]);
|
|
63
|
+
if (!section)
|
|
64
|
+
return null;
|
|
65
|
+
const items = profile[section];
|
|
66
|
+
const indices = [];
|
|
67
|
+
for (const id of ids) {
|
|
68
|
+
if (used.has(id) || sectionOf(id) !== section)
|
|
69
|
+
return null;
|
|
70
|
+
const source = old[section][positionOf(id)];
|
|
71
|
+
if (!source)
|
|
72
|
+
return null;
|
|
73
|
+
const key = profileItemIdentityKey(source);
|
|
74
|
+
const index = items.findIndex((item, i) => !indices.includes(i) && profileItemIdentityKey(item) === key);
|
|
75
|
+
if (index < 0)
|
|
76
|
+
return null;
|
|
77
|
+
indices.push(index);
|
|
78
|
+
}
|
|
79
|
+
return { section, indices };
|
|
80
|
+
}
|
|
81
|
+
function sectionOf(id) {
|
|
82
|
+
return SECTION_BY_PREFIX[id.split("_")[0] ?? ""];
|
|
83
|
+
}
|
|
84
|
+
function positionOf(id) {
|
|
85
|
+
return Number.parseInt(id.split("_")[1] ?? "", 10);
|
|
86
|
+
}
|
|
87
|
+
/** The fields that identify a profile item. Counters and vectors are left out. */
|
|
88
|
+
export function profileItemIdentityKey(item) {
|
|
89
|
+
return JSON.stringify({
|
|
90
|
+
category: item.category ?? null,
|
|
91
|
+
description: item.description ?? null,
|
|
92
|
+
steps: Array.isArray(item.steps) ? item.steps : null,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { UserProfileData } from "./types.js";
|
|
2
|
+
import { type CleanupDiff, type IndexedProfile } from "./ai-cleanup.js";
|
|
3
|
+
/** What one batch adds to the review: its changes, and the merged item of each merge. */
|
|
4
|
+
export interface BatchCleanupResult {
|
|
5
|
+
diff: CleanupDiff;
|
|
6
|
+
mergedItems: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
export interface BatchRunResult extends BatchCleanupResult {
|
|
9
|
+
failedBatches: number;
|
|
10
|
+
failedItems: number;
|
|
11
|
+
}
|
|
12
|
+
export interface RunInBatchesOptions {
|
|
13
|
+
callBatch?: (batch: IndexedProfile) => Promise<BatchCleanupResult>;
|
|
14
|
+
onProgress?: (done: number, total: number, failed: number) => void;
|
|
15
|
+
isStopped?: () => boolean;
|
|
16
|
+
}
|
|
17
|
+
/** Indexes the full profile once, so ids are profile positions, and keeps the selected items. */
|
|
18
|
+
export declare function selectForCleanup(profileData: UserProfileData, includeIds: string[]): IndexedProfile;
|
|
19
|
+
/**
|
|
20
|
+
* Packs each section on its own, sorted by category, so a category of up to `size` items
|
|
21
|
+
* stays in one batch. Likely duplicates share a category, and the model merges only
|
|
22
|
+
* inside a section.
|
|
23
|
+
*/
|
|
24
|
+
export declare function splitIntoBatches(indexed: IndexedProfile, size: number): IndexedProfile[];
|
|
25
|
+
/** One model request for one batch. */
|
|
26
|
+
export declare function cleanupBatch(indexed: IndexedProfile): Promise<BatchCleanupResult>;
|
|
27
|
+
/**
|
|
28
|
+
* Runs the batches one after another and joins their results. A failed batch adds
|
|
29
|
+
* nothing, so its items stay as they are. Throws the first error when every batch
|
|
30
|
+
* fails. Returns null when stopped; the stop flag is read before and after each call.
|
|
31
|
+
*/
|
|
32
|
+
export declare function runInBatches(batches: IndexedProfile[], { callBatch, onProgress, isStopped }?: RunInBatchesOptions): Promise<BatchRunResult | null>;
|
|
33
|
+
/** A fixed reason code for the log. Never the error text, which may quote the reply. */
|
|
34
|
+
export declare function failureReason(error: unknown): string;
|