om-memory-system 4.13.3 → 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.
- package/README.md +105 -154
- package/dist/importer/claude-import.js +1 -0
- package/dist/importer/import-sessions.d.ts +5 -0
- package/dist/importer/import-sessions.js +15 -3
- package/dist/importer/importer.d.ts +1 -0
- package/dist/importer/importer.js +1 -0
- package/dist/importer/opencode-import.d.ts +1 -0
- package/dist/importer/opencode-import.js +1 -0
- package/dist/importer/profile-import.d.ts +2 -0
- package/dist/importer/profile-import.js +4 -0
- package/dist/importer/run-import.d.ts +2 -0
- package/dist/importer/run-import.js +11 -2
- package/dist/importer/web-import-group.d.ts +57 -0
- package/dist/importer/web-import-group.js +355 -0
- package/dist/importer/web-import-jobs.d.ts +12 -6
- package/dist/importer/web-import-jobs.js +48 -2
- package/dist/importer/web-import-profile-estimate.d.ts +14 -0
- package/dist/importer/web-import-profile-estimate.js +37 -0
- package/dist/services/web-server.js +1 -1
- package/dist/web/assets/index-C9EubnKm.js +127 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/dist/web/assets/index-BH1QAU9V.js +0 -125
|
@@ -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
|
|
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):
|
|
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 ?
|
|
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
|
|
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
|
+
}
|
|
@@ -823,7 +823,7 @@ export class WebServer {
|
|
|
823
823
|
instance: PROCESS_INSTANCE,
|
|
824
824
|
});
|
|
825
825
|
}
|
|
826
|
-
if (path === "/" || path === "/index.html" || path === "/settings") {
|
|
826
|
+
if (path === "/" || path === "/index.html" || path === "/settings" || path === "/memory") {
|
|
827
827
|
return this.serveStaticFile("index.html", "text/html");
|
|
828
828
|
}
|
|
829
829
|
if (path === "/favicon.ico") {
|