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
|
@@ -9,7 +9,7 @@ import { applySafeExtraParams } from "../ai/providers/base-provider.js";
|
|
|
9
9
|
import { completeStructured, resolveHostProfileModel } from "./profile-model.js";
|
|
10
10
|
import { CATEGORY_GROUP_THRESHOLD, collectCategoryLabels, groupCategoryLabels, groupId, recordRenames, restoreRenames, } from "./category-groups.js";
|
|
11
11
|
import { canonicaliseCategories, categoryKey, tidyCategory } from "./profile-category.js";
|
|
12
|
-
import { latestOpenCleanup, markCleanupUndone, recordCleanup } from "./category-cleanup-store.js";
|
|
12
|
+
import { latestOpenCleanup, loadVerdicts, markCleanupUndone, pruneVerdicts, recordCleanup, saveVerdicts, } from "./category-cleanup-store.js";
|
|
13
13
|
export const GROUPS_PER_MODEL_CALL = 20;
|
|
14
14
|
export const MAX_EXAMPLES_PER_LABEL = 3;
|
|
15
15
|
/**
|
|
@@ -83,23 +83,43 @@ function applyReply(group, answer) {
|
|
|
83
83
|
: labels[0].label;
|
|
84
84
|
return { id: groupId(labels), labels, name, checked: true };
|
|
85
85
|
}
|
|
86
|
+
function verdictOf(answer) {
|
|
87
|
+
if (!answer || !Array.isArray(answer.keep))
|
|
88
|
+
return null;
|
|
89
|
+
return {
|
|
90
|
+
keep: answer.keep.filter((name) => typeof name === "string"),
|
|
91
|
+
name: typeof answer.name === "string" ? answer.name : null,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
86
94
|
/**
|
|
87
95
|
* The profile model checks each group in batches: it removes names that do not belong
|
|
88
|
-
* and proposes one name. A
|
|
89
|
-
*
|
|
96
|
+
* and proposes one name. A group with a saved answer skips the model. A failed batch, a
|
|
97
|
+
* missing reply, or no model falls back to the most used label, marks the group unchecked
|
|
98
|
+
* and saves nothing, so a later preview retries it. The log holds counts and codes only.
|
|
90
99
|
*/
|
|
91
100
|
export async function checkAndNameGroups(groups, examples, model,
|
|
92
|
-
/** Called with batches
|
|
93
|
-
onProgress) {
|
|
101
|
+
/** Called with batches to run and the total, before the first batch and after each one. */
|
|
102
|
+
onProgress, cache) {
|
|
103
|
+
const named = new Map();
|
|
104
|
+
const pending = [];
|
|
105
|
+
for (const group of groups) {
|
|
106
|
+
const saved = cache?.saved.get(group.id);
|
|
107
|
+
if (saved)
|
|
108
|
+
named.set(group.id, applyReply(group, saved));
|
|
109
|
+
else
|
|
110
|
+
pending.push(group);
|
|
111
|
+
}
|
|
94
112
|
const batches = [];
|
|
95
|
-
for (let start = 0; start <
|
|
96
|
-
batches.push(
|
|
113
|
+
for (let start = 0; start < pending.length; start += GROUPS_PER_MODEL_CALL) {
|
|
114
|
+
batches.push(pending.slice(start, start + GROUPS_PER_MODEL_CALL));
|
|
97
115
|
}
|
|
98
116
|
let failedBatches = 0;
|
|
99
|
-
let dropped = 0;
|
|
100
117
|
const checkBatch = async (batch) => {
|
|
101
|
-
if (!model)
|
|
102
|
-
|
|
118
|
+
if (!model) {
|
|
119
|
+
for (const group of batch)
|
|
120
|
+
named.set(group.id, fallback(group));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
103
123
|
let replies;
|
|
104
124
|
try {
|
|
105
125
|
replies = readReply(await model(SYSTEM_PROMPT, buildPrompt(batch, examples)));
|
|
@@ -110,34 +130,50 @@ onProgress) {
|
|
|
110
130
|
groups: batch.length,
|
|
111
131
|
code: error instanceof Error ? error.name : "unknown",
|
|
112
132
|
});
|
|
113
|
-
|
|
133
|
+
for (const group of batch)
|
|
134
|
+
named.set(group.id, fallback(group));
|
|
135
|
+
return;
|
|
114
136
|
}
|
|
115
|
-
const
|
|
137
|
+
const verdicts = new Map();
|
|
116
138
|
for (const group of batch) {
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
139
|
+
const answer = replies.get(group.id);
|
|
140
|
+
named.set(group.id, applyReply(group, answer));
|
|
141
|
+
const verdict = verdictOf(answer);
|
|
142
|
+
if (verdict)
|
|
143
|
+
verdicts.set(group.id, verdict);
|
|
144
|
+
}
|
|
145
|
+
if (cache && verdicts.size > 0) {
|
|
146
|
+
try {
|
|
147
|
+
await cache.save(verdicts);
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
log("category cleanup: verdict save failed", {
|
|
151
|
+
groups: verdicts.size,
|
|
152
|
+
code: error instanceof Error ? error.name : "unknown",
|
|
153
|
+
});
|
|
154
|
+
}
|
|
122
155
|
}
|
|
123
|
-
return results;
|
|
124
156
|
};
|
|
125
157
|
// One call at a time: a provider that refuses parallel calls still finishes (TDR-044).
|
|
126
|
-
const named = [];
|
|
127
158
|
onProgress?.(0, batches.length);
|
|
128
159
|
for (const [index, batch] of batches.entries()) {
|
|
129
|
-
|
|
160
|
+
await checkBatch(batch);
|
|
130
161
|
onProgress?.(index + 1, batches.length);
|
|
131
162
|
}
|
|
163
|
+
const result = groups
|
|
164
|
+
.map((group) => named.get(group.id))
|
|
165
|
+
.filter((group) => !!group);
|
|
132
166
|
log("category cleanup: groups checked", {
|
|
133
167
|
groups: groups.length,
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
168
|
+
reused: groups.length - pending.length,
|
|
169
|
+
sent: pending.length,
|
|
170
|
+
named: result.length,
|
|
171
|
+
dropped: groups.length - result.length,
|
|
172
|
+
unchecked: result.filter((group) => !group.checked).length,
|
|
137
173
|
failedBatches,
|
|
138
174
|
model: model ? "available" : "none",
|
|
139
175
|
});
|
|
140
|
-
return
|
|
176
|
+
return result;
|
|
141
177
|
}
|
|
142
178
|
/** The host model when one is registered, else the external API, else none. */
|
|
143
179
|
export async function resolveCleanupModel() {
|
|
@@ -219,28 +255,53 @@ function countAfter(labels, groups) {
|
|
|
219
255
|
}
|
|
220
256
|
return new Set(labels.map((entry) => categoryKey(rename.get(entry.label) ?? entry.label))).size;
|
|
221
257
|
}
|
|
258
|
+
/** Opens the saved model answers for the groups; without the store the preview just asks the model. */
|
|
259
|
+
async function openVerdictCache(profileId, groupIds) {
|
|
260
|
+
try {
|
|
261
|
+
await pruneVerdicts(profileId, groupIds);
|
|
262
|
+
return {
|
|
263
|
+
saved: await loadVerdicts(profileId),
|
|
264
|
+
save: (verdicts) => saveVerdicts(profileId, verdicts),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
log("category cleanup: verdict store unavailable", {
|
|
269
|
+
code: error instanceof Error ? error.name : "unknown",
|
|
270
|
+
});
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
222
274
|
/** Builds the groups for review. Changes nothing in the stored profile. */
|
|
223
275
|
export async function previewCategoryCleanup(userId, deps = {}) {
|
|
224
276
|
const manager = await managerOf(deps);
|
|
225
277
|
const profile = await manager.getActiveProfile(userId);
|
|
226
278
|
if (!profile)
|
|
227
279
|
return null;
|
|
280
|
+
const started = Date.now();
|
|
228
281
|
const data = readProfile(profile.profileData);
|
|
229
282
|
const labels = collectCategoryLabels(data);
|
|
230
283
|
const embed = deps.embed ?? (await defaultEmbed());
|
|
231
284
|
const vectors = [];
|
|
232
285
|
for (const entry of labels)
|
|
233
286
|
vectors.push({ ...entry, vector: await embed(entry.label) });
|
|
287
|
+
const embedded = Date.now();
|
|
234
288
|
const groups = groupCategoryLabels(vectors, deps.threshold ?? CATEGORY_GROUP_THRESHOLD);
|
|
289
|
+
const grouped = Date.now();
|
|
290
|
+
const cache = await openVerdictCache(profile.id, groups.map((group) => group.id));
|
|
235
291
|
const model = deps.model === undefined ? await resolveCleanupModel() : deps.model;
|
|
236
|
-
const named = await checkAndNameGroups(groups, examplesByLabel(data), model, deps.onProgress);
|
|
292
|
+
const named = await checkAndNameGroups(groups, examplesByLabel(data), model, deps.onProgress, cache);
|
|
293
|
+
const reused = groups.filter((group) => cache?.saved.has(group.id)).length;
|
|
237
294
|
log("category cleanup: preview built", {
|
|
238
295
|
labels: labels.length,
|
|
239
296
|
groups: named.length,
|
|
297
|
+
reusedGroups: reused,
|
|
298
|
+
sentGroups: groups.length - reused,
|
|
299
|
+
embedMs: embedded - started,
|
|
300
|
+
groupMs: grouped - embedded,
|
|
301
|
+
checkMs: Date.now() - grouped,
|
|
240
302
|
});
|
|
241
303
|
return {
|
|
242
304
|
profileId: profile.id,
|
|
243
|
-
version: profile.version,
|
|
244
305
|
before: labels.length,
|
|
245
306
|
after: countAfter(labels, named),
|
|
246
307
|
groups: named,
|
|
@@ -255,16 +316,18 @@ function validGroups(groups) {
|
|
|
255
316
|
typeof group.name === "string" &&
|
|
256
317
|
group.name.trim().length > 0));
|
|
257
318
|
}
|
|
258
|
-
/**
|
|
319
|
+
/**
|
|
320
|
+
* Renames the categories of the accepted groups on the profile as stored now. Learning may
|
|
321
|
+
* have saved newer versions since the preview; the rename goes by label, so that is safe.
|
|
322
|
+
* It refuses only when a save lands between the read and the write below.
|
|
323
|
+
*/
|
|
259
324
|
export async function applyCategoryCleanup(userId, input, deps = {}) {
|
|
260
|
-
if (!
|
|
325
|
+
if (!validGroups(input?.groups))
|
|
261
326
|
return { ok: false, error: "invalid" };
|
|
262
327
|
const manager = await managerOf(deps);
|
|
263
328
|
const profile = await manager.getActiveProfile(userId);
|
|
264
329
|
if (!profile)
|
|
265
330
|
return { ok: false, error: "no_profile" };
|
|
266
|
-
if (profile.version !== input.version)
|
|
267
|
-
return { ok: false, error: "stale" };
|
|
268
331
|
const data = readProfile(profile.profileData);
|
|
269
332
|
const rename = new Map();
|
|
270
333
|
for (const group of input.groups) {
|
|
@@ -281,7 +344,7 @@ export async function applyCategoryCleanup(userId, input, deps = {}) {
|
|
|
281
344
|
if (renames.length === 0)
|
|
282
345
|
return { ok: true, renamed: 0 };
|
|
283
346
|
const summary = `Category cleanup: ${renames.length} items renamed in ${input.groups.length} groups`;
|
|
284
|
-
if (!(await manager.updateProfile(profile.id, next, 0, summary,
|
|
347
|
+
if (!(await manager.updateProfile(profile.id, next, 0, summary, profile.version))) {
|
|
285
348
|
return { ok: false, error: "stale" };
|
|
286
349
|
}
|
|
287
350
|
await recordCleanup(profile.id, renames);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { UserProfile, UserProfileData } from "./types.js";
|
|
2
|
+
/** Learning makes this many save attempts before it leaves the prompts for a later run. */
|
|
3
|
+
export declare const MAX_PROFILE_SAVE_ATTEMPTS = 3;
|
|
4
|
+
/** The profile store operations that a save needs. */
|
|
5
|
+
export interface ProfileSaveStore {
|
|
6
|
+
getProfileById(id: string): Promise<UserProfile | null>;
|
|
7
|
+
mergeProfileData(existing: UserProfileData, updates: Partial<UserProfileData>, embedService?: undefined, profileId?: string): Promise<UserProfileData>;
|
|
8
|
+
updateProfile(profileId: string, profileData: UserProfileData, additionalPromptsAnalyzed: number, changeSummary: string, expectedVersion: number): Promise<boolean>;
|
|
9
|
+
}
|
|
10
|
+
type ReadProfile = Pick<UserProfile, "id" | "version">;
|
|
11
|
+
/**
|
|
12
|
+
* Saves `data` over the profile as the caller read it. Returns false, and changes nothing,
|
|
13
|
+
* when another save landed since. The log holds a code only, never profile content.
|
|
14
|
+
*/
|
|
15
|
+
export declare function saveProfile(profile: ReadProfile, data: UserProfileData, summary: string, promptsAnalyzed?: number, store?: Pick<ProfileSaveStore, "updateProfile">): Promise<boolean>;
|
|
16
|
+
/**
|
|
17
|
+
* Saves the merged result of a profile analysis. When another save landed first, it loads
|
|
18
|
+
* the newer profile, merges the same model result into it, and saves again. It never calls
|
|
19
|
+
* the model. Returns false when there is nothing to save or every attempt met a newer version.
|
|
20
|
+
*/
|
|
21
|
+
export declare function saveMergedProfile(store: ProfileSaveStore, profile: ReadProfile, analysis: {
|
|
22
|
+
raw: Partial<UserProfileData>;
|
|
23
|
+
merged: UserProfileData | null;
|
|
24
|
+
}, promptsAnalyzed: number, summary: string): Promise<boolean>;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { log } from "../logger.js";
|
|
2
|
+
/** Learning makes this many save attempts before it leaves the prompts for a later run. */
|
|
3
|
+
export const MAX_PROFILE_SAVE_ATTEMPTS = 3;
|
|
4
|
+
/**
|
|
5
|
+
* Saves `data` over the profile as the caller read it. Returns false, and changes nothing,
|
|
6
|
+
* when another save landed since. The log holds a code only, never profile content.
|
|
7
|
+
*/
|
|
8
|
+
export async function saveProfile(profile, data, summary, promptsAnalyzed = 0, store) {
|
|
9
|
+
const target = store ?? (await import("./user-profile-manager.js")).userProfileManager;
|
|
10
|
+
const saved = await target.updateProfile(profile.id, data, promptsAnalyzed, summary, profile.version);
|
|
11
|
+
if (!saved)
|
|
12
|
+
log("profile save skipped", { code: "version-changed" });
|
|
13
|
+
return saved;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Saves the merged result of a profile analysis. When another save landed first, it loads
|
|
17
|
+
* the newer profile, merges the same model result into it, and saves again. It never calls
|
|
18
|
+
* the model. Returns false when there is nothing to save or every attempt met a newer version.
|
|
19
|
+
*/
|
|
20
|
+
export async function saveMergedProfile(store, profile, analysis, promptsAnalyzed, summary) {
|
|
21
|
+
let merged = analysis.merged;
|
|
22
|
+
let version = profile.version;
|
|
23
|
+
for (let attempt = 1; merged && attempt <= MAX_PROFILE_SAVE_ATTEMPTS; attempt++) {
|
|
24
|
+
if (await store.updateProfile(profile.id, merged, promptsAnalyzed, summary, version)) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
log("profile save conflict", { attempt, of: MAX_PROFILE_SAVE_ATTEMPTS });
|
|
28
|
+
if (attempt === MAX_PROFILE_SAVE_ATTEMPTS)
|
|
29
|
+
break;
|
|
30
|
+
const fresh = await store.getProfileById(profile.id);
|
|
31
|
+
if (!fresh)
|
|
32
|
+
return false;
|
|
33
|
+
const current = JSON.parse(fresh.profileData);
|
|
34
|
+
merged = await store.mergeProfileData(current, analysis.raw, undefined, fresh.id);
|
|
35
|
+
version = fresh.version;
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
@@ -20,8 +20,8 @@ export declare class UserProfileManager {
|
|
|
20
20
|
getActiveProfile(userId: string): Promise<UserProfile | null>;
|
|
21
21
|
createProfile(userId: string, displayName: string, userName: string, userEmail: string, profileData: UserProfileData, promptsAnalyzed: number): Promise<string>;
|
|
22
22
|
updateProfile(profileId: string, profileData: UserProfileData, additionalPromptsAnalyzed: number, changeSummary: string,
|
|
23
|
-
/**
|
|
24
|
-
expectedVersion
|
|
23
|
+
/** The version the caller read. The save is refused when the stored version differs. */
|
|
24
|
+
expectedVersion: number): Promise<boolean>;
|
|
25
25
|
private addChangelog;
|
|
26
26
|
private cleanupOldChangelogs;
|
|
27
27
|
getProfileChangelogs(profileId: string, limit?: number): Promise<UserProfileChangelog[]>;
|
|
@@ -274,7 +274,7 @@ export class UserProfileManager {
|
|
|
274
274
|
return id;
|
|
275
275
|
}
|
|
276
276
|
async updateProfile(profileId, profileData, additionalPromptsAnalyzed, changeSummary,
|
|
277
|
-
/**
|
|
277
|
+
/** The version the caller read. The save is refused when the stored version differs. */
|
|
278
278
|
expectedVersion) {
|
|
279
279
|
const db = await this.ready();
|
|
280
280
|
const now = Date.now();
|
|
@@ -286,7 +286,7 @@ export class UserProfileManager {
|
|
|
286
286
|
});
|
|
287
287
|
const versionRow = await db.get(`SELECT version FROM user_profiles WHERE id = ?`, [profileId]);
|
|
288
288
|
const currentVersion = Number(versionRow?.version ?? 0);
|
|
289
|
-
if (
|
|
289
|
+
if (currentVersion !== expectedVersion)
|
|
290
290
|
return false;
|
|
291
291
|
const newVersion = currentVersion + 1;
|
|
292
292
|
const changes = await db.run(`
|