om-memory-system 4.17.0-next.124 → 4.19.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 (31) hide show
  1. package/dist/adapters/opencode/profile-learning.js +7 -6
  2. package/dist/adapters/opencode/tui-status.js +2 -1
  3. package/dist/adapters/pi/extension.js +3 -2
  4. package/dist/adapters/pi/profile.js +2 -1
  5. package/dist/cli/profile-cleanup-categories-command.js +1 -2
  6. package/dist/cli/profile-normalise-categories-command.js +1 -1
  7. package/dist/core/memory-operations.js +10 -2
  8. package/dist/importer/claude-hook-api.js +2 -2
  9. package/dist/importer/profile-backlog.d.ts +1 -1
  10. package/dist/importer/profile-backlog.js +3 -2
  11. package/dist/importer/profile-import.d.ts +1 -1
  12. package/dist/services/api-handlers.js +6 -6
  13. package/dist/services/profile-admin.d.ts +1 -1
  14. package/dist/services/profile-admin.js +1 -1
  15. package/dist/services/status-glyphs.d.ts +19 -0
  16. package/dist/services/status-glyphs.js +21 -0
  17. package/dist/services/user-profile/category-cleanup-store.d.ts +11 -0
  18. package/dist/services/user-profile/category-cleanup-store.js +57 -1
  19. package/dist/services/user-profile/category-cleanup.d.ts +16 -7
  20. package/dist/services/user-profile/category-cleanup.js +94 -31
  21. package/dist/services/user-profile/merge-and-save.d.ts +25 -0
  22. package/dist/services/user-profile/merge-and-save.js +38 -0
  23. package/dist/services/user-profile/user-profile-manager.d.ts +2 -2
  24. package/dist/services/user-profile/user-profile-manager.js +2 -2
  25. package/dist/web/assets/index-BkHg0H-_.js +127 -0
  26. package/dist/web/assets/index-D2yC6v1B.css +2 -0
  27. package/dist/web/index.html +2 -2
  28. package/opencode/tui.tsx +3 -3
  29. package/package.json +1 -1
  30. package/dist/web/assets/index-B46sRbG_.js +0 -127
  31. package/dist/web/assets/index-BktRr9cC.css +0 -2
@@ -5,6 +5,7 @@ import { CONFIG, refreshConfigIfChanged } from "../../config.js";
5
5
  import { resolveOpencodeHostModel } from "../../services/ai/live-model-choice.js";
6
6
  import { userPromptManager } from "../../services/user-prompt/user-prompt-manager.js";
7
7
  import { userProfileManager } from "../../services/user-profile/user-profile-manager.js";
8
+ import { saveProfile } from "../../services/user-profile/merge-and-save.js";
8
9
  import { sortProfileItems } from "../../utils/profile.js";
9
10
  import { truncateToMaxBytes } from "../../utils/context-limit.js";
10
11
  import { loadOpencodeProvider } from "./opencode-provider-loader.js";
