om-memory-system 4.20.0 → 4.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/adapters/opencode/profile-learning.d.ts +0 -1
  2. package/dist/adapters/opencode/profile-learning.js +9 -35
  3. package/dist/adapters/pi/profile.js +17 -0
  4. package/dist/config.d.ts +1 -1
  5. package/dist/config.js +5 -4
  6. package/dist/importer/claude-hook-api.js +4 -66
  7. package/dist/importer/claude-profile-learning.d.ts +7 -0
  8. package/dist/importer/claude-profile-learning.js +78 -0
  9. package/dist/services/ai-cleanup-handlers.js +15 -15
  10. package/dist/services/auto-cleanup-handlers.d.ts +22 -0
  11. package/dist/services/auto-cleanup-handlers.js +71 -0
  12. package/dist/services/cleanup-service.js +9 -0
  13. package/dist/services/global-config-writer.js +15 -1
  14. package/dist/services/language-detector.js +14 -0
  15. package/dist/services/profile-cleanup-routes.d.ts +6 -0
  16. package/dist/services/profile-cleanup-routes.js +32 -0
  17. package/dist/services/settings-snapshot.js +5 -0
  18. package/dist/services/user-profile/ai-cleanup-apply.d.ts +21 -1
  19. package/dist/services/user-profile/ai-cleanup-apply.js +19 -5
  20. package/dist/services/user-profile/ai-cleanup-batches.d.ts +3 -2
  21. package/dist/services/user-profile/ai-cleanup-batches.js +5 -3
  22. package/dist/services/user-profile/ai-cleanup-undo.d.ts +15 -0
  23. package/dist/services/user-profile/ai-cleanup-undo.js +63 -0
  24. package/dist/services/user-profile/ai-cleanup.d.ts +2 -6
  25. package/dist/services/user-profile/ai-cleanup.js +6 -44
  26. package/dist/services/user-profile/auto-cleanup-store.d.ts +32 -0
  27. package/dist/services/user-profile/auto-cleanup-store.js +97 -0
  28. package/dist/services/user-profile/auto-cleanup-trigger.d.ts +10 -0
  29. package/dist/services/user-profile/auto-cleanup-trigger.js +12 -0
  30. package/dist/services/user-profile/auto-cleanup.d.ts +32 -0
  31. package/dist/services/user-profile/auto-cleanup.js +102 -0
  32. package/dist/services/user-profile/profile-edit-save.d.ts +35 -0
  33. package/dist/services/user-profile/profile-edit-save.js +26 -0
  34. package/dist/services/web-server.js +8 -14
  35. package/dist/web/assets/index-7385lY75.css +2 -0
  36. package/dist/web/assets/index-C9y78EH_.js +127 -0
  37. package/dist/web/index.html +2 -2
  38. package/package.json +1 -1
  39. package/dist/web/assets/index-CYcRfxr4.css +0 -2
  40. package/dist/web/assets/index-DvNL0vOb.js +0 -127
@@ -7,10 +7,24 @@ const FALLBACK_MAP = {
7
7
  arz: "ar", // Egyptian Arabic
8
8
  hbs: "sr", // Serbo-Croatian
9
9
  };
