om-memory-system 4.14.1 → 4.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/adapters/opencode/profile-learning.js +6 -25
  2. package/dist/cli/index.js +22 -0
  3. package/dist/cli/profile-catch-up-command.js +12 -0
  4. package/dist/cli/profile-cleanup-categories-command.d.ts +3 -0
  5. package/dist/cli/profile-cleanup-categories-command.js +92 -0
  6. package/dist/cli/profile-normalise-categories-command.d.ts +2 -0
  7. package/dist/cli/profile-normalise-categories-command.js +85 -0
  8. package/dist/core/extraction.d.ts +15 -0
  9. package/dist/core/extraction.js +36 -0
  10. package/dist/core/profile-analysis.js +9 -5
  11. package/dist/core/retrieval.js +2 -1
  12. package/dist/importer/import-runs.js +9 -3
  13. package/dist/services/api-handlers.d.ts +7 -0
  14. package/dist/services/api-handlers.js +72 -0
  15. package/dist/services/context.d.ts +1 -0
  16. package/dist/services/context.js +3 -1
  17. package/dist/services/user-profile/category-cleanup-job.d.ts +30 -0
  18. package/dist/services/user-profile/category-cleanup-job.js +50 -0
  19. package/dist/services/user-profile/category-cleanup-store.d.ts +8 -0
  20. package/dist/services/user-profile/category-cleanup-store.js +35 -0
  21. package/dist/services/user-profile/category-cleanup.d.ts +74 -0
  22. package/dist/services/user-profile/category-cleanup.js +310 -0
  23. package/dist/services/user-profile/category-groups.d.ts +60 -0
  24. package/dist/services/user-profile/category-groups.js +171 -0
  25. package/dist/services/user-profile/profile-candidate-matching.d.ts +19 -0
  26. package/dist/services/user-profile/profile-candidate-matching.js +67 -0
  27. package/dist/services/user-profile/profile-category.d.ts +13 -0
  28. package/dist/services/user-profile/profile-category.js +43 -0
  29. package/dist/services/user-profile/user-profile-manager.d.ts +9 -1
  30. package/dist/services/user-profile/user-profile-manager.js +46 -65
  31. package/dist/services/web-server.js +16 -1
  32. package/dist/web/assets/index-B46sRbG_.js +127 -0
  33. package/dist/web/assets/index-BktRr9cC.css +2 -0
  34. package/dist/web/index.html +2 -2
  35. package/package.json +1 -1
  36. package/skills/omms-memory/SKILL.md +11 -2
  37. package/dist/web/assets/index-C9EubnKm.js +0 -127
  38. package/dist/web/assets/index-xoKkyBBV.css +0 -2
