om-memory-system 4.14.0-next.106 → 4.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import { resolve } from "node:path";
2
+ import { profileFailureCode } from "../core/profile-failure.js";
2
3
  import { hostLabel } from "../types/host-label.js";
3
4
  const dryRunCapture = {
4
5
  summarize: async () => {
@@ -55,14 +56,20 @@ export async function runHistoryImport(host, args, run) {
55
56
  },
56
57
  });
57
58
  const handled = report.unitsImported + report.unitsSkipped + report.unitsFailed;
58
- await recorder.finish(recorder.pauseRequested ? "paused" : controller.signal.aborted ? "stopped" : "done", {
59
+ await recorder.finish(recorder.pauseRequested
60
+ ? "paused"
61
+ : controller.signal.aborted
62
+ ? "stopped"
63
+ : report.profile?.error
64
+ ? "failed"
65
+ : "done", {
59
66
  // The total stays the one progress last reported; a real run has no
60
67
  // "would import" count of its own.
61
68
  done: handled,
62
69
  imported: report.unitsImported,
63
70
  skipped: report.unitsSkipped,
64
71
  failed: report.unitsFailed,
65
- });
72
+ }, report.profile?.error ? profileFailureCode(report.profile.error) : undefined);
66
73
  // Only a run over every project and every session sees the full list.
67
74
  if (args.scope === "all-projects" && !run.selection)
68
75
  await recordUnresolved(host, report);
@@ -126,6 +133,7 @@ async function runUntracked(host, args, run) {
126
133
  ...(run.models.capture ? { provider: capture } : {}),
127
134
  ...(run.models.profile ? { profileModel: run.models.profile } : {}),
128
135
  ...(run.onProfileProgress ? { onProfileProgress: run.onProfileProgress } : {}),
136
+ ...(run.onProfilePrompt ? { onProfilePrompt: run.onProfilePrompt } : {}),
129
137
  });
130
138
  }
131
139
  const deps = {
@@ -138,6 +146,7 @@ async function runUntracked(host, args, run) {
138
146
  ...(run.models.profile ? { model: run.models.profile } : {}),
139
147
  ...(args.profileBatch ? { batchSize: args.profileBatch } : {}),
140
148
  ...(run.onProfileProgress ? { onProgress: run.onProfileProgress } : {}),
149
+ ...(run.onProfilePrompt ? { onEligiblePrompt: run.onProfilePrompt } : {}),
141
150
  },
142
151
  }
143
152
  : {}),
