om-memory-system 4.1.0-next.56 → 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.
- package/dist/importer/auto-backfill.js +2 -2
- package/dist/importer/claude-import.js +1 -0
- package/dist/importer/import-readiness.js +2 -2
- package/dist/importer/import-runs.d.ts +8 -0
- package/dist/importer/import-runs.js +26 -1
- package/dist/importer/importer.d.ts +1 -0
- package/dist/importer/importer.js +1 -0
- package/dist/importer/map-suggestions.js +2 -1
- package/dist/importer/opencode-import.d.ts +2 -0
- package/dist/importer/opencode-import.js +1 -0
- package/dist/importer/pi-sdk.d.ts +19 -0
- package/dist/importer/pi-sdk.js +96 -0
- package/dist/importer/profile-import.d.ts +2 -0
- package/dist/importer/profile-import.js +7 -0
- package/dist/importer/run-import.d.ts +2 -0
- package/dist/importer/run-import.js +9 -1
- package/dist/importer/session-loader.js +3 -1
- package/dist/importer/settings-models.js +1 -1
- package/dist/services/api-handlers.js +9 -18
- package/dist/services/backfill-state.d.ts +12 -2
- package/dist/services/backfill-state.js +12 -5
- package/dist/services/profile-admin.d.ts +30 -0
- package/dist/services/profile-admin.js +54 -0
- package/dist/services/profile-identity.d.ts +17 -0
- package/dist/services/profile-identity.js +20 -0
- package/dist/services/tags.d.ts +2 -0
- package/dist/services/tags.js +10 -3
- package/dist/services/user-profile/user-profile-manager.d.ts +2 -0
- package/dist/services/user-profile/user-profile-manager.js +8 -0
- package/dist/services/web-server.d.ts +2 -0
- package/dist/services/web-server.js +51 -0
- package/dist/utils/profile.d.ts +2 -0
- package/dist/utils/profile.js +16 -0
- package/dist/web/assets/index-Bt9T_DDB.css +2 -0
- package/dist/web/assets/index-RCrvx7Dv.js +125 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-AWhQ57ps.js +0 -125
- 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
|
|
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("
|
|
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)
|
|
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
|
},
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
60
|
-
|
|
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
|
|
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
|
+
}
|