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.
- package/dist/adapters/opencode/profile-learning.d.ts +0 -1
- package/dist/adapters/opencode/profile-learning.js +9 -35
- package/dist/adapters/pi/profile.js +17 -0
- package/dist/config.d.ts +1 -1
- package/dist/config.js +5 -4
- package/dist/importer/claude-hook-api.js +4 -66
- package/dist/importer/claude-profile-learning.d.ts +7 -0
- package/dist/importer/claude-profile-learning.js +78 -0
- 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/auto-cleanup-handlers.d.ts +22 -0
- package/dist/services/auto-cleanup-handlers.js +71 -0
- package/dist/services/cleanup-service.js +9 -0
- package/dist/services/global-config-writer.js +15 -1
- package/dist/services/language-detector.js +14 -0
- package/dist/services/profile-cleanup-routes.d.ts +6 -0
- package/dist/services/profile-cleanup-routes.js +32 -0
- package/dist/services/request-timeouts.d.ts +2 -0
- package/dist/services/request-timeouts.js +2 -0
- package/dist/services/settings-snapshot.js +5 -0
- package/dist/services/user-profile/ai-cleanup-apply.d.ts +47 -0
- package/dist/services/user-profile/ai-cleanup-apply.js +108 -0
- package/dist/services/user-profile/ai-cleanup-batches.d.ts +35 -0
- package/dist/services/user-profile/ai-cleanup-batches.js +179 -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-undo.d.ts +15 -0
- package/dist/services/user-profile/ai-cleanup-undo.js +63 -0
- package/dist/services/user-profile/ai-cleanup.d.ts +12 -11
- package/dist/services/user-profile/ai-cleanup.js +10 -83
- package/dist/services/user-profile/auto-cleanup-store.d.ts +32 -0
- package/dist/services/user-profile/auto-cleanup-store.js +97 -0
- package/dist/services/user-profile/auto-cleanup-trigger.d.ts +10 -0
- package/dist/services/user-profile/auto-cleanup-trigger.js +12 -0
- package/dist/services/user-profile/auto-cleanup.d.ts +32 -0
- package/dist/services/user-profile/auto-cleanup.js +102 -0
- package/dist/services/user-profile/profile-edit-save.d.ts +35 -0
- package/dist/services/user-profile/profile-edit-save.js +26 -0
- package/dist/services/web-server.js +9 -17
- package/dist/web/assets/index-7385lY75.css +2 -0
- package/dist/web/assets/index-C9y78EH_.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,35 @@
|
|
|
1
|
+
import type { ModelPort } from "../../core/profile-analysis.js";
|
|
2
|
+
import type { UserProfileData } from "./types.js";
|
|
3
|
+
import { type CleanupDiff, type IndexedProfile } from "./ai-cleanup.js";
|
|
4
|
+
/** What one batch adds to the review: its changes, and the merged item of each merge. */
|
|
5
|
+
export interface BatchCleanupResult {
|
|
6
|
+
diff: CleanupDiff;
|
|
7
|
+
mergedItems: Record<string, unknown>;
|
|
8
|
+
}
|
|
9
|
+
export interface BatchRunResult extends BatchCleanupResult {
|
|
10
|
+
failedBatches: number;
|
|
11
|
+
failedItems: number;
|
|
12
|
+
}
|
|
13
|
+
export interface RunInBatchesOptions {
|
|
14
|
+
callBatch?: (batch: IndexedProfile) => Promise<BatchCleanupResult>;
|
|
15
|
+
onProgress?: (done: number, total: number, failed: number) => void;
|
|
16
|
+
isStopped?: () => boolean;
|
|
17
|
+
}
|
|
18
|
+
/** Indexes the full profile once, so ids are profile positions, and keeps the selected items. */
|
|
19
|
+
export declare function selectForCleanup(profileData: UserProfileData, includeIds: string[]): IndexedProfile;
|
|
20
|
+
/**
|
|
21
|
+
* Packs each section on its own, sorted by category, so a category of up to `size` items
|
|
22
|
+
* stays in one batch. Likely duplicates share a category, and the model merges only
|
|
23
|
+
* inside a section.
|
|
24
|
+
*/
|
|
25
|
+
export declare function splitIntoBatches(indexed: IndexedProfile, size: number): IndexedProfile[];
|
|
26
|
+
/** One model request for one batch. A caller with its own model passes it. */
|
|
27
|
+
export declare function cleanupBatch(indexed: IndexedProfile, model?: ModelPort | null): Promise<BatchCleanupResult>;
|
|
28
|
+
/**
|
|
29
|
+
* Runs the batches one after another and joins their results. A failed batch adds
|
|
30
|
+
* nothing, so its items stay as they are. Throws the first error when every batch
|
|
31
|
+
* fails. Returns null when stopped; the stop flag is read before and after each call.
|
|
32
|
+
*/
|
|
33
|
+
export declare function runInBatches(batches: IndexedProfile[], { callBatch, onProgress, isStopped }?: RunInBatchesOptions): Promise<BatchRunResult | null>;
|
|
34
|
+
/** A fixed reason code for the log. Never the error text, which may quote the reply. */
|
|
35
|
+
export declare function failureReason(error: unknown): string;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { log } from "../logger.js";
|
|
2
|
+
import { addIdsToProfile, buildAICleanupPrompt, buildItemIndex, callAICleanup, generateDiff, rebuildProfileUsing, } from "./ai-cleanup.js";
|
|
3
|
+
/**
|
|
4
|
+
* A large selection sent in one model request runs past the request time limit. The web
|
|
5
|
+
* cleanup therefore sends the selection in batches, one request each, one after another.
|
|
6
|
+
*/
|
|
7
|
+
const SECTIONS = ["preferences", "patterns", "workflows"];
|
|
8
|
+
/** Indexes the full profile once, so ids are profile positions, and keeps the selected items. */
|
|
9
|
+
export function selectForCleanup(profileData, includeIds) {
|
|
10
|
+
const idSet = new Set(includeIds);
|
|
11
|
+
const indexed = addIdsToProfile(profileData);
|
|
12
|
+
return {
|
|
13
|
+
preferences: indexed.preferences.filter((p) => idSet.has(p.id)),
|
|
14
|
+
patterns: indexed.patterns.filter((p) => idSet.has(p.id)),
|
|
15
|
+
workflows: indexed.workflows.filter((p) => idSet.has(p.id)),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Packs each section on its own, sorted by category, so a category of up to `size` items
|
|
20
|
+
* stays in one batch. Likely duplicates share a category, and the model merges only
|
|
21
|
+
* inside a section.
|
|
22
|
+
*/
|
|
23
|
+
export function splitIntoBatches(indexed, size) {
|
|
24
|
+
const batches = [];
|
|
25
|
+
for (const section of SECTIONS) {
|
|
26
|
+
const flush = (items) => {
|
|
27
|
+
if (items.length > 0)
|
|
28
|
+
batches.push({ preferences: [], patterns: [], workflows: [], [section]: items });
|
|
29
|
+
};
|
|
30
|
+
let current = [];
|
|
31
|
+
for (const group of groupByCategory(indexed[section])) {
|
|
32
|
+
if (group.length > size) {
|
|
33
|
+
flush(current);
|
|
34
|
+
current = [];
|
|
35
|
+
for (let i = 0; i < group.length; i += size) {
|
|
36
|
+
const chunk = group.slice(i, i + size);
|
|
37
|
+
if (chunk.length === size)
|
|
38
|
+
flush(chunk);
|
|
39
|
+
else
|
|
40
|
+
current = chunk;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
else if (current.length + group.length > size) {
|
|
44
|
+
flush(current);
|
|
45
|
+
current = [...group];
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
current.push(...group);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
flush(current);
|
|
52
|
+
}
|
|
53
|
+
return batches;
|
|
54
|
+
}
|
|
55
|
+
function groupByCategory(items) {
|
|
56
|
+
const groups = new Map();
|
|
57
|
+
for (const item of items) {
|
|
58
|
+
const key = (item.category ?? "").toLowerCase();
|
|
59
|
+
const group = groups.get(key);
|
|
60
|
+
if (group)
|
|
61
|
+
group.push(item);
|
|
62
|
+
else
|
|
63
|
+
groups.set(key, [item]);
|
|
64
|
+
}
|
|
65
|
+
return [...groups.keys()].sort().map((key) => groups.get(key));
|
|
66
|
+
}
|
|
67
|
+
/** One model request for one batch. A caller with its own model passes it. */
|
|
68
|
+
export async function cleanupBatch(indexed, model) {
|
|
69
|
+
const t0 = Date.now();
|
|
70
|
+
const prompt = buildAICleanupPrompt(indexed);
|
|
71
|
+
log("AI cleanup: batch prompt built", { items: countItems(indexed), promptLen: prompt.length });
|
|
72
|
+
const result = await callAICleanup(prompt, model);
|
|
73
|
+
log("AI cleanup: batch reply received", { callMs: Date.now() - t0 });
|
|
74
|
+
const cleanedById = buildItemIndex(result.profile);
|
|
75
|
+
const originalById = buildItemIndex(indexed);
|
|
76
|
+
const mapping = restrictToBatch(result.mapping, originalById);
|
|
77
|
+
return {
|
|
78
|
+
diff: generateDiff(indexed, mapping, cleanedById),
|
|
79
|
+
mergedItems: buildMergedItems(mapping, cleanedById, originalById),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// The model may return an id that is not in the batch. Apply looks ids up in the whole
|
|
83
|
+
// profile, so such an id could change an item the user never selected.
|
|
84
|
+
function restrictToBatch(mapping, originalById) {
|
|
85
|
+
const inBatch = (id) => originalById.has(id);
|
|
86
|
+
return {
|
|
87
|
+
kept: mapping.kept.filter(inBatch),
|
|
88
|
+
merged: mapping.merged.map((group) => group.filter(inBatch)).filter((g) => g.length > 1),
|
|
89
|
+
removed: mapping.removed.filter(inBatch),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
// The merged item of each group, keyed by its target id, so Apply can put a ticked merge
|
|
93
|
+
// into a newer profile without the rest of the cleaned batch.
|
|
94
|
+
function buildMergedItems(mapping, cleanedById, originalById) {
|
|
95
|
+
const items = {};
|
|
96
|
+
for (const group of mapping.merged.filter((g) => g.length > 1)) {
|
|
97
|
+
const originals = new Map();
|
|
98
|
+
for (const id of group) {
|
|
99
|
+
const original = originalById.get(id);
|
|
100
|
+
if (original)
|
|
101
|
+
originals.set(id, original);
|
|
102
|
+
}
|
|
103
|
+
const one = rebuildProfileUsing({ kept: [], merged: [group], removed: [] }, cleanedById, originals);
|
|
104
|
+
const item = one.preferences[0] ?? one.patterns[0] ?? one.workflows[0];
|
|
105
|
+
if (item)
|
|
106
|
+
items[group[0]] = item;
|
|
107
|
+
}
|
|
108
|
+
return items;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Runs the batches one after another and joins their results. A failed batch adds
|
|
112
|
+
* nothing, so its items stay as they are. Throws the first error when every batch
|
|
113
|
+
* fails. Returns null when stopped; the stop flag is read before and after each call.
|
|
114
|
+
*/
|
|
115
|
+
export async function runInBatches(batches, { callBatch = cleanupBatch, onProgress, isStopped = () => false } = {}) {
|
|
116
|
+
const t0 = Date.now();
|
|
117
|
+
const joined = {
|
|
118
|
+
diff: { kept: [], merged: [], removed: [] },
|
|
119
|
+
mergedItems: {},
|
|
120
|
+
failedBatches: 0,
|
|
121
|
+
failedItems: 0,
|
|
122
|
+
};
|
|
123
|
+
let firstError;
|
|
124
|
+
for (const [index, batch] of batches.entries()) {
|
|
125
|
+
if (isStopped())
|
|
126
|
+
return null;
|
|
127
|
+
try {
|
|
128
|
+
const result = await callBatch(batch);
|
|
129
|
+
if (isStopped())
|
|
130
|
+
return null;
|
|
131
|
+
joined.diff.kept.push(...result.diff.kept);
|
|
132
|
+
joined.diff.merged.push(...result.diff.merged);
|
|
133
|
+
joined.diff.removed.push(...result.diff.removed);
|
|
134
|
+
Object.assign(joined.mergedItems, result.mergedItems);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
if (isStopped())
|
|
138
|
+
return null;
|
|
139
|
+
if (joined.failedBatches === 0)
|
|
140
|
+
firstError = error;
|
|
141
|
+
joined.failedBatches++;
|
|
142
|
+
joined.failedItems += countItems(batch);
|
|
143
|
+
log("AI cleanup: batch failed", {
|
|
144
|
+
batch: index + 1,
|
|
145
|
+
of: batches.length,
|
|
146
|
+
items: countItems(batch),
|
|
147
|
+
reason: failureReason(error),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
onProgress?.(index + 1, batches.length, joined.failedBatches);
|
|
151
|
+
}
|
|
152
|
+
if (batches.length > 0 && joined.failedBatches === batches.length)
|
|
153
|
+
throw firstError;
|
|
154
|
+
log("AI cleanup: batches finished", {
|
|
155
|
+
batches: batches.length,
|
|
156
|
+
failedBatches: joined.failedBatches,
|
|
157
|
+
totalMs: Date.now() - t0,
|
|
158
|
+
});
|
|
159
|
+
return joined;
|
|
160
|
+
}
|
|
161
|
+
/** A fixed reason code for the log. Never the error text, which may quote the reply. */
|
|
162
|
+
export function failureReason(error) {
|
|
163
|
+
if (error instanceof Error) {
|
|
164
|
+
if (error.name === "TimeoutError" || error.name === "AbortError")
|
|
165
|
+
return "timeout";
|
|
166
|
+
const status = /API request failed: (\d{3})/.exec(error.message)?.[1];
|
|
167
|
+
if (status)
|
|
168
|
+
return `http-${status}`;
|
|
169
|
+
if (error instanceof SyntaxError || /valid JSON|No content/.test(error.message)) {
|
|
170
|
+
return "invalid-reply";
|
|
171
|
+
}
|
|
172
|
+
if (/No AI provider configured/.test(error.message))
|
|
173
|
+
return "no-provider";
|
|
174
|
+
}
|
|
175
|
+
return "error";
|
|
176
|
+
}
|
|
177
|
+
function countItems(indexed) {
|
|
178
|
+
return indexed.preferences.length + indexed.patterns.length + indexed.workflows.length;
|
|
179
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { CleanupDiff } from "./ai-cleanup.js";
|
|
2
|
+
import type { CleanupJobResult } from "./ai-cleanup-apply.js";
|
|
3
|
+
import { type RunInBatchesOptions } from "./ai-cleanup-batches.js";
|
|
4
|
+
import type { UserProfileData } from "./types.js";
|
|
5
|
+
/**
|
|
6
|
+
* A large AI cleanup takes many minutes, longer than a web request may stay open. The web
|
|
7
|
+
* app therefore runs it as a background job per user, and the dialog polls its state.
|
|
8
|
+
* The job also holds the finished result until Apply. Jobs live in this process only; a
|
|
9
|
+
* restart drops them, and nothing is saved before Apply.
|
|
10
|
+
*/
|
|
11
|
+
export type AiCleanupJobStatus = {
|
|
12
|
+
state: "running";
|
|
13
|
+
done: number;
|
|
14
|
+
total: number;
|
|
15
|
+
failed: number;
|
|
16
|
+
} | {
|
|
17
|
+
state: "done";
|
|
18
|
+
result: {
|
|
19
|
+
old: UserProfileData;
|
|
20
|
+
changes: CleanupDiff;
|
|
21
|
+
failedBatches: number;
|
|
22
|
+
failedItems: number;
|
|
23
|
+
};
|
|
24
|
+
} | {
|
|
25
|
+
state: "failed";
|
|
26
|
+
error: string;
|
|
27
|
+
};
|
|
28
|
+
export interface AiCleanupJobView {
|
|
29
|
+
id: string;
|
|
30
|
+
startedAt: number;
|
|
31
|
+
selection: string[];
|
|
32
|
+
status: AiCleanupJobStatus;
|
|
33
|
+
}
|
|
34
|
+
export interface AiCleanupJobInput {
|
|
35
|
+
/** The profile as the dialog lists it, so item ids match. */
|
|
36
|
+
profileData: UserProfileData;
|
|
37
|
+
/** An empty or missing selection means every item. */
|
|
38
|
+
includeIds?: string[];
|
|
39
|
+
}
|
|
40
|
+
export interface AiCleanupJobDeps {
|
|
41
|
+
callBatch?: RunInBatchesOptions["callBatch"];
|
|
42
|
+
batchSize?: number;
|
|
43
|
+
now?: () => number;
|
|
44
|
+
}
|
|
45
|
+
/** Starts a cleanup job for the user, or returns the one already running. */
|
|
46
|
+
export declare function startAiCleanupJob(userId: string, input: AiCleanupJobInput, deps?: AiCleanupJobDeps): AiCleanupJobView;
|
|
47
|
+
/** The user's job, or null when none was started or a finished one expired. */
|
|
48
|
+
export declare function getAiCleanupJob(userId: string, now?: number): AiCleanupJobView | null;
|
|
49
|
+
/** The finished result that Apply needs, or null when the job is not done. */
|
|
50
|
+
export declare function getAiCleanupResult(userId: string, now?: number): CleanupJobResult | null;
|
|
51
|
+
/** Stops a running job, or discards a finished one. A stopped job writes nothing. */
|
|
52
|
+
export declare function stopAiCleanupJob(userId: string): void;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { log } from "../logger.js";
|
|
2
|
+
import { AI_CLEANUP_BATCH_SIZE } from "../request-timeouts.js";
|
|
3
|
+
import { failureReason, runInBatches, selectForCleanup, splitIntoBatches, } from "./ai-cleanup-batches.js";
|
|
4
|
+
/** A finished job is forgotten this long after it finished, as the old pending result was. */
|
|
5
|
+
const FINISHED_JOB_TTL_MS = 30 * 60 * 1000;
|
|
6
|
+
const jobs = new Map();
|
|
7
|
+
let nextId = 1;
|
|
8
|
+
/** Starts a cleanup job for the user, or returns the one already running. */
|
|
9
|
+
export function startAiCleanupJob(userId, input, deps = {}) {
|
|
10
|
+
const current = jobs.get(userId);
|
|
11
|
+
if (current?.status.state === "running")
|
|
12
|
+
return view(current);
|
|
13
|
+
const now = deps.now ?? Date.now;
|
|
14
|
+
const selection = input.includeIds?.length ? input.includeIds : allIds(input.profileData);
|
|
15
|
+
const batches = splitIntoBatches(selectForCleanup(input.profileData, selection), deps.batchSize ?? AI_CLEANUP_BATCH_SIZE);
|
|
16
|
+
const job = {
|
|
17
|
+
id: String(nextId++),
|
|
18
|
+
startedAt: now(),
|
|
19
|
+
selection,
|
|
20
|
+
status: { state: "running", done: 0, total: batches.length, failed: 0 },
|
|
21
|
+
stopped: false,
|
|
22
|
+
mergedItems: {},
|
|
23
|
+
};
|
|
24
|
+
jobs.set(userId, job);
|
|
25
|
+
void (async () => {
|
|
26
|
+
try {
|
|
27
|
+
const result = await runInBatches(batches, {
|
|
28
|
+
callBatch: deps.callBatch,
|
|
29
|
+
isStopped: () => job.stopped,
|
|
30
|
+
onProgress: (done, total, failed) => {
|
|
31
|
+
job.status = { state: "running", done, total, failed };
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
if (!result)
|
|
35
|
+
return;
|
|
36
|
+
job.mergedItems = result.mergedItems;
|
|
37
|
+
job.status = {
|
|
38
|
+
state: "done",
|
|
39
|
+
result: {
|
|
40
|
+
old: input.profileData,
|
|
41
|
+
changes: result.diff,
|
|
42
|
+
failedBatches: result.failedBatches,
|
|
43
|
+
failedItems: result.failedItems,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
if (job.stopped)
|
|
49
|
+
return;
|
|
50
|
+
const reason = failureReason(error);
|
|
51
|
+
log("AI cleanup job: failed", { reason });
|
|
52
|
+
job.status = {
|
|
53
|
+
state: "failed",
|
|
54
|
+
error: reason === "timeout"
|
|
55
|
+
? "The model did not answer in time."
|
|
56
|
+
: error instanceof Error
|
|
57
|
+
? error.message
|
|
58
|
+
: String(error),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
job.finishedAt = now();
|
|
63
|
+
}
|
|
64
|
+
})();
|
|
65
|
+
return view(job);
|
|
66
|
+
}
|
|
67
|
+
/** The user's job, or null when none was started or a finished one expired. */
|
|
68
|
+
export function getAiCleanupJob(userId, now = Date.now()) {
|
|
69
|
+
const job = liveJob(userId, now);
|
|
70
|
+
return job ? view(job) : null;
|
|
71
|
+
}
|
|
72
|
+
/** The finished result that Apply needs, or null when the job is not done. */
|
|
73
|
+
export function getAiCleanupResult(userId, now = Date.now()) {
|
|
74
|
+
const job = liveJob(userId, now);
|
|
75
|
+
if (job?.status.state !== "done")
|
|
76
|
+
return null;
|
|
77
|
+
return {
|
|
78
|
+
old: job.status.result.old,
|
|
79
|
+
changes: job.status.result.changes,
|
|
80
|
+
mergedItems: job.mergedItems,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** Stops a running job, or discards a finished one. A stopped job writes nothing. */
|
|
84
|
+
export function stopAiCleanupJob(userId) {
|
|
85
|
+
const job = jobs.get(userId);
|
|
86
|
+
if (!job)
|
|
87
|
+
return;
|
|
88
|
+
job.stopped = true;
|
|
89
|
+
jobs.delete(userId);
|
|
90
|
+
}
|
|
91
|
+
function liveJob(userId, now) {
|
|
92
|
+
const job = jobs.get(userId);
|
|
93
|
+
if (!job)
|
|
94
|
+
return null;
|
|
95
|
+
if (job.status.state !== "running" && job.finishedAt !== undefined) {
|
|
96
|
+
if (now - job.finishedAt > FINISHED_JOB_TTL_MS) {
|
|
97
|
+
jobs.delete(userId);
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return job;
|
|
102
|
+
}
|
|
103
|
+
function view(job) {
|
|
104
|
+
return { id: job.id, startedAt: job.startedAt, selection: job.selection, status: job.status };
|
|
105
|
+
}
|
|
106
|
+
function allIds(profile) {
|
|
107
|
+
return [
|
|
108
|
+
...profile.preferences.map((_, i) => `pref_${i}`),
|
|
109
|
+
...profile.patterns.map((_, i) => `pat_${i}`),
|
|
110
|
+
...profile.workflows.map((_, i) => `wf_${i}`),
|
|
111
|
+
];
|
|
112
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { RestoreEntry } from "./ai-cleanup-apply.js";
|
|
2
|
+
import type { UserProfileData } from "./types.js";
|
|
3
|
+
export interface UndoResult {
|
|
4
|
+
profile: UserProfileData;
|
|
5
|
+
restored: number;
|
|
6
|
+
notFound: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Reverses the changes of one automatic cleanup in `current`, which may be newer than the
|
|
10
|
+
* profile the run saved. A merge is undone by taking out the merged item and putting its
|
|
11
|
+
* originals back. A removal is undone by putting the original back. A change counts as
|
|
12
|
+
* not found, and is left alone, when the merged item is gone or was reworded, or when an
|
|
13
|
+
* original is already in the profile. Every other item stays as stored.
|
|
14
|
+
*/
|
|
15
|
+
export declare function undoCleanupEdits(current: UserProfileData, entries: RestoreEntry[]): UndoResult;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { profileItemIdentityKey } from "./ai-cleanup-apply.js";
|
|
2
|
+
const SECTIONS = ["preferences", "patterns", "workflows"];
|
|
3
|
+
function keyOf(section, item) {
|
|
4
|
+
return `${section}|${profileItemIdentityKey(item)}`;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Reverses the changes of one automatic cleanup in `current`, which may be newer than the
|
|
8
|
+
* profile the run saved. A merge is undone by taking out the merged item and putting its
|
|
9
|
+
* originals back. A removal is undone by putting the original back. A change counts as
|
|
10
|
+
* not found, and is left alone, when the merged item is gone or was reworded, or when an
|
|
11
|
+
* original is already in the profile. Every other item stays as stored.
|
|
12
|
+
*/
|
|
13
|
+
export function undoCleanupEdits(current, entries) {
|
|
14
|
+
const profile = {
|
|
15
|
+
...current,
|
|
16
|
+
preferences: [...current.preferences],
|
|
17
|
+
patterns: [...current.patterns],
|
|
18
|
+
workflows: [...current.workflows],
|
|
19
|
+
};
|
|
20
|
+
// How many items of each identity the profile holds now. A restored item is not added,
|
|
21
|
+
// so two identical originals of the record both come back.
|
|
22
|
+
const present = new Map();
|
|
23
|
+
for (const section of SECTIONS) {
|
|
24
|
+
for (const item of profile[section]) {
|
|
25
|
+
const key = keyOf(section, item);
|
|
26
|
+
present.set(key, (present.get(key) ?? 0) + 1);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const claimed = new Set();
|
|
30
|
+
let restored = 0;
|
|
31
|
+
let notFound = 0;
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
const items = profile[entry.section];
|
|
34
|
+
if (entry.kind === "remove") {
|
|
35
|
+
if ((present.get(keyOf(entry.section, entry.original)) ?? 0) > 0) {
|
|
36
|
+
notFound++;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const back = { ...entry.original };
|
|
40
|
+
items.push(back);
|
|
41
|
+
claimed.add(back);
|
|
42
|
+
restored++;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const mergedKey = keyOf(entry.section, entry.merged);
|
|
46
|
+
const index = items.findIndex((item) => !claimed.has(item) && keyOf(entry.section, item) === mergedKey);
|
|
47
|
+
const blocked = entry.originals.some((original) => {
|
|
48
|
+
const key = keyOf(entry.section, original);
|
|
49
|
+
return (present.get(key) ?? 0) - (key === mergedKey ? 1 : 0) > 0;
|
|
50
|
+
});
|
|
51
|
+
if (index < 0 || blocked) {
|
|
52
|
+
notFound++;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const back = entry.originals.map((original) => ({ ...original }));
|
|
56
|
+
items.splice(index, 1, ...back);
|
|
57
|
+
for (const item of back)
|
|
58
|
+
claimed.add(item);
|
|
59
|
+
present.set(mergedKey, (present.get(mergedKey) ?? 1) - 1);
|
|
60
|
+
restored++;
|
|
61
|
+
}
|
|
62
|
+
return { profile, restored, notFound };
|
|
63
|
+
}
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
import type { UserProfileData } from "./types.js";
|
|
2
|
-
|
|
3
|
-
cleaned: UserProfileData;
|
|
4
|
-
diff: CleanupDiff;
|
|
5
|
-
}
|
|
2
|
+
import type { ModelPort } from "../../core/profile-analysis.js";
|
|
6
3
|
export interface CleanupDiff {
|
|
7
4
|
kept: string[];
|
|
8
5
|
merged: Array<{
|
|
@@ -14,10 +11,7 @@ export interface CleanupDiff {
|
|
|
14
11
|
reason: string;
|
|
15
12
|
}>;
|
|
16
13
|
}
|
|
17
|
-
export
|
|
18
|
-
export declare function aiCleanupProfileFromIndexed(indexed: IndexedProfile): Promise<AICleanupResult>;
|
|
19
|
-
export declare function filterProfileForCleanup(profileData: UserProfileData, includeIds: string[]): IndexedProfile;
|
|
20
|
-
interface IndexedProfileItem {
|
|
14
|
+
export interface IndexedProfileItem {
|
|
21
15
|
id: string;
|
|
22
16
|
category?: string;
|
|
23
17
|
description: string;
|
|
@@ -25,18 +19,25 @@ interface IndexedProfileItem {
|
|
|
25
19
|
frequency?: number;
|
|
26
20
|
[key: string]: unknown;
|
|
27
21
|
}
|
|
28
|
-
interface IndexedProfile {
|
|
22
|
+
export interface IndexedProfile {
|
|
29
23
|
preferences: IndexedProfileItem[];
|
|
30
24
|
patterns: IndexedProfileItem[];
|
|
31
25
|
workflows: IndexedProfileItem[];
|
|
32
26
|
}
|
|
33
|
-
interface AIMapping {
|
|
27
|
+
export interface AIMapping {
|
|
34
28
|
kept: string[];
|
|
35
29
|
merged: string[][];
|
|
36
30
|
removed: string[];
|
|
37
31
|
}
|
|
32
|
+
export declare function addIdsToProfile(profile: UserProfileData): IndexedProfile;
|
|
33
|
+
export declare function buildAICleanupPrompt(profile: IndexedProfile): string;
|
|
34
|
+
export declare function callAICleanup(prompt: string, model?: ModelPort | null): Promise<{
|
|
35
|
+
profile: IndexedProfile;
|
|
36
|
+
mapping: AIMapping;
|
|
37
|
+
}>;
|
|
38
|
+
export declare function buildItemIndex(profile: IndexedProfile): Map<string, IndexedProfileItem>;
|
|
38
39
|
export declare function rebuildProfileUsing(mapping: AIMapping, cleanedById: Map<string, IndexedProfileItem>, originalById: Map<string, IndexedProfileItem>, counters?: {
|
|
39
40
|
cleaned: number;
|
|
40
41
|
original: number;
|
|
41
42
|
}): UserProfileData;
|
|
42
|
-
export
|
|
43
|
+
export declare function generateDiff(original: IndexedProfile, mapping: AIMapping, cleanedById: Map<string, IndexedProfileItem>): CleanupDiff;
|
|
@@ -3,81 +3,6 @@ import { log } from "../logger.js";
|
|
|
3
3
|
import { resolveHostProfileModel } from "./profile-model.js";
|
|
4
4
|
import { EXTERNAL_PROFILE_CLEANUP_TIMEOUT_MS } from "../request-timeouts.js";
|
|
5
5
|
import { applySafeExtraParams } from "../ai/providers/base-provider.js";
|
|
6
|
-
export async function aiCleanupProfile(profileData) {
|
|
7
|
-
const t0 = Date.now();
|
|
8
|
-
const indexed = addIdsToProfile(profileData);
|
|
9
|
-
const prompt = buildAICleanupPrompt(indexed);
|
|
10
|
-
log("AI cleanup: prompt built", {
|
|
11
|
-
prefCount: indexed.preferences.length,
|
|
12
|
-
patCount: indexed.patterns.length,
|
|
13
|
-
wfCount: indexed.workflows.length,
|
|
14
|
-
promptLen: prompt.length,
|
|
15
|
-
buildMs: Date.now() - t0,
|
|
16
|
-
});
|
|
17
|
-
const aiStart = Date.now();
|
|
18
|
-
const result = await callAICleanup(prompt);
|
|
19
|
-
log("AI cleanup: AI response received", { callMs: Date.now() - aiStart });
|
|
20
|
-
const cleanedById = buildItemIndex(result.profile);
|
|
21
|
-
const originalById = buildItemIndex(indexed);
|
|
22
|
-
const counters = { cleaned: 0, original: 0 };
|
|
23
|
-
const cleaned = rebuildProfileUsing(result.mapping, cleanedById, originalById, counters);
|
|
24
|
-
const diff = generateDiff(indexed, result.mapping, cleanedById);
|
|
25
|
-
const sampleCleaned = result.profile.preferences[0];
|
|
26
|
-
log("AI cleanup: rebuild done", {
|
|
27
|
-
cleanedPrefCount: result.profile.preferences.length,
|
|
28
|
-
cleanedPatCount: result.profile.patterns.length,
|
|
29
|
-
cleanedWfCount: result.profile.workflows.length,
|
|
30
|
-
sampleId: sampleCleaned?.id,
|
|
31
|
-
sampleDescLen: sampleCleaned?.description?.length,
|
|
32
|
-
cleanedItemDescLen: cleanedById.get("pref_0")?.description?.length,
|
|
33
|
-
originalItemDescLen: originalById.get("pref_0")?.description?.length,
|
|
34
|
-
keptById: cleaned.preferences.length,
|
|
35
|
-
usedCleaned: counters.cleaned,
|
|
36
|
-
usedOriginal: counters.original,
|
|
37
|
-
});
|
|
38
|
-
log("AI cleanup: complete", {
|
|
39
|
-
totalMs: Date.now() - t0,
|
|
40
|
-
kept: diff.kept.length,
|
|
41
|
-
merged: diff.merged.length,
|
|
42
|
-
removed: diff.removed.length,
|
|
43
|
-
});
|
|
44
|
-
return { cleaned, diff };
|
|
45
|
-
}
|
|
46
|
-
export async function aiCleanupProfileFromIndexed(indexed) {
|
|
47
|
-
const t0 = Date.now();
|
|
48
|
-
const prompt = buildAICleanupPrompt(indexed);
|
|
49
|
-
log("AI cleanup: prompt built (filtered)", {
|
|
50
|
-
prefCount: indexed.preferences.length,
|
|
51
|
-
patCount: indexed.patterns.length,
|
|
52
|
-
wfCount: indexed.workflows.length,
|
|
53
|
-
promptLen: prompt.length,
|
|
54
|
-
buildMs: Date.now() - t0,
|
|
55
|
-
});
|
|
56
|
-
const aiStart = Date.now();
|
|
57
|
-
const result = await callAICleanup(prompt);
|
|
58
|
-
log("AI cleanup: AI response received (filtered)", { callMs: Date.now() - aiStart });
|
|
59
|
-
const cleanedById = buildItemIndex(result.profile);
|
|
60
|
-
const originalById = buildItemIndex(indexed);
|
|
61
|
-
const counters = { cleaned: 0, original: 0 };
|
|
62
|
-
const cleaned = rebuildProfileUsing(result.mapping, cleanedById, originalById, counters);
|
|
63
|
-
const diff = generateDiff(indexed, result.mapping, cleanedById);
|
|
64
|
-
log("AI cleanup: complete (filtered)", {
|
|
65
|
-
totalMs: Date.now() - t0,
|
|
66
|
-
kept: diff.kept.length,
|
|
67
|
-
merged: diff.merged.length,
|
|
68
|
-
removed: diff.removed.length,
|
|
69
|
-
});
|
|
70
|
-
return { cleaned, diff };
|
|
71
|
-
}
|
|
72
|
-
export function filterProfileForCleanup(profileData, includeIds) {
|
|
73
|
-
const idSet = new Set(includeIds);
|
|
74
|
-
const indexed = addIdsToProfile(profileData);
|
|
75
|
-
return {
|
|
76
|
-
preferences: indexed.preferences.filter((p) => idSet.has(p.id)),
|
|
77
|
-
patterns: indexed.patterns.filter((p) => idSet.has(p.id)),
|
|
78
|
-
workflows: indexed.workflows.filter((p) => idSet.has(p.id)),
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
6
|
// The model controls this JSON; it may omit fields or return wrong types. Coerce to a
|
|
82
7
|
// well-formed AIMapping so rebuildProfileUsing/generateDiff never call .map/.filter/.includes
|
|
83
8
|
// on undefined. A missing or malformed mapping degrades to a no-op cleanup (all originals are
|
|
@@ -100,7 +25,7 @@ function normalizeAIMapping(raw) {
|
|
|
100
25
|
const removed = Array.isArray(raw.removed) ? raw.removed.filter(isStr) : [];
|
|
101
26
|
return { kept, merged, removed };
|
|
102
27
|
}
|
|
103
|
-
function addIdsToProfile(profile) {
|
|
28
|
+
export function addIdsToProfile(profile) {
|
|
104
29
|
const items = {
|
|
105
30
|
preferences: profile.preferences.map((p, i) => ({ ...p, id: `pref_${i}` })),
|
|
106
31
|
patterns: profile.patterns.map((p, i) => ({ ...p, id: `pat_${i}` })),
|
|
@@ -108,7 +33,7 @@ function addIdsToProfile(profile) {
|
|
|
108
33
|
};
|
|
109
34
|
return items;
|
|
110
35
|
}
|
|
111
|
-
function buildAICleanupPrompt(profile) {
|
|
36
|
+
export function buildAICleanupPrompt(profile) {
|
|
112
37
|
const profileJSON = JSON.stringify({
|
|
113
38
|
preferences: profile.preferences.map(formatForAI),
|
|
114
39
|
patterns: profile.patterns.map(formatForAI),
|
|
@@ -147,10 +72,12 @@ function formatForAI(item) {
|
|
|
147
72
|
const { id, category, description, frequency } = item;
|
|
148
73
|
return { id, category, description, frequency };
|
|
149
74
|
}
|
|
150
|
-
async function callAICleanup(prompt) {
|
|
151
|
-
//
|
|
152
|
-
// When it is available, surface its
|
|
153
|
-
// "No AI provider configured" via a silent fallback (#177).
|
|
75
|
+
export async function callAICleanup(prompt, model) {
|
|
76
|
+
// A caller that has its own model (Pi) passes it. Otherwise use the host model when the
|
|
77
|
+
// host registered one (same pattern as auto-capture). When it is available, surface its
|
|
78
|
+
// errors instead of masking them as "No AI provider configured" via a silent fallback (#177).
|
|
79
|
+
if (model)
|
|
80
|
+
return callViaHostModel(model, prompt);
|
|
154
81
|
const hostModel = await resolveHostProfileModel();
|
|
155
82
|
if (hostModel) {
|
|
156
83
|
return callViaHostModel(hostModel, prompt);
|
|
@@ -215,7 +142,7 @@ async function callViaHostModel(model, prompt) {
|
|
|
215
142
|
mapping: normalizeAIMapping(parsed.mapping),
|
|
216
143
|
};
|
|
217
144
|
}
|
|
218
|
-
function buildItemIndex(profile) {
|
|
145
|
+
export function buildItemIndex(profile) {
|
|
219
146
|
const map = new Map();
|
|
220
147
|
for (const item of profile.preferences)
|
|
221
148
|
map.set(item.id, item);
|
|
@@ -393,7 +320,7 @@ export function rebuildProfileUsing(mapping, cleanedById, originalById, counters
|
|
|
393
320
|
}
|
|
394
321
|
return result;
|
|
395
322
|
}
|
|
396
|
-
function generateDiff(original, mapping, cleanedById) {
|
|
323
|
+
export function generateDiff(original, mapping, cleanedById) {
|
|
397
324
|
const index = buildItemIndex(original);
|
|
398
325
|
const diff = {
|
|
399
326
|
kept: mapping.kept.map((id) => index.get(id)?.description || id),
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { RestoreEntry } from "./ai-cleanup-apply.js";
|
|
2
|
+
/** A record can be undone for this long, then it is deleted. */
|
|
3
|
+
export declare const AUTO_CLEANUP_RECORD_MAX_AGE_MS: number;
|
|
4
|
+
export interface AutoCleanupRecordInput {
|
|
5
|
+
promptsAnalyzed: number;
|
|
6
|
+
merged: number;
|
|
7
|
+
removed: number;
|
|
8
|
+
entries: RestoreEntry[];
|
|
9
|
+
}
|
|
10
|
+
export interface AutoCleanupSummary {
|
|
11
|
+
id: number;
|
|
12
|
+
createdAt: number;
|
|
13
|
+
merged: number;
|
|
14
|
+
removed: number;
|
|
15
|
+
}
|
|
16
|
+
export interface AutoCleanupRecord extends AutoCleanupSummary {
|
|
17
|
+
promptsAnalyzed: number;
|
|
18
|
+
entries: RestoreEntry[];
|
|
19
|
+
}
|
|
20
|
+
export declare function insertAutoCleanupRecord(profileId: string, record: AutoCleanupRecordInput, now?: number): Promise<number>;
|
|
21
|
+
/**
|
|
22
|
+
* Deletes every record older than 30 days, for all profiles. The daily cleanup and the web
|
|
23
|
+
* app start call it, so a record does not outlive its limit when nothing else touches the
|
|
24
|
+
* table. Returns how many rows it deleted.
|
|
25
|
+
*/
|
|
26
|
+
export declare function pruneAutoCleanupRecords(now?: number): Promise<number>;
|
|
27
|
+
export declare function deleteAutoCleanup(id: number): Promise<void>;
|
|
28
|
+
/** The newest record of the profile that is not undone, without its items. */
|
|
29
|
+
export declare function latestOpenAutoCleanupSummary(profileId: string, now?: number): Promise<AutoCleanupSummary | null>;
|
|
30
|
+
/** The newest record of the profile that is not undone, with the items to restore. */
|
|
31
|
+
export declare function latestOpenAutoCleanup(profileId: string, now?: number): Promise<AutoCleanupRecord | null>;
|
|
32
|
+
export declare function markAutoCleanupUndone(id: number, now?: number): Promise<void>;
|