@@ -0,0 +1,57 @@
1
+ import { type ImportHost } from "./import-args.js";
2
+ import { type ImportReadiness } from "./import-readiness.js";
3
+ import { resolveImportSelection } from "./import-sessions.js";
4
+ import { type ImportSourceIdentity } from "./import-sources.js";
5
+ import { runHistoryImport, summarizeHistoryImportReport, type HistoryImportRun } from "./run-import.js";
6
+ import { type GroupImportRequest } from "./web-import-jobs.js";
7
+ import { type ProfileEstimate } from "./web-import-profile-estimate.js";
8
+ export type GroupHostState = "queued" | "running" | "done" | "failed" | "cancelled" | "no-work" | "not-run";
9
+ type LegacySummary = ReturnType<typeof summarizeHistoryImportReport>;
10
+ export type GroupImportSummary = Omit<LegacySummary, "profile"> & {
11
+ profile?: NonNullable<LegacySummary["profile"]> & {
12
+ promptsAlreadyHandled: number;
13
+ };
14
+ };
15
+ type Summary = GroupImportSummary;
16
+ export interface GroupHostJob {
17
+ host: ImportHost;
18
+ state: GroupHostState;
19
+ sessions: number;
20
+ phase: "preparing" | "memory" | "profile";
21
+ processed: number;
22
+ total: number;
23
+ profileProcessed: number;
24
+ profileTotal: number;
25
+ summary?: Summary;
26
+ profileEstimate?: ProfileEstimate;
27
+ /** Model readiness for a real run, also visible in a read-only preview. */
28
+ blocker?: string;
29
+ error?: string;
30
+ }
31
+ export interface GroupImportJob {
32
+ id: string;
33
+ dryRun: boolean;
34
+ state: "running" | "cancelling" | "cancelled" | "done" | "failed";
35
+ activeHost?: ImportHost;
36
+ hosts: GroupHostJob[];
37
+ sessions: number;
38
+ processed: number;
39
+ total: number;
40
+ summary?: Summary;
41
+ profileEstimate?: ProfileEstimate;
42
+ error?: string;
43
+ }
44
+ export interface GroupImportDeps {
45
+ /** Model construction and store warmup, never invoked by a preview. */
46
+ prepareModels?: (choice: string, directory: string, signal: AbortSignal, host: ImportHost) => Promise<HistoryImportRun["models"]>;
47
+ discardSelection?: (identity: ImportSourceIdentity) => Promise<void>;
48
+ waitingPrompts?: () => Promise<Set<string>>;
49
+ }
50
+ type Deps = GroupImportDeps & {
51
+ runner: typeof runHistoryImport;
52
+ resolveSelection: typeof resolveImportSelection;
53
+ readiness: () => Promise<ImportReadiness>;
54
+ };
55
+ /** Preflight every child before launching a bounded sequential group in the existing slot. */
56
+ export declare function startImportGroup(input: GroupImportRequest, directory: string, deps: Deps, publish: (job: GroupImportJob, controller: AbortController) => void): Promise<GroupImportJob>;
57
+ export {};
@@ -0,0 +1,355 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { CONFIG } from "../config.js";
3
+ import { hostLabel } from "../types/host-label.js";
4
+ import { parseHistoryImportArgs, webImportTokens, } from "./import-args.js";
5
+ import { importBlockedReason } from "./import-readiness.js";
6
+ import { resolveImportSelection } from "./import-sessions.js";
7
+ import { importSourceKey, readImportSourceToken, } from "./import-sources.js";
8
+ import { historyImportFailed, ImportAlreadyRunningError, runHistoryImport, summarizeHistoryImportReport, } from "./run-import.js";
9
+ import { safeHealthError } from "./settings-health.js";
10
+ import { ImportJobError } from "./web-import-jobs.js";
11
+ import { profileEstimate, readWaitingProfilePrompts, } from "./web-import-profile-estimate.js";
12
+ import { profileFailureCode } from "../core/profile-failure.js";
13
+ async function prepareModels(choice, directory, signal) {
14
+ if (signal.aborted)
15
+ return {};
16
+ let models;
17
+ if (choice === "external") {
18
+ const { selectImportModel } = await import("./model-selection.js");
19
+ models = selectImportModel({});
20
+ }
21
+ else {
22
+ const [providerID = "", ...id] = choice.split("/");
23
+ const { getOpencodeHostModels } = await import("./backfill-controls.js");
24
+ const opencode = getOpencodeHostModels();
25
+ if (!opencode?.isProviderConnected(providerID))
26
+ throw new Error("OpenCode provider is not connected");
27
+ models = await opencode.createImportModels({ providerID, modelID: id.join("/") }, directory);
28
+ }
29
+ if (!signal.aborted) {
30
+ const { memoryClient } = await import("../services/client.js");
31
+ await memoryClient.warmup();
32
+ }
33
+ return models;
34
+ }
35
+ async function discardSelection(identity) {
36
+ if (identity.kind !== "opencode-db")
37
+ return;
38
+ const { opencodeSnapshots } = await import("./opencode-snapshot.js");
39
+ opencodeSnapshots.discard(importSourceKey(identity));
40
+ }
41
+ function matchOptions(child, directory, signal) {
42
+ return {
43
+ host: child.input.host,
44
+ scope: child.args.scope,
45
+ ...(child.args.project ? { project: child.args.project } : {}),
46
+ pathMaps: child.args.pathMaps,
47
+ cwd: directory,
48
+ allowEmpty: true,
49
+ signal,
50
+ };
51
+ }
52
+ function hostError(host, error) {
53
+ return new ImportJobError(`${hostLabel(host)}: ${safeHealthError(error, [CONFIG.memoryApiKey, CONFIG.embeddingApiKey])}`, error.status ?? 400);
54
+ }
55
+ function emptySummary(dryRun) {
56
+ return {
57
+ dryRun,
58
+ sessionsDiscovered: 0,
59
+ sessionsLoaded: 0,
60
+ sessionsFilteredOut: 0,
61
+ unitsTotal: 0,
62
+ unitsImported: 0,
63
+ unitsWouldImport: 0,
64
+ unitsSkipped: 0,
65
+ unitsFailed: 0,
66
+ unitsAlreadyHandled: 0,
67
+ unitsHeldBack: 0,
68
+ unitsUntimed: 0,
69
+ projects: 0,
70
+ unresolved: 0,
71
+ loadErrors: 0,
72
+ };
73
+ }
74
+ function updateTotals(job) {
75
+ job.sessions = job.hosts.reduce((n, child) => n + child.sessions, 0);
76
+ job.processed = job.hosts.reduce((n, child) => n + child.processed, 0);
77
+ job.total = job.hosts.reduce((n, child) => n + child.total, 0);
78
+ const reports = job.hosts.flatMap((child) => (child.summary ? [child.summary] : []));
79
+ const sum = {
80
+ ...(reports[0] ?? emptySummary(job.dryRun)),
81
+ profile: undefined,
82
+ };
83
+ for (const key of Object.keys(sum)) {
84
+ if (typeof sum[key] !== "number")
85
+ continue;
86
+ sum[key] = reports.reduce((n, report) => n + report[key], 0);
87
+ }
88
+ const profiles = reports.flatMap((report) => (report.profile ? [report.profile] : []));
89
+ if (profiles.length)
90
+ sum.profile = {
91
+ promptsRecorded: profiles.reduce((n, p) => n + p.promptsRecorded, 0),
92
+ promptsWouldRecord: profiles.reduce((n, p) => n + p.promptsWouldRecord, 0),
93
+ promptsAlreadyHandled: profiles.reduce((n, p) => n + p.promptsAlreadyHandled, 0),
94
+ batchesBuilt: profiles.reduce((n, p) => n + p.batchesBuilt, 0),
95
+ // Every child drains the same queue. Its last reading is the remaining backlog.
96
+ remaining: profiles.at(-1).remaining,
97
+ failed: profiles.some((p) => p.failed),
98
+ };
99
+ job.summary = sum;
100
+ }
101
+ /** Preflight every child before launching a bounded sequential group in the existing slot. */
102
+ export async function startImportGroup(input, directory, deps, publish) {
103
+ const controller = new AbortController();
104
+ const job = {
105
+ id: randomUUID(),
106
+ dryRun: Boolean(input.options.dryRun),
107
+ state: "running",
108
+ hosts: input.hosts.map(({ host }) => ({
109
+ host,
110
+ state: "queued",
111
+ sessions: 0,
112
+ phase: "preparing",
113
+ processed: 0,
114
+ total: 0,
115
+ profileProcessed: 0,
116
+ profileTotal: 0,
117
+ })),
118
+ sessions: 0,
119
+ processed: 0,
120
+ total: 0,
121
+ };
122
+ const children = input.hosts.map((child, index) => {
123
+ const options = {
124
+ ...input.options,
125
+ ...(child.modelChoice && child.modelChoice !== "external"
126
+ ? { model: child.modelChoice }
127
+ : {}),
128
+ };
129
+ const args = parseHistoryImportArgs(webImportTokens(options, child.host), {
130
+ host: child.host,
131
+ surface: "web",
132
+ });
133
+ if (args.errors.length)
134
+ throw hostError(child.host, new Error(args.errors.join("; ")));
135
+ return { input: child, args, row: job.hosts[index] };
136
+ });
137
+ publish(job, controller);
138
+ const cleanup = async () => {
139
+ for (const child of children) {
140
+ // A failed resolver may already have opened a snapshot for its signed token.
141
+ let identity = child.selection?.identity;
142
+ if (!identity) {
143
+ try {
144
+ identity = readImportSourceToken(child.input.source, child.input.host);
145
+ }
146
+ catch {
147
+ continue;
148
+ }
149
+ }
150
+ await (deps.discardSelection ?? discardSelection)(identity).catch(() => { });
151
+ }
152
+ };
153
+ try {
154
+ const readiness = await deps.readiness();
155
+ for (const child of children) {
156
+ if (controller.signal.aborted)
157
+ break;
158
+ const host = child.input.host;
159
+ try {
160
+ const readerBlocked = importBlockedReason(readiness, { host, needsModel: false });
161
+ if (readerBlocked)
162
+ throw new Error(readerBlocked);
163
+ child.row.blocker =
164
+ importBlockedReason(readiness, {
165
+ host,
166
+ needsModel: true,
167
+ modelChoice: child.input.modelChoice,
168
+ }) ?? undefined;
169
+ if (!job.dryRun && child.row.blocker)
170
+ throw new Error(child.row.blocker);
171
+ child.selection = await deps.resolveSelection(child.input.source, child.input.selection, matchOptions(child, directory, controller.signal));
172
+ child.row.sessions = child.selection.keys.length;
173
+ if (child.selection.unresolvedCount !== undefined)
174
+ child.row.summary = {
175
+ ...emptySummary(job.dryRun),
176
+ unresolved: child.selection.unresolvedCount,
177
+ };
178
+ child.args.source = child.selection.identity.realPath;
179
+ if (!child.row.sessions)
180
+ child.row.state = "no-work";
181
+ }
182
+ catch (error) {
183
+ if (controller.signal.aborted)
184
+ break;
185
+ const refused = hostError(host, error);
186
+ if (!job.dryRun)
187
+ throw refused;
188
+ child.row.state = "failed";
189
+ child.row.error = refused.message;
190
+ }
191
+ }
192
+ updateTotals(job);
193
+ if (controller.signal.aborted) {
194
+ for (const child of job.hosts)
195
+ if (child.state === "queued")
196
+ child.state = "cancelled";
197
+ await cleanup();
198
+ job.state = "cancelled";
199
+ return structuredClone(job);
200
+ }
201
+ const waiting = job.dryRun && !input.options.skipProfile
202
+ ? await (deps.waitingPrompts ?? readWaitingProfilePrompts)()
203
+ : new Set();
204
+ void (async () => {
205
+ let outcome = "failed";
206
+ try {
207
+ outcome = await executeGroup(children, job, controller.signal, directory, deps, waiting);
208
+ }
209
+ catch {
210
+ outcome = controller.signal.aborted ? "cancelled" : "failed";
211
+ job.error = "Import preparation failed. Refresh the preview and retry.";
212
+ }
213
+ finally {
214
+ delete job.activeHost;
215
+ if (!job.dryRun || outcome !== "done")
216
+ await cleanup();
217
+ job.state = controller.signal.aborted ? "cancelled" : outcome;
218
+ }
219
+ })();
220
+ return structuredClone(job);
221
+ }
222
+ catch (error) {
223
+ await cleanup();
224
+ if (controller.signal.aborted) {
225
+ for (const child of job.hosts)
226
+ if (child.state === "queued")
227
+ child.state = "cancelled";
228
+ job.state = "cancelled";
229
+ return structuredClone(job);
230
+ }
231
+ throw error;
232
+ }
233
+ }
234
+ async function executeGroup(children, job, signal, directory, deps, waiting) {
235
+ const history = new Set();
236
+ if (job.dryRun && !children[0].args.skipProfile)
237
+ job.profileEstimate = profileEstimate(history, waiting, children[0].args.profileBatch ?? 50);
238
+ let failed = job.hosts.some((row) => row.state === "failed");
239
+ for (const child of children) {
240
+ if (child.row.state !== "queued")
241
+ continue;
242
+ if (signal.aborted || (failed && !job.dryRun)) {
243
+ child.row.state = signal.aborted ? "cancelled" : "not-run";
244
+ continue;
245
+ }
246
+ const host = child.input.host;
247
+ job.activeHost = host;
248
+ child.row.state = "running";
249
+ try {
250
+ // Reuse the signed source and original selection. Never refresh a queued child.
251
+ const current = await deps.resolveSelection(child.input.source, child.input.selection, matchOptions(child, directory, signal));
252
+ const pinned = child.selection;
253
+ if (importSourceKey(current.identity) !== importSourceKey(pinned.identity) ||
254
+ current.cutoff !== pinned.cutoff ||
255
+ JSON.stringify([...current.keys].sort()) !== JSON.stringify([...pinned.keys].sort()))
256
+ throw new Error("The session list is out of date. Refresh the list and try again.");
257
+ if (signal.aborted) {
258
+ child.row.state = "cancelled";
259
+ continue;
260
+ }
261
+ // Reader/model readiness may change while earlier children finish their profile work.
262
+ const blocked = importBlockedReason(await deps.readiness(), {
263
+ host,
264
+ needsModel: !job.dryRun,
265
+ modelChoice: child.input.modelChoice,
266
+ });
267
+ if (blocked)
268
+ throw new Error(blocked);
269
+ const models = job.dryRun
270
+ ? {}
271
+ : await (deps.prepareModels ?? prepareModels)(child.input.modelChoice, directory, signal, host);
272
+ if (signal.aborted) {
273
+ child.row.state = "cancelled";
274
+ continue;
275
+ }
276
+ const eligible = new Set();
277
+ child.row.phase = child.args.skipMemories ? "profile" : "memory";
278
+ const report = await deps.runner(host, child.args, {
279
+ cwd: directory,
280
+ models,
281
+ signal,
282
+ track: { surface: "web" },
283
+ selection: {
284
+ keys: pinned.keys,
285
+ cutoff: pinned.cutoff,
286
+ ...(pinned.identity.kind === "opencode-db"
287
+ ? { snapshotKey: importSourceKey(pinned.identity) }
288
+ : {}),
289
+ },
290
+ onProgress: (processed, total) => {
291
+ child.row.processed = processed;
292
+ child.row.total = total;
293
+ updateTotals(job);
294
+ },
295
+ onProfileProgress: (done, total) => {
296
+ child.row.phase = "profile";
297
+ child.row.profileProcessed = done;
298
+ child.row.profileTotal = total;
299
+ },
300
+ ...(job.dryRun && !child.args.skipProfile
301
+ ? {
302
+ onProfilePrompt: (identity) => {
303
+ eligible.add(identity);
304
+ history.add(identity);
305
+ },
306
+ }
307
+ : {}),
308
+ });
309
+ const summary = summarizeHistoryImportReport(report);
310
+ child.row.summary = {
311
+ ...summary,
312
+ // Source resolution sees missing folders omitted from the pinned runner keys.
313
+ unresolved: pinned.unresolvedCount ??
314
+ (report.unresolvedProjects ?? []).reduce((n, project) => n + project.sessions, 0) +
315
+ report.unresolvableSessions.length,
316
+ profile: summary.profile
317
+ ? { ...summary.profile, promptsAlreadyHandled: report.profile.promptsAlreadyHandled }
318
+ : undefined,
319
+ };
320
+ if (job.dryRun && !child.args.skipProfile) {
321
+ child.row.profileEstimate = profileEstimate(eligible, waiting, child.args.profileBatch ?? 50);
322
+ job.profileEstimate = profileEstimate(history, waiting, child.args.profileBatch ?? 50);
323
+ }
324
+ if (signal.aborted)
325
+ child.row.state = "cancelled";
326
+ else if (historyImportFailed(report)) {
327
+ child.row.state = "failed";
328
+ failed = true;
329
+ child.row.error = `${hostLabel(host)}: ${report.unitsFailed ? "Memory work failed" : "Profile learning failed"}. Refresh the preview and retry.`;
330
+ job.error = child.row.error;
331
+ }
332
+ else {
333
+ const pending = report.unitsImported +
334
+ report.unitsWouldImport +
335
+ (report.profile?.promptsRecorded ?? 0) +
336
+ (report.profile?.promptsWouldRecord ?? 0) +
337
+ (report.profile?.batchesBuilt ?? 0) +
338
+ (child.row.profileEstimate?.totalPrompts ?? 0);
339
+ child.row.state = pending ? "done" : "no-work";
340
+ }
341
+ updateTotals(job);
342
+ }
343
+ catch (error) {
344
+ child.row.state = signal.aborted ? "cancelled" : "failed";
345
+ // Model exceptions can contain raw replies. Return a reason code, never their text.
346
+ const reason = error instanceof ImportAlreadyRunningError || child.row.phase === "preparing"
347
+ ? safeHealthError(error, [CONFIG.memoryApiKey, CONFIG.embeddingApiKey])
348
+ : `${profileFailureCode(error)}. Refresh the preview and retry.`;
349
+ child.row.error = `${hostLabel(host)}: ${reason}`;
350
+ job.error = child.row.error;
351
+ failed = true;
352
+ }
353
+ }
354
+ return signal.aborted ? "cancelled" : failed ? "failed" : "done";
355
+ }
@@ -2,6 +2,7 @@ import { type ImportHost, type WebImportOptions } from "./import-args.js";
2
2
  import { type ImportReadiness } from "./import-readiness.js";