@@ -0,0 +1,50 @@
1
+ import { log } from "../logger.js";
2
+ import { previewCategoryCleanup, } from "./category-cleanup.js";
3
+ const jobs = new Map();
4
+ let nextId = 1;
5
+ function running(job) {
6
+ return !!job && (job.status.state === "grouping" || job.status.state === "checking");
7
+ }
8
+ /** Starts a preview job for the user, or returns the one already running. */
9
+ export function startCategoryCleanupJob(userId, deps = {}) {
10
+ const current = jobs.get(userId);
11
+ if (running(current))
12
+ return current;
13
+ const job = {
14
+ id: String(nextId++),
15
+ startedAt: Date.now(),
16
+ status: { state: "grouping" },
17
+ };
18
+ jobs.set(userId, job);
19
+ void (async () => {
20
+ try {
21
+ const preview = await previewCategoryCleanup(userId, {
22
+ ...deps,
23
+ onProgress: (done, total) => {
24
+ job.status = { state: "checking", done, total };
25
+ },
26
+ });
27
+ job.status = preview
28
+ ? { state: "done", preview }
29
+ : { state: "failed", error: "No profile found to clean up" };
30
+ }
31
+ catch (error) {
32
+ log("category cleanup job: failed", {
33
+ code: error instanceof Error ? error.name : "unknown",
34
+ });
35
+ job.status = {
36
+ state: "failed",
37
+ error: error instanceof Error ? error.message : String(error),
38
+ };
39
+ }
40
+ })();
41
+ return job;
42
+ }
43
+ /** Forgets the user's finished job, so the next preview reflects the changed profile. */
44
+ export function clearCategoryCleanupJob(userId) {
45
+ if (!running(jobs.get(userId)))
46
+ jobs.delete(userId);
47
+ }
48
+ export function getCategoryCleanupJob(userId) {
49
+ return jobs.get(userId) ?? null;
50
+ }
@@ -0,0 +1,8 @@
1
+ import type { CategoryRename } from "./category-groups.js";
2
+ export declare function recordCleanup(profileId: string, renames: CategoryRename[]): Promise<void>;
3
+ /** The newest cleanup of a profile that has not been undone. */
4
+ export declare function latestOpenCleanup(profileId: string): Promise<{
5
+ id: number;
6
+ renames: CategoryRename[];
7
+ } | null>;
8
+ export declare function markCleanupUndone(id: number): Promise<void>;
@@ -0,0 +1,35 @@
1
+ import { join } from "node:path";
2
+ import { CONFIG } from "../../config.js";
3
+ import { tursoConnectionManager } from "../turso/connection-manager.js";
4
+ import { USER_PROFILES_DB_NAME } from "./user-profile-manager.js";
5
+ /** Undo records for category cleanups. The profile changelog keeps too few entries for undo. */
6
+ async function connection() {
7
+ const db = await tursoConnectionManager.getConnection(join(CONFIG.storagePath || "", USER_PROFILES_DB_NAME));
8
+ await db.execute(`CREATE TABLE IF NOT EXISTS profile_category_cleanups (
9
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
10
+ profile_id TEXT NOT NULL,
11
+ created_at INTEGER NOT NULL,
12
+ renames TEXT NOT NULL,
13
+ undone_at INTEGER
14
+ )`);
15
+ return db;
16
+ }
17
+ export async function recordCleanup(profileId, renames) {
18
+ const db = await connection();
19
+ await db.execute("INSERT INTO profile_category_cleanups (profile_id, created_at, renames) VALUES (?, ?, ?)", [profileId, Date.now(), JSON.stringify(renames)]);
20
+ }
21
+ /** The newest cleanup of a profile that has not been undone. */
22
+ export async function latestOpenCleanup(profileId) {
23
+ const db = await connection();
24
+ const row = await db.get("SELECT id, renames FROM profile_category_cleanups WHERE profile_id = ? AND undone_at IS NULL ORDER BY id DESC LIMIT 1", [profileId]);
25
+ if (!row)
26
+ return null;
27
+ return { id: Number(row.id), renames: JSON.parse(String(row.renames)) };
28
+ }
29
+ export async function markCleanupUndone(id) {
30
+ const db = await connection();
31
+ await db.execute("UPDATE profile_category_cleanups SET undone_at = ? WHERE id = ?", [
32
+ Date.now(),
33
+ id,
34
+ ]);
35
+ }
@@ -0,0 +1,74 @@
1
+ import { type CategoryGroup } from "./category-groups.js";
2
+ import type { UserProfileManager } from "./user-profile-manager.js";
3
+ export declare const GROUPS_PER_MODEL_CALL = 20;
4
+ export declare const MAX_EXAMPLES_PER_LABEL = 3;
5
+ /**
6
+ * Time limit per model call. A reasoning model took about 110 s per batch on the
7
+ * live profile (TDR-044), so the 60 s profile cleanup limit timed out every batch.
8
+ */
9
+ export declare const CATEGORY_CLEANUP_MODEL_TIMEOUT_MS = 180000;
10
+ /** Rejects with the message when the work takes longer than the limit, and always clears its timer. */
11
+ export declare function withTimeout<T>(work: Promise<T>, ms: number, message: string): Promise<T>;
12
+ /** Sends one prompt and returns the parsed JSON reply; throws on any failure. */
13
+ export type CleanupModel = (systemPrompt: string, userPrompt: string) => Promise<unknown>;
14
+ export interface NamedGroup extends CategoryGroup {
15
+ name: string;
16
+ /** False when the model did not check this group and the most used label was chosen. */
17
+ checked: boolean;
18
+ }
19
+ /**
20
+ * The profile model checks each group in batches: it removes names that do not belong
21
+ * and proposes one name. A failed batch, a missing reply, or no model falls back to the
22
+ * most used label and marks the group unchecked. The log holds counts and codes only.
23
+ */
24
+ export declare function checkAndNameGroups(groups: CategoryGroup[], examples: Map<string, string[]>, model: CleanupModel | null,
25
+ /** Called with batches done and the total, before the first batch and after each one. */
26
+ onProgress?: (done: number, total: number) => void): Promise<NamedGroup[]>;
27
+ /** The host model when one is registered, else the external API, else none. */
28
+ export declare function resolveCleanupModel(): Promise<CleanupModel | null>;
29
+ export interface CleanupDeps {
30
+ manager?: UserProfileManager;
31
+ /** Embeds one category name; defaults to the configured embedder. */
32
+ embed?: (text: string) => Promise<number[]>;
33
+ /** Undefined resolves the profile model; null runs without one. */
34
+ model?: CleanupModel | null;
35
+ threshold?: number;
36
+ /** Reports model batches done and the total while a preview is built. */
37
+ onProgress?: (done: number, total: number) => void;
38
+ }
39
+ export interface CategoryCleanupPreview {
40
+ profileId: string;
41
+ version: number;
42
+ /** Distinct categories now, and if every group were accepted. */
43
+ before: number;
44
+ after: number;
45
+ groups: NamedGroup[];
46
+ }
47
+ export interface AcceptedGroup {
48
+ labels: string[];
49
+ name: string;
50
+ }
51
+ export type ApplyResult = {
52
+ ok: true;
53
+ renamed: number;
54
+ } | {
55
+ ok: false;
56
+ error: "stale" | "no_profile" | "invalid";
57
+ };
58
+ export type UndoResult = {
59
+ ok: true;
60
+ restored: number;
61
+ notFound: number;
62
+ } | {
63
+ ok: false;
64
+ error: "nothing_to_undo" | "stale";
65
+ };
66
+ /** Builds the groups for review. Changes nothing in the stored profile. */
67
+ export declare function previewCategoryCleanup(userId: string, deps?: CleanupDeps): Promise<CategoryCleanupPreview | null>;
68
+ /** Renames the categories of the accepted groups; refuses when the profile changed since the preview. */
69
+ export declare function applyCategoryCleanup(userId: string, input: {
70
+ version: number;
71
+ groups: AcceptedGroup[];
72
+ }, deps?: CleanupDeps): Promise<ApplyResult>;
73
+ /** Restores the categories of the newest cleanup that is not undone yet. */
74
+ export declare function undoCategoryCleanup(userId: string, deps?: CleanupDeps): Promise<UndoResult>;
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Smart category cleanup: group profile category names by meaning, let the profile
3
+ * model check and name each group, then apply a rename-only change the user accepted.
4
+ * See openspec/changes/smart-category-cleanup.
5
+ */
6
+ import { CONFIG } from "../../config.js";
7
+ import { log } from "../logger.js";
8
+ import { applySafeExtraParams } from "../ai/providers/base-provider.js";
9
+ import { completeStructured, resolveHostProfileModel } from "./profile-model.js";
10
+ import { CATEGORY_GROUP_THRESHOLD, collectCategoryLabels, groupCategoryLabels, groupId, recordRenames, restoreRenames, } from "./category-groups.js";
11
+ import { canonicaliseCategories, categoryKey, tidyCategory } from "./profile-category.js";
12
+ import { latestOpenCleanup, markCleanupUndone, recordCleanup } from "./category-cleanup-store.js";
13
+ export const GROUPS_PER_MODEL_CALL = 20;
14
+ export const MAX_EXAMPLES_PER_LABEL = 3;
15
+ /**
16
+ * Time limit per model call. A reasoning model took about 110 s per batch on the
17
+ * live profile (TDR-044), so the 60 s profile cleanup limit timed out every batch.
18
+ */
19
+ export const CATEGORY_CLEANUP_MODEL_TIMEOUT_MS = 180_000;
20
+ const MAX_NAME_LENGTH = 60;
21
+ /** Rejects with the message when the work takes longer than the limit, and always clears its timer. */
22
+ export async function withTimeout(work, ms, message) {
23
+ let timer;
24
+ try {
25
+ return await Promise.race([
26
+ work,
27
+ new Promise((_, reject) => {
28
+ timer = setTimeout(() => reject(new Error(message)), ms);
29
+ }),
30
+ ]);
31
+ }
32
+ finally {
33
+ clearTimeout(timer);
34
+ }
35
+ }
36
+ const SYSTEM_PROMPT = "You tidy the category names of a user profile. Return only JSON without markdown wrapping.";
37
+ function buildPrompt(groups, examples) {
38
+ const payload = groups.map((group) => ({
39
+ group: group.id,
40
+ labels: group.labels.map((entry) => ({
41
+ name: entry.label,
42
+ count: entry.count,
43
+ examples: (examples.get(entry.label) ?? []).slice(0, MAX_EXAMPLES_PER_LABEL),
44
+ })),
45
+ }));
46
+ return `Each group below holds category names that may mean the same thing. The examples show memories filed under each name.
47
+
48
+ For each group:
49
+ 1. "keep": the names that mean the same thing as each other. Leave out any name with a different meaning.
50
+ 2. "name": one short category name for the kept names. Prefer an existing name when one fits.
51
+
52
+ Groups:
53
+ ${JSON.stringify(payload)}
54
+
55
+ Return JSON only: { "groups": [ { "group": "<id>", "keep": ["<name>", "..."], "name": "<name>" } ] }`;
56
+ }
57
+ function fallback(group) {
58
+ return { ...group, name: group.labels[0].label, checked: false };
59
+ }
60
+ function readReply(reply) {
61
+ const entries = reply?.groups;
62
+ const byId = new Map();
63
+ if (!Array.isArray(entries))
64
+ return byId;
65
+ for (const entry of entries) {
66
+ if (entry && typeof entry === "object" && typeof entry.group === "string") {
67
+ byId.set(entry.group, { keep: entry.keep, name: entry.name });
68
+ }
69
+ }
70
+ return byId;
71
+ }
72
+ function applyReply(group, answer) {
73
+ if (!answer || !Array.isArray(answer.keep))
74
+ return fallback(group);
75
+ const keep = new Set(answer.keep.filter((name) => typeof name === "string"));
76
+ const labels = group.labels.filter((entry) => keep.has(entry.label));
77
+ if (labels.length < 2)
78
+ return null;
79
+ const name = typeof answer.name === "string" &&
80
+ answer.name.trim() &&
81
+ answer.name.trim().length <= MAX_NAME_LENGTH
82
+ ? answer.name.trim()
83
+ : labels[0].label;
84
+ return { id: groupId(labels), labels, name, checked: true };
85
+ }
86
+ /**
87
+ * The profile model checks each group in batches: it removes names that do not belong
88
+ * and proposes one name. A failed batch, a missing reply, or no model falls back to the
89
+ * most used label and marks the group unchecked. The log holds counts and codes only.
90
+ */
91
+ export async function checkAndNameGroups(groups, examples, model,
92
+ /** Called with batches done and the total, before the first batch and after each one. */
93
+ onProgress) {
94
+ const batches = [];
95
+ for (let start = 0; start < groups.length; start += GROUPS_PER_MODEL_CALL) {
96
+ batches.push(groups.slice(start, start + GROUPS_PER_MODEL_CALL));
97
+ }
98
+ let failedBatches = 0;
99
+ let dropped = 0;
100
+ const checkBatch = async (batch) => {
101
+ if (!model)
102
+ return batch.map(fallback);
103
+ let replies;
104
+ try {
105
+ replies = readReply(await model(SYSTEM_PROMPT, buildPrompt(batch, examples)));
106
+ }
107
+ catch (error) {
108
+ failedBatches++;
109
+ log("category cleanup: model batch failed", {
110
+ groups: batch.length,
111
+ code: error instanceof Error ? error.name : "unknown",
112
+ });
113
+ return batch.map(fallback);
114
+ }
115
+ const results = [];
116
+ for (const group of batch) {
117
+ const result = applyReply(group, replies.get(group.id));
118
+ if (result)
119
+ results.push(result);
120
+ else
121
+ dropped++;
122
+ }
123
+ return results;
124
+ };
125
+ // One call at a time: a provider that refuses parallel calls still finishes (TDR-044).
126
+ const named = [];
127
+ onProgress?.(0, batches.length);
128
+ for (const [index, batch] of batches.entries()) {
129
+ named.push(...(await checkBatch(batch)));
130
+ onProgress?.(index + 1, batches.length);
131
+ }
132
+ log("category cleanup: groups checked", {
133
+ groups: groups.length,
134
+ named: named.length,
135
+ dropped,
136
+ unchecked: named.filter((group) => !group.checked).length,
137
+ failedBatches,
138
+ model: model ? "available" : "none",
139
+ });
140
+ return named;
141
+ }
142
+ /** The host model when one is registered, else the external API, else none. */
143
+ export async function resolveCleanupModel() {
144
+ const hostModel = await resolveHostProfileModel();
145
+ if (hostModel) {
146
+ const { z } = await import("zod");
147
+ const schema = z.object({
148
+ groups: z.array(z.object({ group: z.string(), keep: z.array(z.string()), name: z.string() })),
149
+ });
150
+ return (systemPrompt, userPrompt) => withTimeout(completeStructured(hostModel, systemPrompt, userPrompt, schema), CATEGORY_CLEANUP_MODEL_TIMEOUT_MS, "category cleanup timeout");
151
+ }
152
+ if (CONFIG.memoryModel && CONFIG.memoryApiUrl) {
153
+ return async (systemPrompt, userPrompt) => {
154
+ const requestBody = {};
155
+ if (CONFIG.memoryExtraParams)
156
+ applySafeExtraParams(requestBody, CONFIG.memoryExtraParams);
157
+ Object.assign(requestBody, {
158
+ model: CONFIG.memoryModel,
159
+ messages: [
160
+ { role: "system", content: systemPrompt },
161
+ { role: "user", content: userPrompt },
162
+ ],
163
+ temperature: 0,
164
+ response_format: { type: "json_object" },
165
+ });
166
+ const response = await fetch(`${CONFIG.memoryApiUrl}/chat/completions`, {
167
+ method: "POST",
168
+ headers: {
169
+ "Content-Type": "application/json",
170
+ Authorization: `Bearer ${CONFIG.memoryApiKey || ""}`,
171
+ },
172
+ body: JSON.stringify(requestBody),
173
+ signal: AbortSignal.timeout(CATEGORY_CLEANUP_MODEL_TIMEOUT_MS),
174
+ });
175
+ if (!response.ok)
176
+ throw new Error(`API request failed: ${response.status}`);
177
+ const data = await response.json();
178
+ const content = data.choices?.[0]?.message?.content;
179
+ if (!content)
180
+ throw new Error("No content in API response");
181
+ return JSON.parse(content);
182
+ };
183
+ }
184
+ return null;
185
+ }
186
+ async function managerOf(deps) {
187
+ return deps.manager ?? (await import("./user-profile-manager.js")).userProfileManager;
188
+ }
189
+ async function defaultEmbed() {
190
+ const { EmbeddingService } = await import("../embedding.js");
191
+ const service = EmbeddingService.getInstance();
192
+ return async (text) => Array.from(await service.embed(text, { task: "document" }));
193
+ }
194
+ function readProfile(profileData) {
195
+ const parsed = JSON.parse(profileData);
196
+ return canonicaliseCategories({
197
+ ...parsed,
198
+ preferences: Array.isArray(parsed.preferences) ? parsed.preferences : [],
199
+ patterns: Array.isArray(parsed.patterns) ? parsed.patterns : [],
200
+ workflows: Array.isArray(parsed.workflows) ? parsed.workflows : [],
201
+ });
202
+ }
203
+ function examplesByLabel(data) {
204
+ const items = [...data.preferences, ...data.patterns].sort((a, b) => (b.frequency ?? 0) - (a.frequency ?? 0));
205
+ const examples = new Map();
206
+ for (const item of items) {
207
+ const list = examples.get(item.category) ?? [];
208
+ if (list.length < MAX_EXAMPLES_PER_LABEL)
209
+ list.push(item.description);
210
+ examples.set(item.category, list);
211
+ }
212
+ return examples;
213
+ }
214
+ function countAfter(labels, groups) {
215
+ const rename = new Map();
216
+ for (const group of groups) {
217
+ for (const entry of group.labels)
218
+ rename.set(entry.label, tidyCategory(group.name));
219
+ }
220
+ return new Set(labels.map((entry) => categoryKey(rename.get(entry.label) ?? entry.label))).size;
221
+ }
222
+ /** Builds the groups for review. Changes nothing in the stored profile. */
223
+ export async function previewCategoryCleanup(userId, deps = {}) {
224
+ const manager = await managerOf(deps);
225
+ const profile = await manager.getActiveProfile(userId);
226
+ if (!profile)
227
+ return null;
228
+ const data = readProfile(profile.profileData);
229
+ const labels = collectCategoryLabels(data);
230
+ const embed = deps.embed ?? (await defaultEmbed());
231
+ const vectors = [];
232
+ for (const entry of labels)
233
+ vectors.push({ ...entry, vector: await embed(entry.label) });
234
+ const groups = groupCategoryLabels(vectors, deps.threshold ?? CATEGORY_GROUP_THRESHOLD);
235
+ const model = deps.model === undefined ? await resolveCleanupModel() : deps.model;
236
+ const named = await checkAndNameGroups(groups, examplesByLabel(data), model, deps.onProgress);
237
+ log("category cleanup: preview built", {
238
+ labels: labels.length,
239
+ groups: named.length,
240
+ });
241
+ return {
242
+ profileId: profile.id,
243
+ version: profile.version,
244
+ before: labels.length,
245
+ after: countAfter(labels, named),
246
+ groups: named,
247
+ };
248
+ }
249
+ function validGroups(groups) {
250
+ return (Array.isArray(groups) &&
251
+ groups.every((group) => group &&
252
+ Array.isArray(group.labels) &&
253
+ group.labels.length >= 2 &&
254
+ group.labels.every((label) => typeof label === "string") &&
255
+ typeof group.name === "string" &&
256
+ group.name.trim().length > 0));
257
+ }
258
+ /** Renames the categories of the accepted groups; refuses when the profile changed since the preview. */
259
+ export async function applyCategoryCleanup(userId, input, deps = {}) {
260
+ if (!Number.isInteger(input?.version) || !validGroups(input.groups))
261
+ return { ok: false, error: "invalid" };
262
+ const manager = await managerOf(deps);
263
+ const profile = await manager.getActiveProfile(userId);
264
+ if (!profile)
265
+ return { ok: false, error: "no_profile" };
266
+ if (profile.version !== input.version)
267
+ return { ok: false, error: "stale" };
268
+ const data = readProfile(profile.profileData);
269
+ const rename = new Map();
270
+ for (const group of input.groups) {
271
+ for (const label of group.labels)
272
+ rename.set(label, tidyCategory(group.name));
273
+ }
274
+ const retag = (items) => items.map((item) => rename.has(item.category) ? { ...item, category: rename.get(item.category) } : item);
275
+ const next = {
276
+ ...data,
277
+ preferences: retag(data.preferences),
278
+ patterns: retag(data.patterns),
279
+ };
280
+ const renames = recordRenames(data, canonicaliseCategories(next));
281
+ if (renames.length === 0)
282
+ return { ok: true, renamed: 0 };
283
+ const summary = `Category cleanup: ${renames.length} items renamed in ${input.groups.length} groups`;
284
+ if (!(await manager.updateProfile(profile.id, next, 0, summary, input.version))) {
285
+ return { ok: false, error: "stale" };
286
+ }
287
+ await recordCleanup(profile.id, renames);
288
+ log("category cleanup: applied", { groups: input.groups.length, renamed: renames.length });
289
+ return { ok: true, renamed: renames.length };
290
+ }
291
+ /** Restores the categories of the newest cleanup that is not undone yet. */
292
+ export async function undoCategoryCleanup(userId, deps = {}) {
293
+ const manager = await managerOf(deps);
294
+ const profile = await manager.getActiveProfile(userId);
295
+ if (!profile)
296
+ return { ok: false, error: "nothing_to_undo" };
297
+ const record = await latestOpenCleanup(profile.id);
298
+ if (!record)
299
+ return { ok: false, error: "nothing_to_undo" };
300
+ const restored = restoreRenames(readProfile(profile.profileData), record.renames);
301
+ if (restored.restored > 0) {
302
+ const summary = `Category cleanup undone: ${restored.restored} items restored`;
303
+ if (!(await manager.updateProfile(profile.id, restored.profile, 0, summary, profile.version))) {
304
+ return { ok: false, error: "stale" };
305
+ }
306
+ }
307
+ await markCleanupUndone(record.id);
308
+ log("category cleanup: undone", { restored: restored.restored, notFound: restored.notFound });
309
+ return { ok: true, restored: restored.restored, notFound: restored.notFound };
310
+ }
@@ -0,0 +1,60 @@
1
+ /** Average cosine similarity at which two groups of category names count as one meaning. */
2
+ export declare const CATEGORY_GROUP_THRESHOLD = 0.8;
3
+ export interface CategoryLabelCount {
4
+ label: string;
5
+ count: number;
6
+ }
7
+ export interface CategoryLabelVector extends CategoryLabelCount {
8
+ vector: number[];
9
+ }
10
+ export interface CategoryGroup {
11
+ /** Stable for the same set of labels, so a preview and its apply agree. */
12
+ id: string;
13
+ labels: CategoryLabelCount[];
14
+ }
15
+ type CategoryProfile = {
16
+ preferences: {
17
+ category: string;
18
+ }[];
19
+ patterns: {
20
+ category: string;
21
+ }[];
22
+ };
23
+ /** Distinct labels across preferences and patterns after the spelling rules, most used first. */
24
+ export declare function collectCategoryLabels(profile: CategoryProfile): CategoryLabelCount[];
25
+ export declare function groupId(labels: CategoryLabelCount[]): string;
26
+ /**
27
+ * Groups category names that mean the same thing. Only labels joined by at least one
28
+ * pair at or above the threshold can share a group, so clustering runs per connected
29
+ * component of that graph. A group needs two labels; `explicit` never joins one.
30
+ */
31
+ export declare function groupCategoryLabels(labels: CategoryLabelVector[], threshold?: number): CategoryGroup[];
32
+ export type CategorySection = "preferences" | "patterns";
33
+ /** One item renamed by a cleanup. The description is stored as a hash, never as text. */
34
+ export interface CategoryRename {
35
+ section: CategorySection;
36
+ descriptionSha256: string;
37
+ from: string;
38
+ to: string;
39
+ }
40
+ type DescribedItem = {
41
+ category: string;
42
+ description: string;
43
+ };
44
+ type DescribedProfile = {
45
+ preferences: DescribedItem[];
46
+ patterns: DescribedItem[];
47
+ };
48
+ export declare function descriptionHash(description: string): string;
49
+ /** Renames between two versions of one profile whose items are in the same order. */
50
+ export declare function recordRenames(before: DescribedProfile, after: DescribedProfile): CategoryRename[];
51
+ /**
52
+ * Puts the old category back on each renamed item that still exists with the
53
+ * category the cleanup gave it. Items added or merged later are left alone.
54
+ */
55
+ export declare function restoreRenames<T extends DescribedProfile>(current: T, renames: CategoryRename[]): {
56
+ profile: T;
57
+ restored: number;
58
+ notFound: number;
59
+ };
60
+ export {};