om-memory-system 4.1.0 → 4.2.0-next.58

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 (39) hide show
  1. package/dist/importer/auto-backfill.js +2 -2
  2. package/dist/importer/claude-import.js +1 -0
  3. package/dist/importer/import-readiness.js +2 -2
  4. package/dist/importer/import-runs.d.ts +8 -0
  5. package/dist/importer/import-runs.js +26 -1
  6. package/dist/importer/importer.d.ts +1 -0
  7. package/dist/importer/importer.js +1 -0
  8. package/dist/importer/map-suggestions.js +2 -1
  9. package/dist/importer/opencode-import.d.ts +2 -0
  10. package/dist/importer/opencode-import.js +1 -0
  11. package/dist/importer/pi-sdk.d.ts +19 -0
  12. package/dist/importer/pi-sdk.js +96 -0
  13. package/dist/importer/profile-import.d.ts +2 -0
  14. package/dist/importer/profile-import.js +7 -0
  15. package/dist/importer/run-import.d.ts +2 -0
  16. package/dist/importer/run-import.js +9 -1
  17. package/dist/importer/session-loader.js +3 -1
  18. package/dist/importer/settings-models.js +1 -1
  19. package/dist/services/api-handlers.js +9 -18
  20. package/dist/services/backfill-state.d.ts +12 -2
  21. package/dist/services/backfill-state.js +12 -5
  22. package/dist/services/profile-admin.d.ts +30 -0
  23. package/dist/services/profile-admin.js +54 -0
  24. package/dist/services/profile-identity.d.ts +17 -0
  25. package/dist/services/profile-identity.js +20 -0
  26. package/dist/services/tags.d.ts +2 -0
  27. package/dist/services/tags.js +10 -3
  28. package/dist/services/user-profile/user-profile-manager.d.ts +2 -0
  29. package/dist/services/user-profile/user-profile-manager.js +8 -0
  30. package/dist/services/web-server.d.ts +2 -0
  31. package/dist/services/web-server.js +51 -0
  32. package/dist/utils/profile.d.ts +2 -0
  33. package/dist/utils/profile.js +16 -0
  34. package/dist/web/assets/index-Bt9T_DDB.css +2 -0
  35. package/dist/web/assets/index-RCrvx7Dv.js +125 -0
  36. package/dist/web/index.html +2 -2
  37. package/package.json +1 -1
  38. package/dist/web/assets/index-AWhQ57ps.js +0 -125
  39. package/dist/web/assets/index-Chgv8eM7.css +0 -2
@@ -1,7 +1,7 @@
1
1
  import { CONFIG, refreshConfigIfChanged } from "../config.js";
2
2
  import { log } from "../services/logger.js";
3
3
  import { tryAcquireBackfillLock } from "./backfill-lock.js";
4
- import { getBackfillCutoff, recordUnresolvedDirectories, unresolvedDirectoriesOf, updateBackfillStatus, } from "../services/backfill-state.js";
4
+ import { getBackfillCutoff, recordUnresolvedDirectories, unresolvedDirectoriesOf, unresolvedSessionCount, updateBackfillStatus, } from "../services/backfill-state.js";
5
5
  import { runHistoryImport, } from "./run-import.js";
6
6
  import { isManualImportRunning } from "./manual-import-guard.js";
7
7
  import { isBackfillPaused } from "./import-runs.js";
@@ -13,7 +13,7 @@ function counts(report) {
13
13
  skipped: report.unitsSkipped,
14
14
  failed: report.unitsFailed,
15
15
  pending: report.unitsWouldImport,
16
- unresolved: (report.unresolvedProjects?.length ?? 0) + report.unresolvableSessions.length,
16
+ unresolved: unresolvedSessionCount(report),
17
17
  };
18
18
  }
19
19
  const delay = (signal) => new Promise((resolve) => {
@@ -56,6 +56,7 @@ export async function importClaudeHistory(deps, filters) {
56
56
  dryRun: Boolean(filters.dryRun),
57
57
  model: deps.profile.model,
58
58
  batchSize: deps.profile.batchSize,
59
+ ...(deps.profile.onProgress ? { onProgress: deps.profile.onProgress } : {}),
59
60
  });
60
61
  }
61
62
  return report;