@@ -29,7 +30,8 @@ async function runAutomaticProfileCleanup(userId) {
29
30
  const result = await aiCleanupProfile(profileData);
30
31
  if (result.diff.merged.length === 0 && result.diff.removed.length === 0)
31
32
  return;
32
- const updated = await userProfileManager.updateProfile(profile.id, result.cleaned, 0, `Automatic AI cleanup: ${result.diff.merged.length} merged, ${result.diff.removed.length} removed`);
33
+ const summary = `Automatic AI cleanup: ${result.diff.merged.length} merged, ${result.diff.removed.length} removed`;
34
+ const updated = await saveProfile(profile, result.cleaned, summary);
33
35
  log("user-profile-learning: automatic cleanup complete", {
34
36
  userId,
35
37
  updated,
@@ -177,7 +179,7 @@ Rules:
177
179
  if (validationSummary) {
178
180
  changeSummary = changeSummary + "; " + validationSummary;
179
181
  }
180
- success = await userProfileManager.updateProfile(existingProfile.id, updatedProfileData, prompts.length, changeSummary);
182
+ success = await saveProfile(existingProfile, updatedProfileData, changeSummary, prompts.length);
181
183
  if (!success) {
182
184
  log("User profile update conflict, retrying", {
183
185
  profileId: existingProfile.id,
@@ -188,13 +190,12 @@ Rules:
188
190
  retries++;
189
191
  }
190
192
  if (!success) {
191
- // Mark the batch anyway so the same prompts are not retried forever
192
- // (token burn + repeated toasts on every subsequent idle).
193
+ // Keep the prompts for a later run; the backoff stops a retry on every idle.
193
194
  log("User profile update conflict: exhausted retries", {
194
195
  profileId: existingProfile?.id,
195
196
  userId,
196
197
  });
197
- await userPromptManager.markMultipleAsUserLearningCaptured(prompts.map((p) => p.id));
198
+ recordProfileFailure("opencode", "user-profile-learning: conflict", new Error("conflict"));
198
199
  return;
199
200
  }
200
201
  await userPromptManager.markMultipleAsUserLearningCaptured(prompts.map((p) => p.id));
@@ -664,5 +665,5 @@ If no clear chains, return { "paths": [] }.`;
664
665
  return;
665
666
  const data = JSON.parse(profile.profileData);
666
667
  data.learning_paths = result.paths;
667
- await userProfileManager.updateProfile(profileId, data, 0, `Updated learning paths: ${result.paths.map((p) => p.topic).join(", ")}`);
668
+ await saveProfile(profile, data, `Updated learning paths: ${result.paths.map((p) => p.topic).join(", ")}`);
668
669
  }
@@ -1,6 +1,7 @@
1
+ import { ommsStatusLabel } from "../../services/status-glyphs.js";
1
2
  import { availableUpdate, latestNpmVersion } from "../../services/update-check.js";
2
3
  export function tuiStatusText(state) {
3
- const web = state.web === "offline" ? "omms:web app off" : `omms:${state.web}`;
4
+ const web = ommsStatusLabel(state.web === "offline" ? "web app off" : state.web);
4
5
  return state.update ? `${web} · ${state.update} available` : web;
5
6
  }
6
7
  /**
@@ -13,6 +13,7 @@ import { registerPiHistoryImportCommand } from "./import-command.js";
13
13
  import { performPiProfileLearning } from "./profile.js";
14
14
  import { buildRetrievalSection, retrievalWrapperBytes, wrapRetrievalSection, } from "../../core/retrieval.js";
15
15
  import { DEFAULT_RETRIEVAL_MAX_TOKENS, tokensToByteCeiling } from "../../core/context-budget.js";
16
+ import { ommsStatusLabel } from "../../services/status-glyphs.js";
16
17
  const GLOBAL_PLUGIN_WARMUP_KEY = Symbol.for("omms.plugin.warmedup");
17
18
  const GLOBAL_PI_BACKFILL_KEY = Symbol.for("omms.pi.backfill.scheduled");
18
19
  const OMMS_STATUS_KEY = "omms";
@@ -79,13 +80,13 @@ export default function ommsPiExtension(pi) {
79
80
  const startStatus = (ctx, status) => {
80
81
  const version = ++statusVersion;
81
82
  if (ctx.hasUI)
82
- ctx.ui.setStatus(OMMS_STATUS_KEY, `omms:${status}`);
83
+ ctx.ui.setStatus(OMMS_STATUS_KEY, ommsStatusLabel(status));
83
84
  return version;
84
85
  };
85
86
  const finishStatus = (ctx, version, status) => {
86
87
  if (version !== statusVersion || !ctx.hasUI)
87
88
  return;
88
- ctx.ui.setStatus(OMMS_STATUS_KEY, `omms:${status}`);
89
+ ctx.ui.setStatus(OMMS_STATUS_KEY, ommsStatusLabel(status));
89
90
  };
90
91
  const clearStatus = (ctx) => {
91
92
  statusVersion++;
@@ -62,6 +62,7 @@ export async function performPiProfileLearning(input) {
62
62
  }
63
63
  const userId = tags.user.userEmail;
64
64
  const { userProfileManager } = await import("../../services/user-profile/user-profile-manager.js");
65
+ const { saveMergedProfile } = await import("../../services/user-profile/merge-and-save.js");
65
66
  const existing = await userProfileManager.getActiveProfile(userId);
66
67
  const resolveProfileModel = input.resolveProfileModel;
67
68
  const analyzer = resolveProfileModel
@@ -79,7 +80,7 @@ export async function performPiProfileLearning(input) {
79
80
  return;
80
81
  }
81
82
  if (existing && analysis.merged) {
82
- const updated = await userProfileManager.updateProfile(existing.id, analysis.merged, input.prompts.length, `Pi analysis of ${input.prompts.length} prompts`);
83
+ const updated = await saveMergedProfile(userProfileManager, existing, analysis, input.prompts.length, `Pi analysis of ${input.prompts.length} prompts`);
83
84
  if (!updated) {
84
85
  log("pi profile learning: update conflict", { profileId: existing.id });
85
86
  return;
@@ -71,14 +71,13 @@ export async function runProfileCleanupCategoriesCommand(argv, print = console.l
71
71
  return 0;
72
72
  }
73
73
  const result = await cleanup.applyCategoryCleanup(userId, {
74
- version: preview.version,
75
74
  groups: preview.groups.map((group) => ({
76
75
  labels: group.labels.map((entry) => entry.label),
77
76
  name: group.name,
78
77
  })),
79
78
  }, deps);
80
79
  if (!result.ok)
81
- throw new Error(`Cleanup not applied (${result.error}). Run the preview again.`);
80
+ throw new Error(`Cleanup not applied (${result.error}). Run the command again.`);
82
81
  print(`Renamed ${result.renamed} items.`);
83
82
  return 0;
84
83
  }
@@ -69,7 +69,7 @@ export async function runProfileNormaliseCategoriesCommand(argv, print = console
69
69
  print("Preview only. Run again with --yes to save.");
70
70
  return 0;
71
71
  }
72
- const saved = await userProfileManager.updateProfile(profile.id, after, 0, `Normalised profile categories: ${changes} items renamed`);
72
+ const saved = await userProfileManager.updateProfile(profile.id, after, 0, `Normalised profile categories: ${changes} items renamed`, profile.version);
73
73
  if (!saved)
74
74
  throw new Error("Profile was modified by another session. Please retry.");
75
75
  print("Category renames saved. The previous snapshot remains in the profile changelog.");
@@ -145,6 +145,7 @@ export async function executeMemoryOperation(args, context) {
145
145
  };
146
146
  }
147
147
  const { userProfileManager } = await import("../services/user-profile/user-profile-manager.js");
148
+ const { saveMergedProfile } = await import("../services/user-profile/merge-and-save.js");
148
149
  const userId = tags.user.userEmail || "unknown";
149
150
  if (args.content !== undefined) {
150
151
  const trimmed = args.content.trim();
@@ -172,8 +173,15 @@ export async function executeMemoryOperation(args, context) {
172
173
  const existingProfile = await userProfileManager.getActiveProfile(userId);
173
174
  if (existingProfile) {
174
175
  const existingData = JSON.parse(existingProfile.profileData);
175
- const mergedData = await userProfileManager.mergeProfileData(existingData, { preferences: [newPreference] }, undefined, existingProfile.id);
176
- await userProfileManager.updateProfile(existingProfile.id, mergedData, 0, `Explicit preference added: ${sanitizedContent.slice(0, 80)}`);
176
+ const raw = { preferences: [newPreference] };
177
+ const mergedData = await userProfileManager.mergeProfileData(existingData, raw, undefined, existingProfile.id);
178
+ const saved = await saveMergedProfile(userProfileManager, existingProfile, { raw, merged: mergedData }, 0, `Explicit preference added: ${sanitizedContent.slice(0, 80)}`);
179
+ if (!saved) {
180
+ return {
181
+ success: false,
182
+ error: "The profile changed while saving. Try again.",
183
+ };
184
+ }
177
185
  return { success: true, message: "Preference saved to profile" };
178
186
  }
179
187
  await userProfileManager.createProfile(userId, tags.user.displayName || userId, tags.user.userName || userId, tags.user.userEmail || userId, { preferences: [newPreference], patterns: [], workflows: [] }, 0);
@@ -449,8 +449,8 @@ async function learnProfile(directory, deps) {
449
449
  const context = batch.map((prompt, index) => `${index + 1}. ${prompt.content}`).join("\n");
450
450
  const analysis = await analyzeProfile(model, context, existing ? { id: existing.id, profileData: existing.profileData } : null);
451
451
  if (existing) {
452
- const updated = analysis.merged &&
453
- (await userProfileManager.updateProfile(existing.id, analysis.merged, batch.length, `Claude Code analysis of ${batch.length} prompts`));
452
+ const { saveMergedProfile } = await import("../services/user-profile/merge-and-save.js");
453
+ const updated = await saveMergedProfile(userProfileManager, existing, analysis, batch.length, `Claude Code analysis of ${batch.length} prompts`);
454
454
  if (!updated)
455
455
  throw new Error("Profile update conflict");
456
456
  }
@@ -3,7 +3,7 @@ import { type ProfileFailureCode } from "../core/profile-failure.js";
3
3
  import { userProfileManager } from "../services/user-profile/user-profile-manager.js";
4
4
  import { userPromptManager } from "../services/user-prompt/user-prompt-manager.js";
5
5
  export type BacklogPromptStore = Pick<typeof userPromptManager, "countUnanalyzedForUserLearning" | "getPromptsForUserLearning" | "markMultipleAsUserLearningCaptured"> & Partial<Pick<typeof userPromptManager, "skipTrivialPromptsForLearning">>;
6
- export type BacklogProfileStore = Pick<typeof userProfileManager, "getActiveProfile" | "createProfile" | "updateProfile">;
6
+ export type BacklogProfileStore = Pick<typeof userProfileManager, "getActiveProfile" | "getProfileById" | "createProfile" | "mergeProfileData" | "updateProfile">;
7
7
  export interface DrainProfileBacklogOptions {
8
8
  user: {
9
9
  userEmail: string;
@@ -1,5 +1,6 @@
1
1
  import { analyzeProfile } from "../core/profile-analysis.js";
2
2
  import { profileFailureCode } from "../core/profile-failure.js";
3
+ import { saveMergedProfile } from "../services/user-profile/merge-and-save.js";
3
4
  import { userProfileManager } from "../services/user-profile/user-profile-manager.js";
4
5
  import { userPromptManager } from "../services/user-prompt/user-prompt-manager.js";
5
6
  /**
@@ -54,8 +55,8 @@ export async function drainProfileBacklog(options) {
54
55
  const context = batch.map((item, index) => `${index + 1}. ${item.content}`).join("\n");
55
56
  const analysis = await analyzeProfile(model, context, existing ? { id: existing.id, profileData: existing.profileData } : null);
56
57
  if (existing) {
57
- if (!analysis.merged ||
58
- !(await profiles.updateProfile(existing.id, analysis.merged, batch.length, `${label} of ${batch.length} prompts`)))
58
+ const summary = `${label} of ${batch.length} prompts`;
59
+ if (!(await saveMergedProfile(profiles, existing, analysis, batch.length, summary)))
59
60
  throw new Error("Profile update conflict");
60
61
  }
61
62
  else {
@@ -13,7 +13,7 @@ export interface ProfileImportReport {
13
13
  error?: string;
14
14
  }
15
15
  type PromptStore = Pick<typeof userPromptManager, "savePrompt" | "markAsCaptured" | "countUnanalyzedForUserLearning" | "getPromptsForUserLearning" | "markMultipleAsUserLearningCaptured" | "markForUserLearning">;
16
- type ProfileStore = Pick<typeof userProfileManager, "getActiveProfile" | "createProfile" | "updateProfile">;
16
+ type ProfileStore = Pick<typeof userProfileManager, "getActiveProfile" | "getProfileById" | "createProfile" | "mergeProfileData" | "updateProfile">;
17
17
  type Ledger = Pick<ImportLedger, "get" | "begin" | "complete"> & Partial<Pick<ImportLedger, "peek">>;
18
18
  export interface ProfileImportOptions {
19
19
  host: MemoryHost;
@@ -9,6 +9,7 @@ import { log } from "./logger.js";
9
9
  import { countKeywords } from "./keyword-counts.js";
10
10
  import { CONFIG } from "../config.js";
11
11
  import { userPromptManager } from "./user-prompt/user-prompt-manager.js";
12
+ import { saveProfile } from "./user-profile/merge-and-save.js";
12
13
  import { sortProfileItems, stripProfileVectors } from "../utils/profile.js";
13
14
  import { resolveWebProfileUserId } from "./profile-identity.js";
14
15
  async function getAllMemoryShards() {
@@ -863,8 +864,7 @@ export async function handleRefreshProfile(userId) {
863
864
  const pData = JSON.parse(profile.profileData);
864
865
  const { data: decayed, hasChanges } = userProfileManager.decayInMemory(pData);
865
866
  if (hasChanges) {
866
- await userProfileManager.updateProfile(profile.id, decayed, 0, "Applied confidence decay");
867
- decayApplied = true;
867
+ decayApplied = await saveProfile(profile, decayed, "Applied confidence decay");
868
868
  }
869
869
  }
870
870
  const unanalyzedCount = await userPromptManager.countUnanalyzedForUserLearning();
@@ -1054,7 +1054,7 @@ export async function handleApplyCleanup(userId, body) {
1054
1054
  explicitAcceptance,
1055
1055
  });
1056
1056
  const partial = (pending.includeIds && pending.includeIds.length > 0) || explicitAcceptance;
1057
- const success = await userProfileManager.updateProfile(profile.id, result, 0, partial ? "AI cleanup applied (partial)" : "AI cleanup applied");
1057
+ const success = await saveProfile(profile, result, partial ? "AI cleanup applied (partial)" : "AI cleanup applied");
1058
1058
  if (!success) {
1059
1059
  return { success: false, error: "Profile was modified by another session. Please retry." };
1060
1060
  }
@@ -1108,14 +1108,14 @@ export async function handleCategoryCleanupApply(userId, body, deps) {
1108
1108
  const { applyCategoryCleanup } = await import("./user-profile/category-cleanup.js");
1109
1109
  const { clearCategoryCleanupJob } = await import("./user-profile/category-cleanup-job.js");
1110
1110
  const user = await categoryCleanupUser(userId);
1111
- const result = await applyCategoryCleanup(user, { version: body?.version, groups: body?.groups }, deps);
1111
+ const result = await applyCategoryCleanup(user, { groups: body?.groups }, deps);
1112
1112
  if (result.ok) {
1113
1113
  // The finished preview no longer matches the profile.
1114
1114
  clearCategoryCleanupJob(user);
1115
1115
  return { success: true, data: { renamed: result.renamed } };
1116
1116
  }
1117
1117
  const messages = {
1118
- stale: "Profile changed since the preview. Build a new preview.",
1118
+ stale: "Profile saved at the same moment. Try Apply again.",
1119
1119
  no_profile: "No profile found to clean up",
1120
1120
  invalid: "Each group needs at least two labels and a name.",
1121
1121
  };
@@ -1243,7 +1243,7 @@ export async function handleUpdateProfileItem(body) {
1243
1243
  const changeSummary = action === "delete"
1244
1244
  ? `Deleted ${type.slice(0, -1)} at index ${index}`
1245
1245
  : `Edited ${type.slice(0, -1)} at index ${index}`;
1246
- const success = await userProfileManager.updateProfile(profile.id, profileData, 0, changeSummary);
1246
+ const success = await saveProfile(profile, profileData, changeSummary);
1247
1247
  if (!success)
1248
1248
  return { success: false, error: "Profile was modified by another session. Please retry." };
1249
1249
  return {
@@ -4,7 +4,7 @@ export interface ProfileAdminStore {
4
4
  getAllActiveProfiles(): Promise<UserProfile[]>;
5
5
  getProfileById(id: string): Promise<UserProfile | null>;
6
6
  mergeProfileData(existing: UserProfileData, updates: Partial<UserProfileData>, embedService?: undefined, profileId?: string): Promise<UserProfileData>;
7
- updateProfile(profileId: string, profileData: UserProfileData, additionalPromptsAnalyzed: number, changeSummary: string): Promise<boolean>;
7
+ updateProfile(profileId: string, profileData: UserProfileData, additionalPromptsAnalyzed: number, changeSummary: string, expectedVersion: number): Promise<boolean>;
8
8
  deactivateProfile(profileId: string): Promise<boolean>;
9
9
  }
10
10
  export interface ProfileSummary {
@@ -44,7 +44,7 @@ export async function mergeProfiles(store, sourceId, targetId) {
44
44
  throw new ProfileAdminError("Both profiles must exist and be active", 404);
45
45
  }
46
46
  const merged = await store.mergeProfileData(parse(target), parse(source), undefined, target.id);
47
- const saved = await store.updateProfile(target.id, merged, source.totalPromptsAnalyzed, `Merged profile ${source.userId}`);
47
+ const saved = await store.updateProfile(target.id, merged, source.totalPromptsAnalyzed, `Merged profile ${source.userId}`, target.version);
48
48
  if (!saved)
49
49
  throw new ProfileAdminError("Profile was changed by another session. Retry.", 409);
50
50
  await store.deactivateProfile(source.id);
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Footer label for the Pi and OpenCode adapters. Claude Code cannot import
3
+ * from `src/`, so `hooks/omms-status-glyphs.js` holds a twin of this file and
4
+ * `tests/status-glyph-parity.test.ts` keeps the two equal.
5
+ *
6
+ * Each glyph is a one-column text symbol. Never append U+FE0F: it asks the
7
+ * terminal for a two-column colour emoji.
8
+ */
9
+ export declare const STATUS_GLYPHS: {
10
+ readonly warming: "⧖";
11
+ readonly connected: "✔";
12
+ readonly recalling: "↻";
13
+ readonly capturing: "✎";
14
+ readonly error: "✘";
15
+ };
16
+ export type StatusGlyphState = keyof typeof STATUS_GLYPHS;
17
+ /** States that need an action keep their words after the error glyph. */
18
+ export type StatusWordState = "web app off" | "not installed";
19
+ export declare function ommsStatusLabel(state: StatusGlyphState | StatusWordState): string;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Footer label for the Pi and OpenCode adapters. Claude Code cannot import
3
+ * from `src/`, so `hooks/omms-status-glyphs.js` holds a twin of this file and
4
+ * `tests/status-glyph-parity.test.ts` keeps the two equal.
5
+ *
6
+ * Each glyph is a one-column text symbol. Never append U+FE0F: it asks the
7
+ * terminal for a two-column colour emoji.
8
+ */
9
+ export const STATUS_GLYPHS = {
10
+ warming: "⧖",
11
+ connected: "✔",
12
+ recalling: "↻",
13
+ capturing: "✎",
14
+ error: "✘",
15
+ };
16
+ export function ommsStatusLabel(state) {
17
+ if (state === "web app off" || state === "not installed") {
18
+ return `omms: ${STATUS_GLYPHS.error} ${state}`;
19
+ }
20
+ return `omms: ${STATUS_GLYPHS[state]}`;
21
+ }
@@ -6,3 +6,14 @@ export declare function latestOpenCleanup(profileId: string): Promise<{
6
6
  renames: CategoryRename[];
7
7
  } | null>;
8
8
  export declare function markCleanupUndone(id: number): Promise<void>;
9
+ /** What the profile model answered for one group: the labels it kept and the name it proposed. */
10
+ export interface SavedVerdict {
11
+ keep: string[];
12
+ name: string | null;
13
+ }
14
+ export declare const VERDICT_MAX_AGE_MS: number;
15
+ /** Saves the answers by group id. Only category names are stored, never descriptions. */
16
+ export declare function saveVerdicts(profileId: string, verdicts: Map<string, SavedVerdict>, now?: number): Promise<void>;
17
+ export declare function loadVerdicts(profileId: string): Promise<Map<string, SavedVerdict>>;
18
+ /** Deletes the profile's answers that are too old or belong to a group that no longer exists. */
19
+ export declare function pruneVerdicts(profileId: string, currentGroupIds: Iterable<string>, maxAgeMs?: number, now?: number): Promise<void>;
@@ -2,7 +2,10 @@ import { join } from "node:path";
2
2
  import { CONFIG } from "../../config.js";
3
3
  import { tursoConnectionManager } from "../turso/connection-manager.js";
4
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. */
5
+ /**
6
+ * Undo records for category cleanups (the profile changelog keeps too few entries for undo)
7
+ * and the profile model's saved verdicts per group, so a new preview asks only about new groups.
8
+ */
6
9
  async function connection() {
7
10
  const db = await tursoConnectionManager.getConnection(join(CONFIG.storagePath || "", USER_PROFILES_DB_NAME));
8
11
  await db.execute(`CREATE TABLE IF NOT EXISTS profile_category_cleanups (
@@ -11,6 +14,14 @@ async function connection() {
11
14
  created_at INTEGER NOT NULL,
12
15
  renames TEXT NOT NULL,
13
16
  undone_at INTEGER
17
+ )`);
18
+ await db.execute(`CREATE TABLE IF NOT EXISTS profile_category_verdicts (
19
+ profile_id TEXT NOT NULL,
20
+ group_id TEXT NOT NULL,
21
+ keep TEXT NOT NULL,
22
+ name TEXT,
23
+ created_at INTEGER NOT NULL,
24
+ PRIMARY KEY (profile_id, group_id)
14
25
  )`);
15
26
  return db;
16
27
  }
@@ -33,3 +44,48 @@ export async function markCleanupUndone(id) {
33
44
  id,
34
45
  ]);
35
46
  }
47
+ export const VERDICT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
48
+ const DELETE_CHUNK = 200;
49
+ /** Saves the answers by group id. Only category names are stored, never descriptions. */
50
+ export async function saveVerdicts(profileId, verdicts, now = Date.now()) {
51
+ const db = await connection();
52
+ for (const [groupId, verdict] of verdicts) {
53
+ await db.execute("INSERT OR REPLACE INTO profile_category_verdicts (profile_id, group_id, keep, name, created_at) VALUES (?, ?, ?, ?, ?)", [profileId, groupId, JSON.stringify(verdict.keep), verdict.name, now]);
54
+ }
55
+ }
56
+ export async function loadVerdicts(profileId) {
57
+ const db = await connection();
58
+ const rows = await db.all("SELECT group_id, keep, name FROM profile_category_verdicts WHERE profile_id = ?", [profileId]);
59
+ const verdicts = new Map();
60
+ for (const row of rows) {
61
+ let keep;
62
+ try {
63
+ keep = JSON.parse(String(row.keep));
64
+ }
65
+ catch {
66
+ continue;
67
+ }
68
+ // A row that is not a list of names is skipped, so its group is asked again.
69
+ if (!Array.isArray(keep))
70
+ continue;
71
+ verdicts.set(String(row.group_id), {
72
+ keep: keep.filter((label) => typeof label === "string"),
73
+ name: row.name == null ? null : String(row.name),
74
+ });
75
+ }
76
+ return verdicts;
77
+ }
78
+ /** Deletes the profile's answers that are too old or belong to a group that no longer exists. */
79
+ export async function pruneVerdicts(profileId, currentGroupIds, maxAgeMs = VERDICT_MAX_AGE_MS, now = Date.now()) {
80
+ const db = await connection();
81
+ await db.execute("DELETE FROM profile_category_verdicts WHERE profile_id = ? AND created_at < ?", [profileId, now - maxAgeMs]);
82
+ const current = new Set(currentGroupIds);
83
+ const rows = await db.all("SELECT group_id FROM profile_category_verdicts WHERE profile_id = ?", [
84
+ profileId,
85
+ ]);
86
+ const gone = rows.map((row) => String(row.group_id)).filter((id) => !current.has(id));
87
+ for (let start = 0; start < gone.length; start += DELETE_CHUNK) {
88
+ const chunk = gone.slice(start, start + DELETE_CHUNK);
89
+ await db.execute(`DELETE FROM profile_category_verdicts WHERE profile_id = ? AND group_id IN (${chunk.map(() => "?").join(",")})`, [profileId, ...chunk]);
90
+ }
91
+ }
@@ -1,4 +1,5 @@
1
1
  import { type CategoryGroup } from "./category-groups.js";
2
+ import { type SavedVerdict } from "./category-cleanup-store.js";
2
3
  import type { UserProfileManager } from "./user-profile-manager.js";
3
4
  export declare const GROUPS_PER_MODEL_CALL = 20;
4
5
  export declare const MAX_EXAMPLES_PER_LABEL = 3;
@@ -16,14 +17,20 @@ export interface NamedGroup extends CategoryGroup {
16
17
  /** False when the model did not check this group and the most used label was chosen. */
17
18
  checked: boolean;
18
19
  }
20
+ /** The model's saved answers by group id, and where to save new ones. */
21
+ export interface VerdictCache {
22
+ saved: Map<string, SavedVerdict>;
23
+ save: (verdicts: Map<string, SavedVerdict>) => Promise<void>;
24
+ }
19
25
  /**
20
26
  * 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.
27
+ * and proposes one name. A group with a saved answer skips the model. A failed batch, a
28
+ * missing reply, or no model falls back to the most used label, marks the group unchecked
29
+ * and saves nothing, so a later preview retries it. The log holds counts and codes only.
23
30
  */
24
31
  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[]>;
32
+ /** Called with batches to run and the total, before the first batch and after each one. */
33
+ onProgress?: (done: number, total: number) => void, cache?: VerdictCache): Promise<NamedGroup[]>;
27
34
  /** The host model when one is registered, else the external API, else none. */
28
35
  export declare function resolveCleanupModel(): Promise<CleanupModel | null>;
29
36
  export interface CleanupDeps {
@@ -38,7 +45,6 @@ export interface CleanupDeps {
38
45
  }
39
46
  export interface CategoryCleanupPreview {
40
47
  profileId: string;
41
- version: number;
42
48
  /** Distinct categories now, and if every group were accepted. */
43
49
  before: number;
44
50
  after: number;
@@ -65,9 +71,12 @@ export type UndoResult = {
65
71
  };
66
72
  /** Builds the groups for review. Changes nothing in the stored profile. */
67
73
  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. */
74
+ /**
75
+ * Renames the categories of the accepted groups on the profile as stored now. Learning may
76
+ * have saved newer versions since the preview; the rename goes by label, so that is safe.
77
+ * It refuses only when a save lands between the read and the write below.
78
+ */
69
79
  export declare function applyCategoryCleanup(userId: string, input: {
70
- version: number;
71
80
  groups: AcceptedGroup[];
72
81
  }, deps?: CleanupDeps): Promise<ApplyResult>;
73
82
  /** Restores the categories of the newest cleanup that is not undone yet. */