3
3
  import { resolveImportSelection, type ImportSelection } from "./import-sessions.js";
4
4
  import { runHistoryImport, summarizeHistoryImportReport } from "./run-import.js";
5
+ import { type GroupImportJob, type GroupImportDeps } from "./web-import-group.js";
5
6
  type JobState = "running" | "cancelling" | "cancelled" | "done" | "failed";
6
7
  type Job = {
7
8
  id: string;
@@ -16,7 +17,7 @@ type Job = {
16
17
  summary?: ReturnType<typeof summarizeHistoryImportReport>;
17
18
  error?: string;
18
19
  };
19
- type Request = {
20
+ export type WebImportRequest = {
20
21
  host: ImportHost;
21
22
  source: unknown;
22
23
  selection: ImportSelection;
@@ -24,13 +25,17 @@ type Request = {
24
25
  modelChoice?: string;
25
26
  };
26
27
  type Runner = typeof runHistoryImport;
28
+ export type GroupImportRequest = {
29
+ hosts: Omit<WebImportRequest, "options">[];
30
+ options: WebImportOptions;
31
+ };
27
32
  /** A refused job; `status` is the HTTP status the page receives. */
28
33
  export declare class ImportJobError extends Error {
29
34
  readonly status: number;
30
35
  constructor(message: string, status?: number);
31
36
  }
32
- export declare function validateWebImportRequest(value: unknown): Request;
33
- export interface ImportJobDeps {
37
+ export declare function validateWebImportRequest(value: unknown): WebImportRequest | GroupImportRequest;
38
+ export interface ImportJobDeps extends GroupImportDeps {
34
39
  runner?: Runner;
35
40
  resolveSelection?: typeof resolveImportSelection;
36
41
  readiness?: () => Promise<ImportReadiness>;
@@ -38,16 +43,17 @@ export interface ImportJobDeps {
38
43
  /** One in-memory slot per web server, shared by previews and imports. */
39
44
  export declare class SettingsImportJobs {
40
45
  private job?;
46
+ private readonly deps;
41
47
  private controller?;
42
48
  private starting;
43
49
  private readonly runner;
44
50
  private readonly resolveSelection;
45
51
  private readonly readiness;
46
52
  constructor(deps?: ImportJobDeps);
47
- current(): Job | null;
53
+ current(): Job | GroupImportJob | null;
48
54
  private busy;
49
- start(request: unknown, directory: string): Promise<Job>;
55
+ start(request: unknown, directory: string): Promise<Job | GroupImportJob>;
50
56
  private begin;
51
- cancel(): Job;
57
+ cancel(): Job | GroupImportJob;
52
58
  }
53
59
  export {};
@@ -7,6 +7,7 @@ import { resolveImportSelection, validateImportSelection, } from "./import-sessi
7
7
  import { importSourceKey } from "./import-sources.js";
8
8
  import { formatHistoryImportReport, runHistoryImport, summarizeHistoryImportReport, } from "./run-import.js";
9
9
  import { safeHealthError } from "./settings-health.js";
10
+ import { startImportGroup } from "./web-import-group.js";
10
11
  /** A refused job; `status` is the HTTP status the page receives. */
11
12
  export class ImportJobError extends Error {
12
13
  status;
@@ -33,6 +34,28 @@ export function validateWebImportRequest(value) {
33
34
  if (!value || typeof value !== "object" || Array.isArray(value))
34
35
  throw new Error("Invalid import request");
35
36
  const input = value;
37
+ if ("hosts" in input) {
38
+ if (Object.keys(input).some((key) => key !== "hosts" && key !== "options"))
39
+ throw new Error("Grouped imports accept hosts and shared options only");
40
+ if (!Array.isArray(input.hosts) || input.hosts.length === 0 || input.hosts.length > 3)
41
+ throw new Error("Choose one or more distinct hosts");
42
+ const hosts = input.hosts.map((child) => {
43
+ if (!child ||
44
+ typeof child !== "object" ||
45
+ Array.isArray(child) ||
46
+ Object.keys(child).some((key) => !["host", "source", "selection", "modelChoice"].includes(key)))
47
+ throw new Error("Invalid host import options; use shared options");
48
+ return validateWebImportRequest({ ...child, options: input.options });
49
+ });
50
+ if (new Set(hosts.map((child) => child.host)).size !== hosts.length)
51
+ throw new Error("Choose distinct hosts");
52
+ const order = ["pi", "opencode", "claude-code"];
53
+ hosts.sort((a, b) => order.indexOf(a.host) - order.indexOf(b.host));
54
+ return {
55
+ hosts: hosts.map(({ options: _options, ...child }) => child),
56
+ options: hosts[0].options,
57
+ };
58
+ }
36
59
  if (input.host !== "pi" && input.host !== "opencode" && input.host !== "claude-code") {
37
60
  throw new Error("Choose Pi, OpenCode, or Claude Code");
38
61
  }
@@ -69,6 +92,8 @@ export function validateWebImportRequest(value) {
69
92
  throw new Error("The project must be an absolute path");
70
93
  }
71
94
  }
95
+ if (options.skipMemories === true && options.skipProfile === true)
96
+ throw new Error("Choose Project memories or User profile");
72
97
  if (input.modelChoice !== undefined &&
73
98
  (typeof input.modelChoice !== "string" ||
74
99
  (input.modelChoice !== "external" && !/^[^/]+\/.+$/.test(input.modelChoice))))
@@ -90,18 +115,20 @@ export function validateWebImportRequest(value) {
90
115
  /** One in-memory slot per web server, shared by previews and imports. */
91
116
  export class SettingsImportJobs {
92
117
  job;
118
+ deps;
93
119
  controller;
94
120
  starting = false;
95
121
  runner;
96
122
  resolveSelection;
97
123
  readiness;
98
124
  constructor(deps = {}) {
125
+ this.deps = deps;
99
126
  this.runner = deps.runner ?? runHistoryImport;
100
127
  this.resolveSelection = deps.resolveSelection ?? resolveImportSelection;
101
128
  this.readiness = deps.readiness ?? (() => importReadiness());
102
129
  }
103
130
  current() {
104
- return this.job ? { ...this.job } : null;
131
+ return this.job ? structuredClone(this.job) : null;
105
132
  }
106
133
  busy() {
107
134
  return this.starting || this.job?.state === "running" || this.job?.state === "cancelling";
@@ -125,6 +152,25 @@ export class SettingsImportJobs {
125
152
  catch (error) {
126
153
  throw new ImportJobError(error.message);
127
154
  }
155
+ if ("hosts" in input) {
156
+ const previous = this.job;
157
+ try {
158
+ return await startImportGroup(input, directory, {
159
+ ...this.deps,
160
+ runner: this.runner,
161
+ resolveSelection: this.resolveSelection,
162
+ readiness: this.readiness,
163
+ }, (job, controller) => {
164
+ this.job = job;
165
+ this.controller = controller;
166
+ });
167
+ }
168
+ catch (error) {
169
+ this.job = previous;
170
+ this.controller = undefined;
171
+ throw error;
172
+ }
173
+ }
128
174
  const options = { ...input.options };
129
175
  if (input.modelChoice && input.modelChoice !== "external")
130
176
  options.model = input.modelChoice;
@@ -229,6 +275,6 @@ export class SettingsImportJobs {
229
275
  throw new Error("No running import");
230
276
  this.controller.abort();
231
277
  this.job.state = "cancelling";
232
- return { ...this.job };
278
+ return structuredClone(this.job);
233
279
  }
234
280
  }
@@ -0,0 +1,14 @@
1
+ export interface ProfileEstimate {
2
+ /** Eligible history prompts absent from the shared waiting queue. */
3
+ historyPrompts: number;
4
+ waitingPrompts: number;
5
+ totalPrompts: number;
6
+ /** Analysis calls only; matching, deduplication and retries can add calls. */
7
+ analysisCalls: number;
8
+ }
9
+ /** Use the prompt store's existing session/message identity without changing ledger keys. */
10
+ export declare function profilePromptIdentity(sessionId: string, messageId: string): string;
11
+ /** Count the union of history and waiting prompts without counting overlapping identities twice. */
12
+ export declare function profileEstimate(history: Set<string>, waiting: Set<string>, batchSize: number): ProfileEstimate;
13
+ /** Read waiting identities only. Never initialise the prompt store or mark trivial prompts learned. */
14
+ export declare function readWaitingProfilePrompts(): Promise<Set<string>>;
@@ -0,0 +1,37 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { CONFIG } from "../config.js";
4
+ import { isTrivialPrompt } from "../core/trivial-prompt.js";
5
+ import { stripPrivateContent } from "../services/privacy.js";
6
+ /** Use the prompt store's existing session/message identity without changing ledger keys. */
7
+ export function profilePromptIdentity(sessionId, messageId) {
8
+ return JSON.stringify([sessionId, messageId]);
9
+ }
10
+ /** Count the union of history and waiting prompts without counting overlapping identities twice. */
11
+ export function profileEstimate(history, waiting, batchSize) {
12
+ const historyPrompts = [...history].filter((key) => !waiting.has(key)).length;
13
+ const totalPrompts = historyPrompts + waiting.size;
14
+ return {
15
+ historyPrompts,
16
+ waitingPrompts: waiting.size,
17
+ totalPrompts,
18
+ analysisCalls: Math.ceil(totalPrompts / batchSize),
19
+ };
20
+ }
21
+ /** Read waiting identities only. Never initialise the prompt store or mark trivial prompts learned. */
22
+ export async function readWaitingProfilePrompts() {
23
+ const path = join(CONFIG.storagePath, "user-prompts.db");
24
+ if (!existsSync(path))
25
+ return new Set();
26
+ const { tursoConnectionManager } = await import("../services/turso/connection-manager.js");
27
+ const db = await tursoConnectionManager.getConnection(path);
28
+ if (!(await db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'user_prompts'")))
29
+ return new Set();
30
+ const rows = await db.all("SELECT session_id, message_id, content FROM user_prompts WHERE user_learning_captured = 0");
31
+ return new Set(rows
32
+ .filter((row) => {
33
+ const text = stripPrivateContent(String(row.content)).trim();
34
+ return text.length > 0 && !isTrivialPrompt(text);
35
+ })
36
+ .map((row) => profilePromptIdentity(String(row.session_id), String(row.message_id))));
37
+ }