@@ -28,9 +28,9 @@ export async function importReadiness(deps = {}) {
28
28
  (deps.listOpencodeModels ??
29
29
  // Imports run OpenCode models, so they need a session, not the standalone list.
30
30
  (async () => (await import("./settings-models.js")).listOpencodeSettingsModels(undefined, null)))(),
31
- (deps.loadPiSdk ?? (() => import("@earendil-works/pi-coding-agent")))().then(() => ({ available: true }), () => ({
31
+ (deps.loadPiSdk ?? (async () => (await import("./pi-sdk.js")).loadPiSdk()))().then(() => ({ available: true }), () => ({
32
32
  available: false,
33
- reason: "The Pi SDK (@earendil-works/pi-coding-agent) is not installed for OpenCode",
33
+ reason: "The Pi SDK (@earendil-works/pi-coding-agent) was not found. Install Pi on this computer.",
34
34
  })),
35
35
  ]);
36
36
  return {
@@ -2,6 +2,8 @@ import type { BackfillHost } from "./backfill-model.js";
2
2
  import { type ProgressEstimate } from "./import-progress.js";
3
3
  export type ImportSurface = "auto" | "web" | "cli" | "slash";
4
4
  export type ImportRunState = "running" | "paused" | "stopped" | "done" | "failed";
5
+ /** A run imports exchanges first, then learns the profile from the imported prompts. */
6
+ export type ImportRunPhase = "exchanges" | "profile";
5
7
  export interface ImportRunCounts {
6
8
  total: number;
7
9
  done: number;
@@ -19,6 +21,10 @@ export interface ImportRun extends ImportRunCounts, ProgressEstimate {
19
21
  error: string | null;
20
22
  /** The user paused this host's backfill; automatic runs wait for Resume. */
21
23
  paused: boolean;
24
+ phase: ImportRunPhase;
25
+ /** Profile batches done and planned, while `phase` is `profile`. */
26
+ profileDone: number;
27
+ profileTotal: number;
22
28
  }
23
29
  /** Read a host's run without creating the store; a running row whose process is gone reads as stopped. */
24
30
  export declare function readImportRun(host: BackfillHost): Promise<ImportRun | null>;
@@ -27,6 +33,8 @@ export declare function setBackfillPaused(host: BackfillHost, paused: boolean):
27
33
  export interface ImportRunRecorder {
28
34
  /** Feed from `onProgress`; writes at most once per interval and checks the paused flag. */
29
35
  progress(done: number, total: number): void;
36
+ /** Feed from the profile step: batches done and planned. Moves the run to the profile phase. */
37
+ profileProgress(done: number, total: number): void;
30
38
  /** True after a progress write saw the paused flag. */
31
39
  readonly pauseRequested: boolean;
32
40
  finish(state: Exclude<ImportRunState, "running">, counts: Partial<ImportRunCounts>, error?: unknown): Promise<void>;
@@ -15,6 +15,19 @@ async function table(storagePath) {
15
15
  failed INTEGER NOT NULL DEFAULT 0, error TEXT, updated_at INTEGER,
16
16
  samples TEXT, paused INTEGER NOT NULL DEFAULT 0
17
17
  )`);
18
+ for (const column of [
19
+ "phase TEXT",
20
+ "profile_done INTEGER NOT NULL DEFAULT 0",
21
+ "profile_total INTEGER NOT NULL DEFAULT 0",
22
+ ]) {
23
+ try {
24
+ await db.run(`ALTER TABLE import_runs ADD COLUMN ${column}`);
25
+ }
26
+ catch (error) {
27
+ if (!String(error?.message ?? error).includes("duplicate column"))
28
+ throw error;
29
+ }
30
+ }
18
31
  // A row per host keeps every later statement a plain UPDATE.
19
32
  await db.run("INSERT OR IGNORE INTO import_runs (host) VALUES ('pi'), ('opencode'), ('claude-code')");
20
33
  return db;
@@ -54,6 +67,9 @@ export async function readImportRun(host) {
54
67
  failed: Number(row.failed),
55
68
  error: row.error === null ? null : String(row.error),
56
69
  paused: Number(row.paused) === 1,
70
+ phase: row.phase === "profile" ? "profile" : "exchanges",
71
+ profileDone: Number(row.profile_done ?? 0),
72
+ profileTotal: Number(row.profile_total ?? 0),
57
73
  ...(state === "running"
58
74
  ? estimateProgress(total, done, samples)
59
75
  : {
@@ -85,7 +101,8 @@ export async function startImportRun(host, surface, options = {}) {
85
101
  const db = await table();
86
102
  const startedAt = clock();
87
103
  await db.run(`UPDATE import_runs SET surface = ?, state = 'running', pid = ?, started_at = ?, total = 0,
88
- done = 0, imported = 0, skipped = 0, failed = 0, error = NULL, updated_at = ?, samples = '[]'
104
+ done = 0, imported = 0, skipped = 0, failed = 0, error = NULL, updated_at = ?, samples = '[]',
105
+ phase = 'exchanges', profile_done = 0, profile_total = 0
89
106
  WHERE host = ?`, [surface, process.pid, startedAt, startedAt, host]);
90
107
  let samples = [];
91
108
  let lastWrite = Number.NEGATIVE_INFINITY;
@@ -119,6 +136,14 @@ export async function startImportRun(host, surface, options = {}) {
119
136
  if (clock() - lastWrite >= interval)
120
137
  write();
121
138
  },
139
+ profileProgress(done, total) {
140
+ const now = clock();
141
+ writing = writing
142
+ .then(() => db.run(`UPDATE import_runs SET phase = 'profile', profile_done = ?, profile_total = ?,
143
+ updated_at = ? WHERE host = ?`, [count(done), count(total), now, host]))
144
+ .then(() => { })
145
+ .catch(() => { });
146
+ },
122
147
  get pauseRequested() {
123
148
  return pauseRequested;
124
149
  },
@@ -94,6 +94,7 @@ export interface ImporterDeps {
94
94
  profile?: {
95
95
  model?: ModelPort;
96
96
  batchSize?: number;
97
+ onProgress?: (done: number, total: number) => void;
97
98
  };
98
99
  }
99
100
  export interface ImportWindow extends CaptureConversation {
@@ -390,6 +390,7 @@ export async function importPiHistory(deps, filters) {
390
390
  dryRun,
391
391
  model: deps.profile.model,
392
392
  batchSize: deps.profile.batchSize,
393
+ ...(deps.profile.onProgress ? { onProgress: deps.profile.onProgress } : {}),
393
394
  });
394
395
  }
395
396
  return report;
@@ -80,7 +80,8 @@ export function suggestMapTargets(directories, context = {}) {
80
80
  const withCache = { ...context, cache: context.cache ?? new Map() };
81
81
  return directories.map((item) => ({
82
82
  ...item,
83
- suggestion: suggestMapTarget(item.directory, withCache),
83
+ // Sessions with no recorded directory have nothing to map.
84
+ suggestion: item.directory ? suggestMapTarget(item.directory, withCache) : null,
84
85
  }));
85
86
  }
86
87
  /**
@@ -26,6 +26,8 @@ export interface OpencodeImportOptions {
26
26
  profileBatch?: number;
27
27
  provider?: CaptureSummaryProvider;
28
28
  profileModel?: ModelPort;
29
+ /** Profile batches done and planned, during the profile step. */
30
+ onProfileProgress?: (done: number, total: number) => void;
29
31
  /** `alreadyHandled`: units found already done so far, which need no model call. */
30
32
  onProgress?: (processed: number, total: number, promptPreview: string, alreadyHandled?: number) => void;
31
33
  signal?: AbortSignal;
@@ -141,6 +141,7 @@ export async function importOpencodeHistory(options) {
141
141
  dryRun: options.dryRun,
142
142
  batchSize: options.profileBatch,
143
143
  model: options.profileModel,
144
+ ...(options.onProfileProgress ? { onProgress: options.onProfileProgress } : {}),
144
145
  });
145
146
  }
146
147
  return report;
@@ -0,0 +1,19 @@
1
+ export type PiSdk = typeof import("@earendil-works/pi-coding-agent");
2
+ export interface PiSdkLocations {
3
+ home: string;
4
+ path: string;
5
+ execPath: string;
6
+ }
7
+ /**
8
+ * Folders that may hold Pi's own SDK when OMMS runs outside Pi, for example in
9
+ * the login web app: Pi's managed install, then each `pi` on `PATH`, then the
10
+ * global `node_modules` of the running Node.
11
+ */
12
+ export declare function piSdkCandidates(locations: PiSdkLocations): string[];
13
+ /**
14
+ * Load Pi's SDK: from OMMS's own dependencies when Pi installed OMMS, otherwise
15
+ * from where Pi itself is installed.
16
+ */
17
+ export declare function loadPiSdk(locations?: PiSdkLocations, importer?: (specifier: string) => Promise<unknown>): Promise<PiSdk>;
18
+ /** Tests only: forget the loaded SDK. */
19
+ export declare function resetPiSdkCache(): void;
@@ -0,0 +1,96 @@
1
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { delimiter, dirname, isAbsolute, join } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ const PACKAGE = "@earendil-works/pi-coding-agent";
6
+ function packageEntry(dir) {
7
+ try {
8
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
9
+ if (pkg.name !== PACKAGE)
10
+ return null;
11
+ const root = pkg.exports?.["."];
12
+ const entry = (typeof root === "string" ? root : (root?.import ?? root?.default)) ?? pkg.main;
13
+ return entry ? join(dir, entry) : null;
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ /**
20
+ * Folders that may hold Pi's own SDK when OMMS runs outside Pi, for example in
21
+ * the login web app: Pi's managed install, then each `pi` on `PATH`, then the
22
+ * global `node_modules` of the running Node.
23
+ */
24
+ export function piSdkCandidates(locations) {
25
+ const dirs = [];
26
+ const agent = join(locations.home, ".pi", "agent");
27
+ try {
28
+ const version = readFileSync(join(agent, "install", "current-version"), "utf8").trim();
29
+ if (/^[\w.+-]+$/.test(version) && version !== "." && version !== "..") {
30
+ dirs.push(join(agent, "install", "releases", version, "node_modules", PACKAGE));
31
+ }
32
+ }
33
+ catch {
34
+ // No managed Pi install.
35
+ }
36
+ for (const entry of locations.path.split(delimiter)) {
37
+ if (!entry || !isAbsolute(entry))
38
+ continue;
39
+ const bin = join(entry, "pi");
40
+ if (!existsSync(bin))
41
+ continue;
42
+ // An npm install links `pi` to a file inside the package: walk up to it.
43
+ let dir;
44
+ try {
45
+ dir = dirname(realpathSync(bin));
46
+ }
47
+ catch {
48
+ continue;
49
+ }
50
+ for (let i = 0; i < 6 && dirname(dir) !== dir; i++, dir = dirname(dir)) {
51
+ if (existsSync(join(dir, "package.json"))) {
52
+ dirs.push(dir);
53
+ break;
54
+ }
55
+ }
56
+ dirs.push(join(dirname(entry), "lib", "node_modules", PACKAGE));
57
+ }
58
+ dirs.push(join(dirname(dirname(locations.execPath)), "lib", "node_modules", PACKAGE));
59
+ return [...new Set(dirs)];
60
+ }
61
+ let cached = null;
62
+ /**
63
+ * Load Pi's SDK: from OMMS's own dependencies when Pi installed OMMS, otherwise
64
+ * from where Pi itself is installed.
65
+ */
66
+ export function loadPiSdk(locations = {
67
+ home: homedir(),
68
+ path: process.env.PATH ?? "",
69
+ execPath: process.execPath,
70
+ }, importer = (specifier) => import(specifier)) {
71
+ if (cached)
72
+ return cached;
73
+ const attempt = (async () => {
74
+ try {
75
+ return (await importer(PACKAGE));
76
+ }
77
+ catch (first) {
78
+ for (const dir of piSdkCandidates(locations)) {
79
+ const entry = packageEntry(dir);
80
+ if (entry && existsSync(entry))
81
+ return (await importer(pathToFileURL(entry).href));
82
+ }
83
+ throw first;
84
+ }
85
+ })();
86
+ cached = attempt;
87
+ attempt.catch(() => {
88
+ if (cached === attempt)
89
+ cached = null;
90
+ });
91
+ return attempt;
92
+ }
93
+ /** Tests only: forget the loaded SDK. */
94
+ export function resetPiSdkCache() {
95
+ cached = null;
96
+ }
@@ -24,6 +24,8 @@ export interface ProfileImportOptions {
24
24
  ledger?: Ledger;
25
25
  promptStore?: PromptStore;
26
26
  profileStore?: ProfileStore;
27
+ /** Profile batches done and planned; called before the first batch and after each one. */
28
+ onProgress?: (done: number, total: number) => void;
27
29
  }
28
30
  /** Record historical prompts and build or update the user profile in batches. */
29
31
  export declare function importProfileFromHistory(source: AsyncIterable<ImportSourceSession> | Iterable<ImportSourceSession>, options: ProfileImportOptions): Promise<ProfileImportReport>;
@@ -80,7 +80,14 @@ export async function importProfileFromHistory(source, options) {
80
80
  report.error = "Profile import needs a user email";
81
81
  return report;
82
82
  }
83
+ const planned = (waiting) => Math.ceil(waiting / batchSize);
84
+ options.onProgress?.(0, planned(await prompts.countUnanalyzedForUserLearning()));
83
85
  const drained = await drainProfileBacklog({
86
+ ...(options.onProgress
87
+ ? {
88
+ onProgress: ({ batchesBuilt, remaining }) => options.onProgress(batchesBuilt, batchesBuilt + planned(remaining)),
89
+ }
90
+ : {}),
84
91
  user: { ...user, userEmail: user.userEmail },
85
92
  model: options.model,
86
93
  batchSize,
@@ -16,6 +16,8 @@ export interface HistoryImportRun {
16
16
  /** `alreadyHandled`: units found already done so far, which need no model call. */
17
17
  onProgress?: (processed: number, total: number, promptPreview: string, alreadyHandled?: number) => void;
18
18
  signal?: AbortSignal;
19
+ /** Profile batches done and planned, during the profile step after the exchanges. */
20
+ onProfileProgress?: (done: number, total: number) => void;
19
21
  /** Record progress and take the host's lock for a run that calls models. */
20
22
  track?: {
21
23
  surface: ImportSurface;
@@ -49,6 +49,10 @@ export async function runHistoryImport(host, args, run) {
49
49
  recorder.progress(work.done, work.total);
50
50
  run.onProgress?.(processed, total, preview, alreadyHandled);
51
51
  },
52
+ onProfileProgress: (done, total) => {
53
+ recorder.profileProgress(done, total);
54
+ run.onProfileProgress?.(done, total);
55
+ },
52
56
  });
53
57
  const handled = report.unitsImported + report.unitsSkipped + report.unitsFailed;
54
58
  await recorder.finish(recorder.pauseRequested ? "paused" : controller.signal.aborted ? "stopped" : "done", {
@@ -59,7 +63,9 @@ export async function runHistoryImport(host, args, run) {
59
63
  skipped: report.unitsSkipped,
60
64
  failed: report.unitsFailed,
61
65
  });
62
- await recordUnresolved(host, report);
66
+ // Only a run over every project and every session sees the full list.
67
+ if (args.scope === "all-projects" && !run.selection)
68
+ await recordUnresolved(host, report);
63
69
  return report;
64
70
  }
65
71
  catch (error) {
@@ -119,6 +125,7 @@ async function runUntracked(host, args, run) {
119
125
  ...(run.onProgress ? { onProgress: run.onProgress } : {}),
120
126
  ...(run.models.capture ? { provider: capture } : {}),
121
127
  ...(run.models.profile ? { profileModel: run.models.profile } : {}),
128
+ ...(run.onProfileProgress ? { onProfileProgress: run.onProfileProgress } : {}),
122
129
  });
123
130
  }
124
131
  const deps = {
@@ -130,6 +137,7 @@ async function runUntracked(host, args, run) {
130
137
  profile: {
131
138
  ...(run.models.profile ? { model: run.models.profile } : {}),
132
139
  ...(args.profileBatch ? { batchSize: args.profileBatch } : {}),
140
+ ...(run.onProfileProgress ? { onProgress: run.onProfileProgress } : {}),
133
141
  },
134
142
  }
135
143
  : {}),
@@ -1,4 +1,6 @@
1
- import { SessionManager } from "@earendil-works/pi-coding-agent";
1
+ import { loadPiSdk } from "./pi-sdk.js";
2
+ // Outside Pi (the login web app), the SDK is found where Pi is installed.
3
+ const { SessionManager } = await loadPiSdk();
2
4
  export function loadPiSessionForImport(file) {
3
5
  const manager = SessionManager.open(file);
4
6
  const header = manager.getHeader();
@@ -75,7 +75,7 @@ export async function listOpencodeSettingsModels(client, readStandalone = readSt
75
75
  return unavailable(OPENCODE_LIST_UNAVAILABLE);
76
76
  }
77
77
  }
78
- export async function listPiSettingsModels(loadSdk = () => import("@earendil-works/pi-coding-agent")) {
78
+ export async function listPiSettingsModels(loadSdk = async () => (await import("./pi-sdk.js")).loadPiSdk()) {
79
79
  try {
80
80
  const { ModelRuntime } = await loadSdk();
81
81
  // Refresh local auth availability without fetching models or writing Pi credentials.
@@ -8,7 +8,8 @@ import { extractScopeFromContainerTag, tryExtractScopeFromContainerTag } from ".
8
8
  import { log } from "./logger.js";
9
9
  import { CONFIG } from "../config.js";
10
10
  import { userPromptManager } from "./user-prompt/user-prompt-manager.js";
11
- import { sortProfileItems } from "../utils/profile.js";
11
+ import { sortProfileItems, stripProfileVectors } from "../utils/profile.js";
12
+ import { resolveWebProfileUserId } from "./profile-identity.js";
12
13
  async function getAllMemoryShards() {
13
14
  await ensureTursoReady();
14
15
  const projectShards = await tursoShardManager.getAllShards("project", "");
@@ -746,11 +747,9 @@ export async function handleBulkDeletePrompts(ids, cascade = false) {
746
747
  export async function handleGetUserProfile(userId) {
747
748
  try {
748
749
  const { userProfileManager } = await import("./user-profile/user-profile-manager.js");
749
- const { getTags } = await import("./tags.js");
750
750
  let targetUserId = userId;
751
751
  if (!targetUserId) {
752
- const tags = getTags(process.cwd());
753
- targetUserId = tags.user.userEmail || "unknown";
752
+ targetUserId = (await resolveWebProfileUserId(CONFIG)) || "unknown";
754
753
  }
755
754
  const profile = await userProfileManager.getActiveProfile(targetUserId);
756
755
  if (!profile)
@@ -779,7 +778,7 @@ export async function handleGetUserProfile(userId) {
779
778
  createdAt: safeToISOString(profile.createdAt),
780
779
  lastAnalyzedAt: safeToISOString(profile.lastAnalyzedAt),
781
780
  totalPromptsAnalyzed: profile.totalPromptsAnalyzed,
782
- profileData,
781
+ profileData: stripProfileVectors(profileData),
783
782
  },
784
783
  };
785
784
  }
@@ -817,7 +816,7 @@ export async function handleGetProfileSnapshot(changelogId) {
817
816
  const changelog = await userProfileManager.getChangelogById(changelogId);
818
817
  if (!changelog)
819
818
  return { success: false, error: "Changelog not found" };
820
- const profileData = JSON.parse(changelog.profileDataSnapshot);
819
+ const profileData = stripProfileVectors(JSON.parse(changelog.profileDataSnapshot));
821
820
  return {
822
821
  success: true,
823
822
  data: {
@@ -834,13 +833,11 @@ export async function handleGetProfileSnapshot(changelogId) {
834
833
  }
835
834
  export async function handleRefreshProfile(userId) {
836
835
  try {
837
- const { getTags } = await import("./tags.js");
838
836
  const { userProfileManager } = await import("./user-profile/user-profile-manager.js");
839
837
  const { userPromptManager } = await import("./user-prompt/user-prompt-manager.js");
840
838
  let targetUserId = userId;
841
839
  if (!targetUserId) {
842
- const tags = getTags(process.cwd());
843
- targetUserId = tags.user.userEmail || "unknown";
840
+ targetUserId = (await resolveWebProfileUserId(CONFIG)) || "unknown";
844
841
  }
845
842
  const profile = await userProfileManager.getActiveProfile(targetUserId);
846
843
  let decayApplied = false;
@@ -874,12 +871,10 @@ const pendingCleanups = new Map();
874
871
  export async function handleAICleanup(userId, includeIds, profileVersion) {
875
872
  try {
876
873
  const { userProfileManager } = await import("./user-profile/user-profile-manager.js");
877
- const { getTags } = await import("./tags.js");
878
874
  const { aiCleanupProfile, aiCleanupProfileFromIndexed, filterProfileForCleanup } = await import("./user-profile/ai-cleanup.js");
879
875
  let targetUserId = userId;
880
876
  if (!targetUserId) {
881
- const tags = getTags(process.cwd());
882
- targetUserId = tags.user.userEmail || "unknown";
877
+ targetUserId = (await resolveWebProfileUserId(CONFIG)) || "unknown";
883
878
  }
884
879
  const profile = await userProfileManager.getActiveProfile(targetUserId);
885
880
  if (!profile) {
@@ -1000,11 +995,9 @@ function pushItemFromProfile(target, source, id) {
1000
995
  export async function handleApplyCleanup(userId, body) {
1001
996
  try {
1002
997
  const { userProfileManager } = await import("./user-profile/user-profile-manager.js");
1003
- const { getTags } = await import("./tags.js");
1004
998
  let targetUserId = userId;
1005
999
  if (!targetUserId) {
1006
- const tags = getTags(process.cwd());
1007
- targetUserId = tags.user.userEmail || "unknown";
1000
+ targetUserId = (await resolveWebProfileUserId(CONFIG)) || "unknown";
1008
1001
  }
1009
1002
  const pending = pendingCleanups.get(targetUserId);
1010
1003
  if (!pending) {
@@ -1115,9 +1108,7 @@ function removeOneByDescription(profile, desc, itemType) {
1115
1108
  export async function handleUpdateProfileItem(body) {
1116
1109
  try {
1117
1110
  const { userProfileManager } = await import("./user-profile/user-profile-manager.js");
1118
- const { getTags } = await import("./tags.js");
1119
- const tags = getTags(process.cwd());
1120
- const userId = tags.user.userEmail || "unknown";
1111
+ const userId = (await resolveWebProfileUserId(CONFIG)) || "unknown";
1121
1112
  if (!userId)
1122
1113
  return { success: false, error: "Unable to resolve user identity" };
1123
1114
  const profile = await userProfileManager.getActiveProfile(userId);
@@ -19,13 +19,16 @@ export type UnresolvedDirectory = {
19
19
  sessions: number;
20
20
  };
21
21
  export declare const MAX_UNRESOLVED_DIRECTORIES = 200;
22
+ /** The entry for sessions that recorded no directory. It cannot be mapped. */
23
+ export declare const NO_DIRECTORY = "";
22
24
  /** Record a fixed per-host cutoff before the first import. */
23
25
  export declare function getBackfillCutoff(host: BackfillHost, now?: number): Promise<number>;
24
26
  /** Read progress without creating a store or a ledger table. */
25
27
  export declare function readBackfillStatus(host: BackfillHost): Promise<BackfillStatus | null>;
26
28
  /**
27
29
  * Group unresolved sessions by directory, most sessions first, capped so the
28
- * row stays small. Sessions with no recorded directory are left out.
30
+ * row stays small. Sessions with no recorded directory share the
31
+ * {@link NO_DIRECTORY} entry, so the counts add up to the host's total.
29
32
  */
30
33
  export declare function summarizeUnresolvedDirectories(sessions: ReadonlyArray<{
31
34
  directory: string | null;
@@ -41,8 +44,15 @@ export declare function unresolvedDirectoriesOf(report: {
41
44
  cwd: string | null;
42
45
  }>;
43
46
  }): UnresolvedDirectory[];
47
+ /** Unresolved sessions of an import report, the same total the Directory maps list adds up to. */
48
+ export declare function unresolvedSessionCount(report: {
49
+ unresolvedProjects?: ReadonlyArray<{
50
+ sessions: number;
51
+ }>;
52
+ unresolvableSessions: ReadonlyArray<unknown>;
53
+ }): number;
44
54
  /**
45
- * Save the unresolved directories of a host's latest listing or run. They
55
+ * Save the unresolved directories of a host's latest full run or listing. They
46
56
  * live in their own table, so recording them never sets a backfill cutoff.
47
57
  */
48
58
  export declare function recordUnresolvedDirectories(host: BackfillHost, directories: UnresolvedDirectory[], now?: number): Promise<void>;
@@ -5,6 +5,8 @@ import { tursoConnectionManager } from "./turso/connection-manager.js";
5
5
  import { safeHealthError } from "./safe-health-error.js";
6
6
  const importLedgerDbPath = () => join(CONFIG.storagePath, "import-ledger.db");
7
7
  export const MAX_UNRESOLVED_DIRECTORIES = 200;
8
+ /** The entry for sessions that recorded no directory. It cannot be mapped. */
9
+ export const NO_DIRECTORY = "";
8
10
  const emptyCounts = () => ({ imported: 0, skipped: 0, failed: 0, pending: 0, unresolved: 0 });
9
11
  async function table() {
10
12
  const db = await tursoConnectionManager.getConnection(importLedgerDbPath());
@@ -51,14 +53,14 @@ export async function readBackfillStatus(host) {
51
53
  }
52
54
  /**
53
55
  * Group unresolved sessions by directory, most sessions first, capped so the
54
- * row stays small. Sessions with no recorded directory are left out.
56
+ * row stays small. Sessions with no recorded directory share the
57
+ * {@link NO_DIRECTORY} entry, so the counts add up to the host's total.
55
58
  */
56
59
  export function summarizeUnresolvedDirectories(sessions) {
57
60
  const byDirectory = new Map();
58
61
  for (const item of sessions) {
59
- if (!item.directory)
60
- continue;
61
- byDirectory.set(item.directory, (byDirectory.get(item.directory) ?? 0) + (item.sessions ?? 1));
62
+ const key = item.directory || NO_DIRECTORY;
63
+ byDirectory.set(key, (byDirectory.get(key) ?? 0) + (item.sessions ?? 1));
62
64
  }
63
65
  return [...byDirectory]
64
66
  .map(([directory, count]) => ({ directory, sessions: count }))
@@ -72,8 +74,13 @@ export function unresolvedDirectoriesOf(report) {
72
74
  ...report.unresolvableSessions.map((session) => ({ directory: session.cwd })),
73
75
  ]);
74
76
  }
77
+ /** Unresolved sessions of an import report, the same total the Directory maps list adds up to. */
78
+ export function unresolvedSessionCount(report) {
79
+ const mapped = (report.unresolvedProjects ?? []).reduce((sum, item) => sum + item.sessions, 0);
80
+ return mapped + report.unresolvableSessions.length;
81
+ }
75
82
  /**
76
- * Save the unresolved directories of a host's latest listing or run. They
83
+ * Save the unresolved directories of a host's latest full run or listing. They
77
84
  * live in their own table, so recording them never sets a backfill cutoff.
78
85
  */
79
86
  export async function recordUnresolvedDirectories(host, directories, now = Date.now()) {
@@ -0,0 +1,30 @@
1
+ import type { UserProfile, UserProfileData } from "./user-profile/types.js";
2
+ /** The profile store operations the Profiles card needs. */
3
+ export interface ProfileAdminStore {
4
+ getAllActiveProfiles(): Promise<UserProfile[]>;
5
+ getProfileById(id: string): Promise<UserProfile | null>;
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>;
8
+ deactivateProfile(profileId: string): Promise<boolean>;
9
+ }
10
+ export interface ProfileSummary {
11
+ id: string;
12
+ userId: string;
13
+ displayName: string;
14
+ preferences: number;
15
+ patterns: number;
16
+ workflows: number;
17
+ totalPromptsAnalyzed: number;
18
+ lastAnalyzedAt: number;
19
+ inUse: boolean;
20
+ }
21
+ export declare class ProfileAdminError extends Error {
22
+ readonly status: number;
23
+ constructor(message: string, status: number);
24
+ }
25
+ /** Every active profile with its item counts; `currentUserId` marks the one in use. */
26
+ export declare function listProfiles(store: ProfileAdminStore, currentUserId: string | null): Promise<ProfileSummary[]>;
27
+ /** Merge `sourceId` into `targetId` with profile learning's rules, then turn the source off. */
28
+ export declare function mergeProfiles(store: ProfileAdminStore, sourceId: string, targetId: string): Promise<{
29
+ target: ProfileSummary;
30
+ }>;
@@ -0,0 +1,54 @@
1
+ export class ProfileAdminError extends Error {
2
+ status;
3
+ constructor(message, status) {
4
+ super(message);
5
+ this.status = status;
6
+ }
7
+ }
8
+ function count(data, field) {
9
+ const value = data?.[field];
10
+ return Array.isArray(value) ? value.length : 0;
11
+ }
12
+ function parse(profile) {
13
+ return JSON.parse(profile.profileData);
14
+ }
15
+ /** Every active profile with its item counts; `currentUserId` marks the one in use. */
16
+ export async function listProfiles(store, currentUserId) {
17
+ const profiles = await store.getAllActiveProfiles();
18
+ return profiles
19
+ .map((profile) => {
20
+ const data = parse(profile);
21
+ return {
22
+ id: profile.id,
23
+ userId: profile.userId,
24
+ displayName: profile.displayName,
25
+ preferences: count(data, "preferences"),
26
+ patterns: count(data, "patterns"),
27
+ workflows: count(data, "workflows"),
28
+ totalPromptsAnalyzed: profile.totalPromptsAnalyzed,
29
+ lastAnalyzedAt: profile.lastAnalyzedAt,
30
+ inUse: profile.userId === currentUserId,
31
+ };
32
+ })
33
+ .sort((a, b) => b.lastAnalyzedAt - a.lastAnalyzedAt);
34
+ }
35
+ /** Merge `sourceId` into `targetId` with profile learning's rules, then turn the source off. */
36
+ export async function mergeProfiles(store, sourceId, targetId) {
37
+ if (!sourceId || !targetId)
38
+ throw new ProfileAdminError("sourceId and targetId are required", 400);
39
+ if (sourceId === targetId)
40
+ throw new ProfileAdminError("A profile cannot merge into itself", 400);
41
+ const source = await store.getProfileById(sourceId);
42
+ const target = await store.getProfileById(targetId);
43
+ if (!source?.isActive || !target?.isActive) {
44
+ throw new ProfileAdminError("Both profiles must exist and be active", 404);
45
+ }
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}`);
48
+ if (!saved)
49
+ throw new ProfileAdminError("Profile was changed by another session. Retry.", 409);
50
+ await store.deactivateProfile(source.id);
51
+ const after = (await store.getProfileById(target.id)) ?? target;
52
+ const [summary] = await listProfiles({ ...store, getAllActiveProfiles: async () => [after] }, null);
53
+ return { target: summary };
54
+ }