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.
- package/dist/adapters/opencode/profile-learning.js +7 -6
- package/dist/adapters/opencode/tui-status.js +2 -1
- package/dist/adapters/pi/extension.js +3 -2
- package/dist/adapters/pi/profile.js +2 -1
- package/dist/cli/profile-cleanup-categories-command.js +1 -2
- package/dist/cli/profile-normalise-categories-command.js +1 -1
- package/dist/core/memory-operations.js +10 -2
- package/dist/importer/claude-hook-api.js +2 -2
- package/dist/importer/profile-backlog.d.ts +1 -1
- package/dist/importer/profile-backlog.js +3 -2
- package/dist/importer/profile-import.d.ts +1 -1
- package/dist/services/api-handlers.js +6 -6
- package/dist/services/profile-admin.d.ts +1 -1
- package/dist/services/profile-admin.js +1 -1
- package/dist/services/status-glyphs.d.ts +19 -0
- package/dist/services/status-glyphs.js +21 -0
- package/dist/services/user-profile/category-cleanup-store.d.ts +11 -0
- package/dist/services/user-profile/category-cleanup-store.js +57 -1
- package/dist/services/user-profile/category-cleanup.d.ts +16 -7
- package/dist/services/user-profile/category-cleanup.js +94 -31
- package/dist/services/user-profile/merge-and-save.d.ts +25 -0
- package/dist/services/user-profile/merge-and-save.js +38 -0
- package/dist/services/user-profile/user-profile-manager.d.ts +2 -2
- package/dist/services/user-profile/user-profile-manager.js +2 -2
- package/dist/web/assets/index-BkHg0H-_.js +127 -0
- package/dist/web/assets/index-D2yC6v1B.css +2 -0
- package/dist/web/index.html +2 -2
- package/opencode/tui.tsx +3 -3
- package/package.json +1 -1
- package/dist/web/assets/index-B46sRbG_.js +0 -127
- 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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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" ? "
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
176
|
-
await userProfileManager.
|
|
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
|
|
453
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
|
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
|
|
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, {
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
22
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
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. */
|