10
+ // franc guesses wrongly on short Latin-script text (for example "ok go ahead" as Somali),
11
+ // and the capture prompt then asks the model to write the memory in that language.
12
+ // Below this length, Latin-script text counts as English. Non-Latin scripts stay reliable.
13
+ const MIN_LATIN_DETECT_LENGTH = 100;
14
+ function isMostlyLatin(text) {
15
+ const letters = text.match(/\p{L}/gu) ?? [];
16
+ if (letters.length === 0)
17
+ return true;
18
+ const latin = letters.filter((char) => /\p{Script=Latin}/u.test(char)).length;
19
+ return latin / letters.length >= 0.5;
20
+ }
10
21
  export function detectLanguage(text) {
11
22
  if (!text || text.trim().length === 0) {
12
23
  return "en";
13
24
  }
25
+ if (text.trim().length < MIN_LATIN_DETECT_LENGTH && isMostlyLatin(text)) {
26
+ return "en";
27
+ }
14
28
  const detected = franc(text, { minLength: 5 });
15
29
  if (detected === "und") {
16
30
  return "en";
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The profile cleanup routes: AI Cleanup (start, status, stop, apply) and the undo of the
3
+ * last automatic cleanup. Returns the JSON body, or undefined when the path and method are
4
+ * not a cleanup route. The body is read only for the routes that need it.
5
+ */
6
+ export declare function routeProfileCleanup(path: string, method: string, url: URL, readBody: () => Promise<any>): Promise<unknown | undefined>;
@@ -0,0 +1,32 @@
1
+ import * as aiCleanup from "./ai-cleanup-handlers.js";
2
+ import * as autoCleanup from "./auto-cleanup-handlers.js";
3
+ /**
4
+ * The profile cleanup routes: AI Cleanup (start, status, stop, apply) and the undo of the
5
+ * last automatic cleanup. Returns the JSON body, or undefined when the path and method are
6
+ * not a cleanup route. The body is read only for the routes that need it.
7
+ */
8
+ export async function routeProfileCleanup(path, method, url, readBody) {
9
+ const queryUser = url.searchParams.get("userId") || undefined;
10
+ if (path === "/api/user-profile/ai-cleanup") {
11
+ if (method === "POST") {
12
+ const body = await readBody();
13
+ return aiCleanup.handleAiCleanupStart(body.userId, body);
14
+ }
15
+ if (method === "GET")
16
+ return aiCleanup.handleAiCleanupStatus(queryUser);
17
+ if (method === "DELETE")
18
+ return aiCleanup.handleAiCleanupStop(queryUser);
19
+ }
20
+ if (path === "/api/user-profile/ai-cleanup/apply" && method === "POST") {
21
+ const body = await readBody();
22
+ return aiCleanup.handleAiCleanupApply(body.userId, body);
23
+ }
24
+ if (path === "/api/user-profile/auto-cleanup" && method === "GET") {
25
+ return autoCleanup.handleAutoCleanupStatus(queryUser);
26
+ }
27
+ if (path === "/api/user-profile/auto-cleanup/undo" && method === "POST") {
28
+ const body = await readBody();
29
+ return autoCleanup.handleAutoCleanupUndo(body.userId || undefined);
30
+ }
31
+ return undefined;
32
+ }
@@ -20,12 +20,15 @@ const editable = [
20
20
  "opencodeBackfillModel",
21
21
  "piBackfillModel",
22
22
  "webServerAutoStart",
23
+ "userProfileAutoCleanupEnabled",
24
+ "userProfileAutoCleanupInterval",
23
25
  "memoryProvider",
24
26
  "memoryApiUrl",
25
27
  "memoryModel",
26
28
  "importPathMaps",
27
29
  "importIgnoredDirectories",
28
30
  "claudeConfigDir",
31
+ "autoCaptureLanguage",
29
32
  ...MEMORY_LIMIT_SETTINGS,
30
33
  ];
31
34
  /** Keys a project config cannot override, so the page always shows the global value. */
@@ -35,6 +38,8 @@ const globalOnly = [
35
38
  "opencodeBackfillModel",
36
39
  "piBackfillModel",
37
40
  "webServerAutoStart",
41
+ "userProfileAutoCleanupEnabled",
42
+ "userProfileAutoCleanupInterval",
38
43
  "memoryProvider",
39
44
  "memoryApiUrl",
40
45
  "importPathMaps",
@@ -1,5 +1,6 @@
1
1
  import type { CleanupDiff } from "./ai-cleanup.js";
2
2
  import type { UserProfileData } from "./types.js";
3
+ export type Section = "preferences" | "patterns" | "workflows";
3
4
  /** What a finished cleanup job keeps for Apply. Ids are positions in `old`. */
4
5
  export interface CleanupJobResult {
5
6
  old: UserProfileData;
@@ -10,10 +11,25 @@ export interface AcceptedCleanup {
10
11
  merged?: string[][];
11
12
  removed?: string[];
12
13
  }
14
+ /**
15
+ * What one applied change replaced, as stored. Undo reverses it. A merge holds the original
16
+ * items and the merged item that was written. A removal holds the original item.
17
+ */
18
+ export type RestoreEntry = {
19
+ kind: "merge";
20
+ section: Section;
21
+ originals: unknown[];
22
+ merged: unknown;
23
+ } | {
24
+ kind: "remove";
25
+ section: Section;
26
+ original: unknown;
27
+ };
13
28
  export interface AppliedCleanup {
14
29
  profile: UserProfileData;
15
30
  applied: number;
16
31
  skipped: number;
32
+ restore: RestoreEntry[];
17
33
  }
18
34
  /**
19
35
  * Puts the ticked changes of a job into `current`, which may be newer than the profile
@@ -23,5 +39,9 @@ export interface AppliedCleanup {
23
39
  * the job are ignored.
24
40
  */
25
41
  export declare function applyCleanupEdits(current: UserProfileData, job: CleanupJobResult, accepted: AcceptedCleanup): AppliedCleanup;
26
- /** The fields that identify a profile item. Counters and vectors are left out. */
42
+ /**
43
+ * The fields that identify a profile item. Counters and vectors are left out. The category
44
+ * counts by its key, because every save keeps one visible spelling per category and may
45
+ * rewrite the spelling of a stored item.
46
+ */
27
47
  export declare function profileItemIdentityKey(item: any): string;
@@ -1,3 +1,4 @@
1
+ import { categoryKey } from "./profile-category.js";
1
2
  const SECTION_BY_PREFIX = {
2
3
  pref: "preferences",
3
4
  pat: "patterns",
@@ -20,6 +21,7 @@ export function applyCleanupEdits(current, job, accepted) {
20
21
  const merges = new Map(job.changes.merged.map((m) => [m.ids.join(","), m.ids]));
21
22
  const removals = new Set(job.changes.removed.map((r) => r.id));
22
23
  const used = new Set();
24
+ const restore = [];
23
25
  let applied = 0;
24
26
  let skipped = 0;
25
27
  for (const ticked of accepted.merged ?? []) {
@@ -33,7 +35,14 @@ export function applyCleanupEdits(current, job, accepted) {
33
35
  continue;
34
36
  }
35
37
  const items = profile[found.section];
36
- items[found.indices[0]] = { ...mergedItem };
38
+ const written = { ...mergedItem };
39
+ restore.push({
40
+ kind: "merge",
41
+ section: found.section,
42
+ originals: found.indices.map((index) => items[index]),
43
+ merged: written,
44
+ });
45
+ items[found.indices[0]] = written;
37
46
  for (const index of found.indices.slice(1).sort((a, b) => b - a))
38
47
  items.splice(index, 1);
39
48
  for (const id of ids)
@@ -48,11 +57,12 @@ export function applyCleanupEdits(current, job, accepted) {
48
57
  skipped++;
49
58
  continue;
50
59
  }
51
- profile[found.section].splice(found.indices[0], 1);
60
+ const [removed] = profile[found.section].splice(found.indices[0], 1);
61
+ restore.push({ kind: "remove", section: found.section, original: removed });
52
62
  used.add(id);
53
63
  applied++;
54
64
  }
55
- return { profile, applied, skipped };
65
+ return { profile, applied, skipped, restore };
56
66
  }
57
67
  /**
58
68
  * Finds each id's item from `old` in `profile`, at a distinct position, all in one
@@ -84,10 +94,14 @@ function sectionOf(id) {
84
94
  function positionOf(id) {
85
95
  return Number.parseInt(id.split("_")[1] ?? "", 10);
86
96
  }
87
- /** The fields that identify a profile item. Counters and vectors are left out. */
97
+ /**
98
+ * The fields that identify a profile item. Counters and vectors are left out. The category
99
+ * counts by its key, because every save keeps one visible spelling per category and may
100
+ * rewrite the spelling of a stored item.
101
+ */
88
102
  export function profileItemIdentityKey(item) {
89
103
  return JSON.stringify({
90
- category: item.category ?? null,
104
+ category: item.category == null ? null : categoryKey(String(item.category)),
91
105
  description: item.description ?? null,
92
106
  steps: Array.isArray(item.steps) ? item.steps : null,
93
107
  });
@@ -1,3 +1,4 @@
1
+ import type { ModelPort } from "../../core/profile-analysis.js";
1
2
  import type { UserProfileData } from "./types.js";
2
3
  import { type CleanupDiff, type IndexedProfile } from "./ai-cleanup.js";
3
4
  /** What one batch adds to the review: its changes, and the merged item of each merge. */
@@ -22,8 +23,8 @@ export declare function selectForCleanup(profileData: UserProfileData, includeId
22
23
  * inside a section.
23
24
  */
24
25
  export declare function splitIntoBatches(indexed: IndexedProfile, size: number): IndexedProfile[];
25
- /** One model request for one batch. */
26
- export declare function cleanupBatch(indexed: IndexedProfile): Promise<BatchCleanupResult>;
26
+ /** One model request for one batch. A caller with its own model passes it. */
27
+ export declare function cleanupBatch(indexed: IndexedProfile, model?: ModelPort | null): Promise<BatchCleanupResult>;
27
28
  /**
28
29
  * Runs the batches one after another and joins their results. A failed batch adds
29
30
  * nothing, so its items stay as they are. Throws the first error when every batch
@@ -64,12 +64,12 @@ function groupByCategory(items) {
64
64
  }
65
65
  return [...groups.keys()].sort().map((key) => groups.get(key));
66
66
  }
67
- /** One model request for one batch. */
68
- export async function cleanupBatch(indexed) {
67
+ /** One model request for one batch. A caller with its own model passes it. */
68
+ export async function cleanupBatch(indexed, model) {
69
69
  const t0 = Date.now();
70
70
  const prompt = buildAICleanupPrompt(indexed);
71
71
  log("AI cleanup: batch prompt built", { items: countItems(indexed), promptLen: prompt.length });
72
- const result = await callAICleanup(prompt);
72
+ const result = await callAICleanup(prompt, model);
73
73
  log("AI cleanup: batch reply received", { callMs: Date.now() - t0 });
74
74
  const cleanedById = buildItemIndex(result.profile);
75
75
  const originalById = buildItemIndex(indexed);
@@ -169,6 +169,8 @@ export function failureReason(error) {
169
169
  if (error instanceof SyntaxError || /valid JSON|No content/.test(error.message)) {
170
170
  return "invalid-reply";
171
171
  }
172
+ if (/No AI provider configured/.test(error.message))
173
+ return "no-provider";
172
174
  }
173
175
  return "error";
174
176
  }
@@ -0,0 +1,15 @@
1
+ import type { RestoreEntry } from "./ai-cleanup-apply.js";
2
+ import type { UserProfileData } from "./types.js";
3
+ export interface UndoResult {
4
+ profile: UserProfileData;
5
+ restored: number;
6
+ notFound: number;
7
+ }
8
+ /**
9
+ * Reverses the changes of one automatic cleanup in `current`, which may be newer than the
10
+ * profile the run saved. A merge is undone by taking out the merged item and putting its
11
+ * originals back. A removal is undone by putting the original back. A change counts as
12
+ * not found, and is left alone, when the merged item is gone or was reworded, or when an
13
+ * original is already in the profile. Every other item stays as stored.
14
+ */
15
+ export declare function undoCleanupEdits(current: UserProfileData, entries: RestoreEntry[]): UndoResult;
@@ -0,0 +1,63 @@
1
+ import { profileItemIdentityKey } from "./ai-cleanup-apply.js";
2
+ const SECTIONS = ["preferences", "patterns", "workflows"];
3
+ function keyOf(section, item) {
4
+ return `${section}|${profileItemIdentityKey(item)}`;
5
+ }
6
+ /**
7
+ * Reverses the changes of one automatic cleanup in `current`, which may be newer than the
8
+ * profile the run saved. A merge is undone by taking out the merged item and putting its
9
+ * originals back. A removal is undone by putting the original back. A change counts as
10
+ * not found, and is left alone, when the merged item is gone or was reworded, or when an
11
+ * original is already in the profile. Every other item stays as stored.
12
+ */
13
+ export function undoCleanupEdits(current, entries) {
14
+ const profile = {
15
+ ...current,
16
+ preferences: [...current.preferences],
17
+ patterns: [...current.patterns],
18
+ workflows: [...current.workflows],
19
+ };
20
+ // How many items of each identity the profile holds now. A restored item is not added,
21
+ // so two identical originals of the record both come back.
22
+ const present = new Map();
23
+ for (const section of SECTIONS) {
24
+ for (const item of profile[section]) {
25
+ const key = keyOf(section, item);
26
+ present.set(key, (present.get(key) ?? 0) + 1);
27
+ }
28
+ }
29
+ const claimed = new Set();
30
+ let restored = 0;
31
+ let notFound = 0;
32
+ for (const entry of entries) {
33
+ const items = profile[entry.section];
34
+ if (entry.kind === "remove") {
35
+ if ((present.get(keyOf(entry.section, entry.original)) ?? 0) > 0) {
36
+ notFound++;
37
+ continue;
38
+ }
39
+ const back = { ...entry.original };
40
+ items.push(back);
41
+ claimed.add(back);
42
+ restored++;
43
+ continue;
44
+ }
45
+ const mergedKey = keyOf(entry.section, entry.merged);
46
+ const index = items.findIndex((item) => !claimed.has(item) && keyOf(entry.section, item) === mergedKey);
47
+ const blocked = entry.originals.some((original) => {
48
+ const key = keyOf(entry.section, original);
49
+ return (present.get(key) ?? 0) - (key === mergedKey ? 1 : 0) > 0;
50
+ });
51
+ if (index < 0 || blocked) {
52
+ notFound++;
53
+ continue;
54
+ }
55
+ const back = entry.originals.map((original) => ({ ...original }));
56
+ items.splice(index, 1, ...back);
57
+ for (const item of back)
58
+ claimed.add(item);
59
+ present.set(mergedKey, (present.get(mergedKey) ?? 1) - 1);
60
+ restored++;
61
+ }
62
+ return { profile, restored, notFound };
63
+ }
@@ -1,8 +1,5 @@
1
1
  import type { UserProfileData } from "./types.js";
2
- export interface AICleanupResult {
3
- cleaned: UserProfileData;
4
- diff: CleanupDiff;
5
- }
2
+ import type { ModelPort } from "../../core/profile-analysis.js";
6
3
  export interface CleanupDiff {
7
4
  kept: string[];
8
5
  merged: Array<{
@@ -14,7 +11,6 @@ export interface CleanupDiff {
14
11
  reason: string;
15
12
  }>;
16
13
  }
17
- export declare function aiCleanupProfile(profileData: UserProfileData): Promise<AICleanupResult>;
18
14
  export interface IndexedProfileItem {
19
15
  id: string;
20
16
  category?: string;
@@ -35,7 +31,7 @@ export interface AIMapping {
35
31
  }
36
32
  export declare function addIdsToProfile(profile: UserProfileData): IndexedProfile;
37
33
  export declare function buildAICleanupPrompt(profile: IndexedProfile): string;
38
- export declare function callAICleanup(prompt: string): Promise<{
34
+ export declare function callAICleanup(prompt: string, model?: ModelPort | null): Promise<{
39
35
  profile: IndexedProfile;
40
36
  mapping: AIMapping;
41
37
  }>;
@@ -3,46 +3,6 @@ import { log } from "../logger.js";
3
3
  import { resolveHostProfileModel } from "./profile-model.js";
4
4
  import { EXTERNAL_PROFILE_CLEANUP_TIMEOUT_MS } from "../request-timeouts.js";
5
5
  import { applySafeExtraParams } from "../ai/providers/base-provider.js";
6
- export async function aiCleanupProfile(profileData) {
7
- const t0 = Date.now();
8
- const indexed = addIdsToProfile(profileData);
9
- const prompt = buildAICleanupPrompt(indexed);
10
- log("AI cleanup: prompt built", {
11
- prefCount: indexed.preferences.length,
12
- patCount: indexed.patterns.length,
13
- wfCount: indexed.workflows.length,
14
- promptLen: prompt.length,
15
- buildMs: Date.now() - t0,
16
- });
17
- const aiStart = Date.now();
18
- const result = await callAICleanup(prompt);
19
- log("AI cleanup: AI response received", { callMs: Date.now() - aiStart });
20
- const cleanedById = buildItemIndex(result.profile);
21
- const originalById = buildItemIndex(indexed);
22
- const counters = { cleaned: 0, original: 0 };
23
- const cleaned = rebuildProfileUsing(result.mapping, cleanedById, originalById, counters);
24
- const diff = generateDiff(indexed, result.mapping, cleanedById);
25
- const sampleCleaned = result.profile.preferences[0];
26
- log("AI cleanup: rebuild done", {
27
- cleanedPrefCount: result.profile.preferences.length,
28
- cleanedPatCount: result.profile.patterns.length,
29
- cleanedWfCount: result.profile.workflows.length,
30
- sampleId: sampleCleaned?.id,
31
- sampleDescLen: sampleCleaned?.description?.length,
32
- cleanedItemDescLen: cleanedById.get("pref_0")?.description?.length,
33
- originalItemDescLen: originalById.get("pref_0")?.description?.length,
34
- keptById: cleaned.preferences.length,
35
- usedCleaned: counters.cleaned,
36
- usedOriginal: counters.original,
37
- });
38
- log("AI cleanup: complete", {
39
- totalMs: Date.now() - t0,
40
- kept: diff.kept.length,
41
- merged: diff.merged.length,
42
- removed: diff.removed.length,
43
- });
44
- return { cleaned, diff };
45
- }
46
6
  // The model controls this JSON; it may omit fields or return wrong types. Coerce to a
47
7
  // well-formed AIMapping so rebuildProfileUsing/generateDiff never call .map/.filter/.includes
48
8
  // on undefined. A missing or malformed mapping degrades to a no-op cleanup (all originals are
@@ -112,10 +72,12 @@ function formatForAI(item) {
112
72
  const { id, category, description, frequency } = item;
113
73
  return { id, category, description, frequency };
114
74
  }
115
- export async function callAICleanup(prompt) {
116
- // Use the host model when the host registered one (same pattern as auto-capture).
117
- // When it is available, surface its errors instead of masking them as
118
- // "No AI provider configured" via a silent fallback (#177).
75
+ export async function callAICleanup(prompt, model) {
76
+ // A caller that has its own model (Pi) passes it. Otherwise use the host model when the
77
+ // host registered one (same pattern as auto-capture). When it is available, surface its
78
+ // errors instead of masking them as "No AI provider configured" via a silent fallback (#177).
79
+ if (model)
80
+ return callViaHostModel(model, prompt);
119
81
  const hostModel = await resolveHostProfileModel();
120
82
  if (hostModel) {
121
83
  return callViaHostModel(hostModel, prompt);
@@ -0,0 +1,32 @@
1
+ import type { RestoreEntry } from "./ai-cleanup-apply.js";
2
+ /** A record can be undone for this long, then it is deleted. */
3
+ export declare const AUTO_CLEANUP_RECORD_MAX_AGE_MS: number;
4
+ export interface AutoCleanupRecordInput {
5
+ promptsAnalyzed: number;
6
+ merged: number;
7
+ removed: number;
8
+ entries: RestoreEntry[];
9
+ }
10
+ export interface AutoCleanupSummary {
11
+ id: number;
12
+ createdAt: number;
13
+ merged: number;
14
+ removed: number;
15
+ }
16
+ export interface AutoCleanupRecord extends AutoCleanupSummary {
17
+ promptsAnalyzed: number;
18
+ entries: RestoreEntry[];
19
+ }
20
+ export declare function insertAutoCleanupRecord(profileId: string, record: AutoCleanupRecordInput, now?: number): Promise<number>;
21
+ /**
22
+ * Deletes every record older than 30 days, for all profiles. The daily cleanup and the web
23
+ * app start call it, so a record does not outlive its limit when nothing else touches the
24
+ * table. Returns how many rows it deleted.
25
+ */
26
+ export declare function pruneAutoCleanupRecords(now?: number): Promise<number>;
27
+ export declare function deleteAutoCleanup(id: number): Promise<void>;
28
+ /** The newest record of the profile that is not undone, without its items. */
29
+ export declare function latestOpenAutoCleanupSummary(profileId: string, now?: number): Promise<AutoCleanupSummary | null>;
30
+ /** The newest record of the profile that is not undone, with the items to restore. */
31
+ export declare function latestOpenAutoCleanup(profileId: string, now?: number): Promise<AutoCleanupRecord | null>;
32
+ export declare function markAutoCleanupUndone(id: number, now?: number): Promise<void>;
@@ -0,0 +1,97 @@
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
+ /** A record can be undone for this long, then it is deleted. */
6
+ export const AUTO_CLEANUP_RECORD_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
7
+ /**
8
+ * Restore records of automatic cleanups: the items a run merged or removed, as stored, and
9
+ * the merged item it wrote. The text is profile text and stays in the profile database. It
10
+ * is never logged.
11
+ */
12
+ async function connection() {
13
+ const db = await tursoConnectionManager.getConnection(join(CONFIG.storagePath || "", USER_PROFILES_DB_NAME));
14
+ await db.execute(`CREATE TABLE IF NOT EXISTS profile_auto_cleanups (
15
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
16
+ profile_id TEXT NOT NULL,
17
+ created_at INTEGER NOT NULL,
18
+ prompts_analyzed INTEGER NOT NULL,
19
+ merged INTEGER NOT NULL,
20
+ removed INTEGER NOT NULL,
21
+ entries TEXT NOT NULL,
22
+ undone_at INTEGER
23
+ )`);
24
+ return db;
25
+ }
26
+ async function prune(db, now) {
27
+ await db.execute("DELETE FROM profile_auto_cleanups WHERE created_at < ?", [
28
+ now - AUTO_CLEANUP_RECORD_MAX_AGE_MS,
29
+ ]);
30
+ }
31
+ export async function insertAutoCleanupRecord(profileId, record, now = Date.now()) {
32
+ const db = await connection();
33
+ await prune(db, now);
34
+ // The id comes from this INSERT. A separate SELECT could return the row of another host
35
+ // that wrote for the same profile in between, and a failed save would delete that row.
36
+ const result = await db.execute("INSERT INTO profile_auto_cleanups (profile_id, created_at, prompts_analyzed, merged, removed, entries) VALUES (?, ?, ?, ?, ?, ?)", [
37
+ profileId,
38
+ now,
39
+ record.promptsAnalyzed,
40
+ record.merged,
41
+ record.removed,
42
+ JSON.stringify(record.entries),
43
+ ]);
44
+ if (result.lastInsertRowid === undefined)
45
+ throw new Error("Restore record id missing");
46
+ return Number(result.lastInsertRowid);
47
+ }
48
+ /**
49
+ * Deletes every record older than 30 days, for all profiles. The daily cleanup and the web
50
+ * app start call it, so a record does not outlive its limit when nothing else touches the
51
+ * table. Returns how many rows it deleted.
52
+ */
53
+ export async function pruneAutoCleanupRecords(now = Date.now()) {
54
+ const db = await connection();
55
+ const result = await db.execute("DELETE FROM profile_auto_cleanups WHERE created_at < ?", [
56
+ now - AUTO_CLEANUP_RECORD_MAX_AGE_MS,
57
+ ]);
58
+ return Number(result.rowsAffected ?? 0);
59
+ }
60
+ export async function deleteAutoCleanup(id) {
61
+ const db = await connection();
62
+ await db.execute("DELETE FROM profile_auto_cleanups WHERE id = ?", [id]);
63
+ }
64
+ /** The newest record of the profile that is not undone, without its items. */
65
+ export async function latestOpenAutoCleanupSummary(profileId, now = Date.now()) {
66
+ const db = await connection();
67
+ await prune(db, now);
68
+ const row = await db.get("SELECT id, created_at, merged, removed FROM profile_auto_cleanups WHERE profile_id = ? AND undone_at IS NULL ORDER BY id DESC LIMIT 1", [profileId]);
69
+ if (!row)
70
+ return null;
71
+ return {
72
+ id: Number(row.id),
73
+ createdAt: Number(row.created_at),
74
+ merged: Number(row.merged),
75
+ removed: Number(row.removed),
76
+ };
77
+ }
78
+ /** The newest record of the profile that is not undone, with the items to restore. */
79
+ export async function latestOpenAutoCleanup(profileId, now = Date.now()) {
80
+ const db = await connection();
81
+ await prune(db, now);
82
+ const row = await db.get("SELECT id, created_at, prompts_analyzed, merged, removed, entries FROM profile_auto_cleanups WHERE profile_id = ? AND undone_at IS NULL ORDER BY id DESC LIMIT 1", [profileId]);
83
+ if (!row)
84
+ return null;
85
+ return {
86
+ id: Number(row.id),
87
+ createdAt: Number(row.created_at),
88
+ promptsAnalyzed: Number(row.prompts_analyzed),
89
+ merged: Number(row.merged),
90
+ removed: Number(row.removed),
91
+ entries: JSON.parse(String(row.entries)),
92
+ };
93
+ }
94
+ export async function markAutoCleanupUndone(id, now = Date.now()) {
95
+ const db = await connection();
96
+ await db.execute("UPDATE profile_auto_cleanups SET undone_at = ? WHERE id = ?", [now, id]);
97
+ }
@@ -0,0 +1,10 @@
1
+ export interface AutoCleanupSettings {
2
+ enabled?: boolean;
3
+ interval?: number;
4
+ }
5
+ /**
6
+ * True when a learning pass that took the analysed-prompt count of the profile from
7
+ * `previous` to `previous + added` passed a multiple of the interval. The caller passes the
8
+ * two settings, so this module does not read the config.
9
+ */
10
+ export declare function shouldStartAutoCleanup(previous: number, added: number, settings: AutoCleanupSettings): boolean;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * True when a learning pass that took the analysed-prompt count of the profile from
3
+ * `previous` to `previous + added` passed a multiple of the interval. The caller passes the
4
+ * two settings, so this module does not read the config.
5
+ */
6
+ export function shouldStartAutoCleanup(previous, added, settings) {
7
+ const { interval } = settings;
8
+ if (!settings.enabled || !Number.isInteger(interval) || interval <= 0)
9
+ return false;
10
+ const every = interval;
11
+ return Math.floor(previous / every) < Math.floor((previous + added) / every);
12
+ }
@@ -0,0 +1,32 @@
1
+ import type { ModelPort } from "../../core/profile-analysis.js";
2
+ import { type RunInBatchesOptions } from "./ai-cleanup-batches.js";
3
+ import { type ProfileEditStore } from "./profile-edit-save.js";
4
+ /** A run that would remove or merge away more than this share of its items applies nothing. */
5
+ export declare const AUTO_CLEANUP_MAX_CHANGE_SHARE = 0.25;
6
+ export type AutoCleanupOutcome = {
7
+ status: "applied";
8
+ applied: number;
9
+ skipped: number;
10
+ } | {
11
+ status: "unchanged";
12
+ skipped: number;
13
+ } | {
14
+ status: "already-running" | "no-profile" | "too-few-items" | "nothing-to-change" | "too-many-changes" | "conflict" | "failed";
15
+ };
16
+ export interface AutoCleanupOptions {
17
+ /** A host that has its own model for profile work (Pi) gives it here. */
18
+ resolveModel?: () => ModelPort | null;
19
+ /** Replaces the model call. Tests use this. */
20
+ callBatch?: RunInBatchesOptions["callBatch"];
21
+ batchSize?: number;
22
+ /** The profile store. It defaults to the shared profile manager. */
23
+ store?: ProfileEditStore;
24
+ }
25
+ /**
26
+ * Cleans up the whole profile in the background: duplicates are merged and useless items
27
+ * are removed, in batches, and every change is saved into the newest profile with no
28
+ * review. Before a save it stores a restore record, so the last run can be undone. The
29
+ * caller does not wait for it, and nothing here throws. The log holds counts and fixed
30
+ * codes only, never item text.
31
+ */
32
+ export declare function startAutoProfileCleanup(userId: string, options?: AutoCleanupOptions): Promise<AutoCleanupOutcome>;