om-memory-system 3.2.0-next.11 → 3.2.0-next.14
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/adapters/opencode/import-command.d.ts +3 -7
- package/dist/adapters/opencode/import-command.js +3 -54
- package/dist/adapters/pi/profile.js +2 -1
- package/dist/config.d.ts +11 -1
- package/dist/config.js +82 -9
- package/dist/core/capture.js +2 -1
- package/dist/core/profile-analysis.js +9 -1
- package/dist/importer/discovery.d.ts +32 -5
- package/dist/importer/discovery.js +88 -36
- package/dist/importer/import-args.d.ts +23 -1
- package/dist/importer/import-args.js +29 -1
- package/dist/importer/import-project.d.ts +16 -0
- package/dist/importer/import-project.js +35 -0
- package/dist/importer/import-readiness.d.ts +47 -0
- package/dist/importer/import-readiness.js +54 -0
- package/dist/importer/import-sessions.d.ts +98 -0
- package/dist/importer/import-sessions.js +222 -0
- package/dist/importer/import-sources.d.ts +47 -0
- package/dist/importer/import-sources.js +147 -0
- package/dist/importer/importer.d.ts +31 -1
- package/dist/importer/importer.js +82 -24
- package/dist/importer/opencode-import.d.ts +11 -1
- package/dist/importer/opencode-import.js +133 -69
- package/dist/importer/opencode-project.d.ts +1 -4
- package/dist/importer/opencode-project.js +3 -36
- package/dist/importer/opencode-reader.d.ts +25 -5
- package/dist/importer/opencode-reader.js +92 -106
- package/dist/importer/opencode-snapshot.d.ts +82 -0
- package/dist/importer/opencode-snapshot.js +276 -0
- package/dist/importer/profile-import.d.ts +1 -0
- package/dist/importer/profile-import.js +7 -1
- package/dist/importer/run-import.d.ts +10 -0
- package/dist/importer/run-import.js +21 -0
- package/dist/importer/settings-health.d.ts +25 -0
- package/dist/importer/settings-health.js +136 -0
- package/dist/importer/web-import-api.d.ts +8 -0
- package/dist/importer/web-import-api.js +8 -0
- package/dist/importer/web-import-jobs.d.ts +53 -0
- package/dist/importer/web-import-jobs.js +227 -0
- package/dist/index.js +1 -0
- package/dist/services/ai/live-model-choice.js +6 -3
- package/dist/services/ai/opencode-import-models.d.ts +11 -0
- package/dist/services/ai/opencode-import-models.js +55 -0
- package/dist/services/capture-attempt-store.d.ts +21 -0
- package/dist/services/capture-attempt-store.js +108 -0
- package/dist/services/capture-diagnostics.js +3 -0
- package/dist/services/cleanup-service.js +9 -0
- package/dist/services/global-config-writer.d.ts +10 -0
- package/dist/services/global-config-writer.js +106 -0
- package/dist/services/settings-log.d.ts +5 -0
- package/dist/services/settings-log.js +36 -0
- package/dist/services/settings-models.d.ts +35 -0
- package/dist/services/settings-models.js +39 -0
- package/dist/services/settings-snapshot.d.ts +24 -0
- package/dist/services/settings-snapshot.js +71 -0
- package/dist/services/settings-traces.d.ts +7 -0
- package/dist/services/settings-traces.js +39 -0
- package/dist/services/user-memory-learning.js +2 -1
- package/dist/services/web-server.d.ts +5 -0
- package/dist/services/web-server.js +176 -3
- package/dist/v2/legacy-client.js +9 -1
- package/dist/web/assets/index-BMkPht7g.js +124 -0
- package/dist/web/assets/index-D5ak5KsI.css +2 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/dist/web/assets/index-DPjEpD1h.css +0 -2
- package/dist/web/assets/index-do26c4JJ.js +0 -122
|
@@ -29,7 +29,11 @@ export async function importProfileFromHistory(source, options) {
|
|
|
29
29
|
const profiles = options.profileStore ?? userProfileManager;
|
|
30
30
|
let directory;
|
|
31
31
|
for await (const session of source) {
|
|
32
|
+
if (options.signal?.aborted)
|
|
33
|
+
break;
|
|
32
34
|
for (const unit of session.units) {
|
|
35
|
+
if (options.signal?.aborted)
|
|
36
|
+
break;
|
|
33
37
|
const prompt = stripPrivateContent(unit.userPrompt).trim();
|
|
34
38
|
if (!prompt ||
|
|
35
39
|
isFullyPrivate(unit.userPrompt) ||
|
|
@@ -67,7 +71,7 @@ export async function importProfileFromHistory(source, options) {
|
|
|
67
71
|
report.promptsRecorded++;
|
|
68
72
|
}
|
|
69
73
|
}
|
|
70
|
-
if (options.dryRun || !directory)
|
|
74
|
+
if (options.dryRun || !directory || options.signal?.aborted)
|
|
71
75
|
return report;
|
|
72
76
|
const user = getTags(directory).user;
|
|
73
77
|
if (!user.userEmail) {
|
|
@@ -77,6 +81,8 @@ export async function importProfileFromHistory(source, options) {
|
|
|
77
81
|
}
|
|
78
82
|
const model = options.model;
|
|
79
83
|
while ((report.remaining = await prompts.countUnanalyzedForUserLearning()) > 0) {
|
|
84
|
+
if (options.signal?.aborted)
|
|
85
|
+
break;
|
|
80
86
|
const batch = await prompts.getPromptsForUserLearning(batchSize);
|
|
81
87
|
if (batch.length === 0)
|
|
82
88
|
break;
|
|
@@ -13,6 +13,14 @@ export interface HistoryImportRun {
|
|
|
13
13
|
cwd: string;
|
|
14
14
|
models: HistoryImportModels;
|
|
15
15
|
onProgress?: (processed: number, total: number, promptPreview: string) => void;
|
|
16
|
+
signal?: AbortSignal;
|
|
17
|
+
/** Web selection, already re-resolved against the source; `args.source` is its real path. */
|
|
18
|
+
selection?: {
|
|
19
|
+
keys: string[];
|
|
20
|
+
cutoff: number;
|
|
21
|
+
/** Shared snapshot key of an OpenCode source, reused from the listing. */
|
|
22
|
+
snapshotKey?: string;
|
|
23
|
+
};
|
|
16
24
|
}
|
|
17
25
|
export type HistoryImportReport = ImportReport & {
|
|
18
26
|
childSessionsFolded?: number;
|
|
@@ -34,6 +42,8 @@ export declare function summarizeHistoryImportReport(report: HistoryImportReport
|
|
|
34
42
|
unitsSkipped: number;
|
|
35
43
|
unitsFailed: number;
|
|
36
44
|
unitsAlreadyHandled: number;
|
|
45
|
+
unitsHeldBack: number;
|
|
46
|
+
unitsUntimed: number;
|
|
37
47
|
projects: number;
|
|
38
48
|
unresolved: number;
|
|
39
49
|
loadErrors: number;
|
|
@@ -18,6 +18,15 @@ export async function runHistoryImport(host, args, run) {
|
|
|
18
18
|
...(args.since !== undefined ? { since: args.since } : {}),
|
|
19
19
|
...(args.until !== undefined ? { until: args.until } : {}),
|
|
20
20
|
...(args.session ? { session: args.session } : {}),
|
|
21
|
+
...(run.selection
|
|
22
|
+
? {
|
|
23
|
+
selectionKeys: run.selection.keys,
|
|
24
|
+
cutoff: run.selection.cutoff,
|
|
25
|
+
...(run.selection.snapshotKey
|
|
26
|
+
? { snapshot: { key: run.selection.snapshotKey, mode: "reuse" } }
|
|
27
|
+
: {}),
|
|
28
|
+
}
|
|
29
|
+
: {}),
|
|
21
30
|
...(project ? { project } : {}),
|
|
22
31
|
...(args.maxSessions ? { maxSessions: args.maxSessions } : {}),
|
|
23
32
|
...(args.profileBatch ? { profileBatch: args.profileBatch } : {}),
|
|
@@ -25,6 +34,8 @@ export async function runHistoryImport(host, args, run) {
|
|
|
25
34
|
force: args.force,
|
|
26
35
|
skipMemories: args.skipMemories,
|
|
27
36
|
skipProfile: args.skipProfile,
|
|
37
|
+
...(run.signal ? { signal: run.signal } : {}),
|
|
38
|
+
...(run.onProgress ? { onProgress: run.onProgress } : {}),
|
|
28
39
|
...(run.models.capture ? { provider: capture } : {}),
|
|
29
40
|
...(run.models.profile ? { profileModel: run.models.profile } : {}),
|
|
30
41
|
});
|
|
@@ -35,6 +46,7 @@ export async function runHistoryImport(host, args, run) {
|
|
|
35
46
|
loadSession: loadPiSessionForImport,
|
|
36
47
|
provider: capture,
|
|
37
48
|
...(run.onProgress ? { onProgress: run.onProgress } : {}),
|
|
49
|
+
...(run.signal ? { signal: run.signal } : {}),
|
|
38
50
|
...(!args.skipProfile
|
|
39
51
|
? {
|
|
40
52
|
profile: {
|
|
@@ -47,6 +59,7 @@ export async function runHistoryImport(host, args, run) {
|
|
|
47
59
|
scope: args.scope,
|
|
48
60
|
currentDirectory: project ?? run.cwd,
|
|
49
61
|
...(args.session ? { session: args.session } : {}),
|
|
62
|
+
...(run.selection ? { selectionKeys: run.selection.keys, cutoff: run.selection.cutoff } : {}),
|
|
50
63
|
...(args.since !== undefined ? { since: args.since } : {}),
|
|
51
64
|
...(args.until !== undefined ? { until: args.until } : {}),
|
|
52
65
|
...(args.maxSessions ? { maxSessions: args.maxSessions } : {}),
|
|
@@ -79,6 +92,12 @@ export function formatHistoryImportReport(host, report, model) {
|
|
|
79
92
|
for (const unresolvable of report.unresolvableSessions) {
|
|
80
93
|
lines.push(` unresolved ${unresolvable.cwd ?? "(no cwd)"}: ${unresolvable.file} (use --map)`);
|
|
81
94
|
}
|
|
95
|
+
if (report.unitsUntimed) {
|
|
96
|
+
lines.push(` turns without a timestamp: ${report.unitsUntimed} (date limits cannot exclude them)`);
|
|
97
|
+
}
|
|
98
|
+
if (report.unitsHeldBack) {
|
|
99
|
+
lines.push(` newer turns held back: ${report.unitsHeldBack}; list the sessions again and import to include them`);
|
|
100
|
+
}
|
|
82
101
|
if (report.loadErrors.length > 0)
|
|
83
102
|
lines.push(` load errors: ${report.loadErrors.length}`);
|
|
84
103
|
if (report.profile) {
|
|
@@ -103,6 +122,8 @@ export function summarizeHistoryImportReport(report) {
|
|
|
103
122
|
unitsSkipped: report.unitsSkipped,
|
|
104
123
|
unitsFailed: report.unitsFailed,
|
|
105
124
|
unitsAlreadyHandled: report.unitsAlreadyHandled,
|
|
125
|
+
unitsHeldBack: report.unitsHeldBack ?? 0,
|
|
126
|
+
unitsUntimed: report.unitsUntimed ?? 0,
|
|
106
127
|
projects: report.projects.length,
|
|
107
128
|
unresolved: (report.unresolvedProjects?.length ?? 0) + report.unresolvableSessions.length,
|
|
108
129
|
loadErrors: report.loadErrors.length,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type HealthRow = {
|
|
2
|
+
check: string;
|
|
3
|
+
status: "pass" | "warn" | "fail";
|
|
4
|
+
reason: string;
|
|
5
|
+
};
|
|
6
|
+
/** Remove configured credentials before returning a model error to the browser. */
|
|
7
|
+
export declare function safeHealthError(error: unknown, secrets: Array<string | undefined>): string;
|
|
8
|
+
export declare function captureFailureHealth(byModel: Array<{
|
|
9
|
+
total: number;
|
|
10
|
+
failed: number;
|
|
11
|
+
}>, byReason: Array<{
|
|
12
|
+
reason: string;
|
|
13
|
+
count: number;
|
|
14
|
+
}>): HealthRow;
|
|
15
|
+
export interface HealthInput {
|
|
16
|
+
directory: string;
|
|
17
|
+
host: string;
|
|
18
|
+
authEnabled: boolean;
|
|
19
|
+
apiTokenSet: boolean;
|
|
20
|
+
testModels: boolean;
|
|
21
|
+
}
|
|
22
|
+
/** Run checks independently so a failed dependency cannot hide the remaining checks. */
|
|
23
|
+
export declare function runSettingsHealth(input: HealthInput): Promise<{
|
|
24
|
+
checks: HealthRow[];
|
|
25
|
+
}>;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { CONFIG } from "../config.js";
|
|
2
|
+
import { getAutoCaptureProviderStatus, resolvePiLiveModel, } from "../services/ai/live-model-choice.js";
|
|
3
|
+
import { isLoopbackHost } from "../services/web-api-auth.js";
|
|
4
|
+
const PROBE = "Reply with a short acknowledgement.";
|
|
5
|
+
/** Remove configured credentials before returning a model error to the browser. */
|
|
6
|
+
export function safeHealthError(error, secrets) {
|
|
7
|
+
let message = error instanceof Error ? error.message : String(error);
|
|
8
|
+
for (const secret of secrets) {
|
|
9
|
+
if (secret && secret.length > 2)
|
|
10
|
+
message = message.replaceAll(secret, "[redacted]");
|
|
11
|
+
}
|
|
12
|
+
return message
|
|
13
|
+
.replace(/(Bearer\s+)[^\s]+/gi, "$1[redacted]")
|
|
14
|
+
.replace(/(api[_-]?key[=:]\s*)[^\s&]+/gi, "$1[redacted]");
|
|
15
|
+
}
|
|
16
|
+
export function captureFailureHealth(byModel, byReason) {
|
|
17
|
+
const total = byModel.reduce((count, row) => count + row.total, 0);
|
|
18
|
+
const failed = byModel.reduce((count, row) => count + row.failed, 0);
|
|
19
|
+
const reason = [...byReason].sort((a, b) => b.count - a.count)[0]?.reason;
|
|
20
|
+
return {
|
|
21
|
+
check: "Capture failures (24 hours)",
|
|
22
|
+
status: total > 0 && failed / total > 0.2 ? "warn" : "pass",
|
|
23
|
+
reason: total
|
|
24
|
+
? `${failed}/${total} failed${reason ? `; most common reason: ${reason}` : ""}`
|
|
25
|
+
: "No recent attempts",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** Run checks independently so a failed dependency cannot hide the remaining checks. */
|
|
29
|
+
export async function runSettingsHealth(input) {
|
|
30
|
+
const checks = [];
|
|
31
|
+
async function check(name, action) {
|
|
32
|
+
try {
|
|
33
|
+
checks.push({ check: name, status: "pass", reason: await action() });
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
checks.push({
|
|
37
|
+
check: name,
|
|
38
|
+
status: "fail",
|
|
39
|
+
reason: safeHealthError(error, [
|
|
40
|
+
CONFIG.memoryApiKey,
|
|
41
|
+
CONFIG.embeddingApiKey,
|
|
42
|
+
CONFIG.webServerApiToken,
|
|
43
|
+
]),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
await check("Config files", async () => {
|
|
48
|
+
const { getSettingsSnapshot } = await import("../services/settings-snapshot.js");
|
|
49
|
+
getSettingsSnapshot(input.directory);
|
|
50
|
+
return "Global and project config parsed";
|
|
51
|
+
});
|
|
52
|
+
await check("Memory store", async () => {
|
|
53
|
+
const { tursoConnectionManager } = await import("../services/turso/connection-manager.js");
|
|
54
|
+
const { join } = await import("node:path");
|
|
55
|
+
const db = await tursoConnectionManager.getConnection(join(CONFIG.storagePath, "user-prompts.db"));
|
|
56
|
+
await db.all("SELECT 1");
|
|
57
|
+
return "Store opened and queried";
|
|
58
|
+
});
|
|
59
|
+
await check("Embedding", async () => {
|
|
60
|
+
const { embeddingService } = await import("../services/embedding.js");
|
|
61
|
+
const result = await embeddingService.embedWithTimeout("OMMS health check", { task: "query" });
|
|
62
|
+
if (!result.length)
|
|
63
|
+
throw new Error("Embedding returned no values");
|
|
64
|
+
return "Test text embedded";
|
|
65
|
+
});
|
|
66
|
+
const secure = isLoopbackHost(input.host) || input.authEnabled || input.apiTokenSet;
|
|
67
|
+
checks.push({
|
|
68
|
+
check: "Web binding",
|
|
69
|
+
status: secure ? "pass" : "fail",
|
|
70
|
+
reason: secure
|
|
71
|
+
? "Binding and access settings are safe"
|
|
72
|
+
: "Network binding requires an API token or Basic Auth",
|
|
73
|
+
});
|
|
74
|
+
const opencode = getAutoCaptureProviderStatus(CONFIG);
|
|
75
|
+
checks.push({
|
|
76
|
+
check: "OpenCode model",
|
|
77
|
+
status: opencode.ready ? "pass" : "fail",
|
|
78
|
+
reason: opencode.ready ? `Resolved: ${opencode.mode}` : opencode.issues.join("; "),
|
|
79
|
+
});
|
|
80
|
+
const pi = resolvePiLiveModel(CONFIG);
|
|
81
|
+
checks.push({
|
|
82
|
+
check: "Pi model",
|
|
83
|
+
status: pi.kind === "unready" ? "fail" : "pass",
|
|
84
|
+
reason: pi.kind === "unready" ? pi.issues.join("; ") : `Resolved: ${pi.kind}`,
|
|
85
|
+
});
|
|
86
|
+
try {
|
|
87
|
+
const { queryCaptureAttempts } = await import("../services/capture-attempt-store.js");
|
|
88
|
+
const { byModel, byReason } = await queryCaptureAttempts(Date.now() - 86400000, Date.now());
|
|
89
|
+
checks.push(captureFailureHealth(byModel, byReason.map((row) => ({ count: row.count, reason: String(row.reason) }))));
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
checks.push({
|
|
93
|
+
check: "Capture failures (24 hours)",
|
|
94
|
+
status: "fail",
|
|
95
|
+
reason: safeHealthError(error, [CONFIG.memoryApiKey]),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (input.testModels) {
|
|
99
|
+
await check("OpenCode model test", async () => {
|
|
100
|
+
const { resolveOpencodeHostModel } = await import("../services/ai/live-model-choice.js");
|
|
101
|
+
const ref = resolveOpencodeHostModel(CONFIG);
|
|
102
|
+
if (ref?.modelID === "inherit")
|
|
103
|
+
throw new Error("A session model needs an active session to test");
|
|
104
|
+
const capture = ref
|
|
105
|
+
? (await (await import("../services/ai/opencode-import-models.js")).createOpencodeImportModels(ref, input.directory)).capture
|
|
106
|
+
: (await import("./model-selection.js")).selectImportModel({}).capture;
|
|
107
|
+
await capture.summarize({
|
|
108
|
+
userPrompt: PROBE,
|
|
109
|
+
context: PROBE,
|
|
110
|
+
sessionId: "health",
|
|
111
|
+
projectDirectory: input.directory,
|
|
112
|
+
});
|
|
113
|
+
return "Fixed prompt completed";
|
|
114
|
+
});
|
|
115
|
+
if (pi.kind === "manual") {
|
|
116
|
+
await check("Pi model test", async () => {
|
|
117
|
+
const { selectImportModel } = await import("./model-selection.js");
|
|
118
|
+
await selectImportModel({}).capture.summarize({
|
|
119
|
+
userPrompt: PROBE,
|
|
120
|
+
context: PROBE,
|
|
121
|
+
sessionId: "health",
|
|
122
|
+
projectDirectory: input.directory,
|
|
123
|
+
});
|
|
124
|
+
return "Fixed prompt completed";
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
checks.push({
|
|
129
|
+
check: "Pi model test",
|
|
130
|
+
status: "warn",
|
|
131
|
+
reason: "Pi models need an active Pi session to test",
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { checks };
|
|
136
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The import pieces the web server loads for the Settings page, behind one
|
|
3
|
+
* dynamic import so the server's boundary with the importer stays small.
|
|
4
|
+
*/
|
|
5
|
+
export { importReadiness } from "./import-readiness.js";
|
|
6
|
+
export { browseImportSources, validateImportSource } from "./import-sources.js";
|
|
7
|
+
export { listImportSessions, validateSessionListRequest } from "./import-sessions.js";
|
|
8
|
+
export { opencodeSnapshots, sweepOrphanSnapshots } from "./opencode-snapshot.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The import pieces the web server loads for the Settings page, behind one
|
|
3
|
+
* dynamic import so the server's boundary with the importer stays small.
|
|
4
|
+
*/
|
|
5
|
+
export { importReadiness } from "./import-readiness.js";
|
|
6
|
+
export { browseImportSources, validateImportSource } from "./import-sources.js";
|
|
7
|
+
export { listImportSessions, validateSessionListRequest } from "./import-sessions.js";
|
|
8
|
+
export { opencodeSnapshots, sweepOrphanSnapshots } from "./opencode-snapshot.js";
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type ImportHost, type WebImportOptions } from "./import-args.js";
|
|
2
|
+
import { type ImportReadiness } from "./import-readiness.js";
|
|
3
|
+
import { resolveImportSelection, type ImportSelection } from "./import-sessions.js";
|
|
4
|
+
import { runHistoryImport, summarizeHistoryImportReport } from "./run-import.js";
|
|
5
|
+
type JobState = "running" | "cancelling" | "cancelled" | "done" | "failed";
|
|
6
|
+
type Job = {
|
|
7
|
+
id: string;
|
|
8
|
+
host: ImportHost;
|
|
9
|
+
dryRun: boolean;
|
|
10
|
+
state: JobState;
|
|
11
|
+
/** Sessions in the resolved selection. */
|
|
12
|
+
sessions: number;
|
|
13
|
+
processed: number;
|
|
14
|
+
total: number;
|
|
15
|
+
report?: string;
|
|
16
|
+
summary?: ReturnType<typeof summarizeHistoryImportReport>;
|
|
17
|
+
error?: string;
|
|
18
|
+
};
|
|
19
|
+
type Request = {
|
|
20
|
+
host: ImportHost;
|
|
21
|
+
source: unknown;
|
|
22
|
+
selection: ImportSelection;
|
|
23
|
+
options: WebImportOptions;
|
|
24
|
+
modelChoice?: string;
|
|
25
|
+
};
|
|
26
|
+
type Runner = typeof runHistoryImport;
|
|
27
|
+
/** A refused job; `status` is the HTTP status the page receives. */
|
|
28
|
+
export declare class ImportJobError extends Error {
|
|
29
|
+
readonly status: number;
|
|
30
|
+
constructor(message: string, status?: number);
|
|
31
|
+
}
|
|
32
|
+
export declare function validateWebImportRequest(value: unknown): Request;
|
|
33
|
+
export interface ImportJobDeps {
|
|
34
|
+
runner?: Runner;
|
|
35
|
+
resolveSelection?: typeof resolveImportSelection;
|
|
36
|
+
readiness?: () => Promise<ImportReadiness>;
|
|
37
|
+
}
|
|
38
|
+
/** One in-memory slot per web server, shared by previews and imports. */
|
|
39
|
+
export declare class SettingsImportJobs {
|
|
40
|
+
private job?;
|
|
41
|
+
private controller?;
|
|
42
|
+
private starting;
|
|
43
|
+
private readonly runner;
|
|
44
|
+
private readonly resolveSelection;
|
|
45
|
+
private readonly readiness;
|
|
46
|
+
constructor(deps?: ImportJobDeps);
|
|
47
|
+
current(): Job | null;
|
|
48
|
+
private busy;
|
|
49
|
+
start(request: unknown, directory: string): Promise<Job>;
|
|
50
|
+
private begin;
|
|
51
|
+
cancel(): Job;
|
|
52
|
+
}
|
|
53
|
+
export {};
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { isAbsolute } from "node:path";
|
|
3
|
+
import { CONFIG } from "../config.js";
|
|
4
|
+
import { importNeedsModel, parseHistoryImportArgs, webImportTokens, } from "./import-args.js";
|
|
5
|
+
import { importBlockedReason, importReadiness } from "./import-readiness.js";
|
|
6
|
+
import { resolveImportSelection, validateImportSelection, } from "./import-sessions.js";
|
|
7
|
+
import { importSourceKey } from "./import-sources.js";
|
|
8
|
+
import { formatHistoryImportReport, runHistoryImport, summarizeHistoryImportReport, } from "./run-import.js";
|
|
9
|
+
import { safeHealthError } from "./settings-health.js";
|
|
10
|
+
/** A refused job; `status` is the HTTP status the page receives. */
|
|
11
|
+
export class ImportJobError extends Error {
|
|
12
|
+
status;
|
|
13
|
+
constructor(message, status = 400) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.status = status;
|
|
16
|
+
this.name = "ImportJobError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// Selection replaces --session and --max-sessions; the source comes as a token.
|
|
20
|
+
const fields = new Set([
|
|
21
|
+
"dryRun",
|
|
22
|
+
"force",
|
|
23
|
+
"skipMemories",
|
|
24
|
+
"skipProfile",
|
|
25
|
+
"scope",
|
|
26
|
+
"project",
|
|
27
|
+
"since",
|
|
28
|
+
"until",
|
|
29
|
+
"profileBatch",
|
|
30
|
+
"pathMaps",
|
|
31
|
+
]);
|
|
32
|
+
export function validateWebImportRequest(value) {
|
|
33
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
34
|
+
throw new Error("Invalid import request");
|
|
35
|
+
const input = value;
|
|
36
|
+
if (input.host !== "pi" && input.host !== "opencode")
|
|
37
|
+
throw new Error("Choose Pi or OpenCode");
|
|
38
|
+
if (!input.options || typeof input.options !== "object" || Array.isArray(input.options))
|
|
39
|
+
throw new Error("Invalid import options");
|
|
40
|
+
const options = input.options;
|
|
41
|
+
for (const [key, option] of Object.entries(options)) {
|
|
42
|
+
if (!fields.has(key))
|
|
43
|
+
throw new Error(`Unknown import option: ${key}`);
|
|
44
|
+
if (["dryRun", "force", "skipMemories", "skipProfile"].includes(key)) {
|
|
45
|
+
if (typeof option !== "boolean")
|
|
46
|
+
throw new Error(`Invalid ${key}`);
|
|
47
|
+
}
|
|
48
|
+
else if (key === "pathMaps") {
|
|
49
|
+
if (!Array.isArray(option) ||
|
|
50
|
+
!option.every((map) => map &&
|
|
51
|
+
typeof map === "object" &&
|
|
52
|
+
typeof map.from === "string" &&
|
|
53
|
+
typeof map.to === "string"))
|
|
54
|
+
throw new Error("Invalid directory maps");
|
|
55
|
+
}
|
|
56
|
+
else if (key === "profileBatch") {
|
|
57
|
+
if (!Number.isSafeInteger(option) || option < 1)
|
|
58
|
+
throw new Error(`Invalid ${key}`);
|
|
59
|
+
}
|
|
60
|
+
else if (key === "since" || key === "until") {
|
|
61
|
+
if (typeof option !== "string" && typeof option !== "number")
|
|
62
|
+
throw new Error(`Invalid ${key}`);
|
|
63
|
+
}
|
|
64
|
+
else if (typeof option !== "string") {
|
|
65
|
+
throw new Error(`Invalid ${key}`);
|
|
66
|
+
}
|
|
67
|
+
else if (key === "project" && !isAbsolute(option)) {
|
|
68
|
+
throw new Error("The project must be an absolute path");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (input.modelChoice !== undefined &&
|
|
72
|
+
(typeof input.modelChoice !== "string" ||
|
|
73
|
+
(input.modelChoice !== "external" && !/^[^/]+\/.+$/.test(input.modelChoice))))
|
|
74
|
+
throw new Error("Choose a signed-in provider/model or the external API");
|
|
75
|
+
return {
|
|
76
|
+
host: input.host,
|
|
77
|
+
source: input.source,
|
|
78
|
+
selection: validateImportSelection(input.selection),
|
|
79
|
+
options: options,
|
|
80
|
+
modelChoice: input.modelChoice,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** One in-memory slot per web server, shared by previews and imports. */
|
|
84
|
+
export class SettingsImportJobs {
|
|
85
|
+
job;
|
|
86
|
+
controller;
|
|
87
|
+
starting = false;
|
|
88
|
+
runner;
|
|
89
|
+
resolveSelection;
|
|
90
|
+
readiness;
|
|
91
|
+
constructor(deps = {}) {
|
|
92
|
+
this.runner = deps.runner ?? runHistoryImport;
|
|
93
|
+
this.resolveSelection = deps.resolveSelection ?? resolveImportSelection;
|
|
94
|
+
this.readiness = deps.readiness ?? (() => importReadiness());
|
|
95
|
+
}
|
|
96
|
+
current() {
|
|
97
|
+
return this.job ? { ...this.job } : null;
|
|
98
|
+
}
|
|
99
|
+
busy() {
|
|
100
|
+
return this.starting || this.job?.state === "running" || this.job?.state === "cancelling";
|
|
101
|
+
}
|
|
102
|
+
async start(request, directory) {
|
|
103
|
+
if (this.busy())
|
|
104
|
+
throw new ImportJobError("An import is already running", 409);
|
|
105
|
+
this.starting = true;
|
|
106
|
+
try {
|
|
107
|
+
return await this.begin(request, directory);
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
this.starting = false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async begin(request, directory) {
|
|
114
|
+
let input;
|
|
115
|
+
try {
|
|
116
|
+
input = validateWebImportRequest(request);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
throw new ImportJobError(error.message);
|
|
120
|
+
}
|
|
121
|
+
const options = { ...input.options };
|
|
122
|
+
if (input.modelChoice && input.modelChoice !== "external")
|
|
123
|
+
options.model = input.modelChoice;
|
|
124
|
+
const args = parseHistoryImportArgs(webImportTokens(options, input.host), {
|
|
125
|
+
host: input.host,
|
|
126
|
+
surface: "web",
|
|
127
|
+
});
|
|
128
|
+
if (args.errors.length)
|
|
129
|
+
throw new ImportJobError(args.errors.join("; "));
|
|
130
|
+
const needsModel = importNeedsModel(args);
|
|
131
|
+
const blocked = importBlockedReason(await this.readiness(), {
|
|
132
|
+
host: input.host,
|
|
133
|
+
needsModel,
|
|
134
|
+
...(input.modelChoice ? { modelChoice: input.modelChoice } : {}),
|
|
135
|
+
});
|
|
136
|
+
if (blocked)
|
|
137
|
+
throw new ImportJobError(blocked);
|
|
138
|
+
let selection;
|
|
139
|
+
try {
|
|
140
|
+
selection = await this.resolveSelection(input.source, input.selection, {
|
|
141
|
+
host: input.host,
|
|
142
|
+
scope: args.scope,
|
|
143
|
+
...(args.project ? { project: args.project } : {}),
|
|
144
|
+
pathMaps: args.pathMaps,
|
|
145
|
+
cwd: directory,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
const status = error.status ?? 400;
|
|
150
|
+
throw new ImportJobError(error.message, status);
|
|
151
|
+
}
|
|
152
|
+
args.source = selection.identity.realPath;
|
|
153
|
+
const job = {
|
|
154
|
+
id: randomUUID(),
|
|
155
|
+
host: input.host,
|
|
156
|
+
dryRun: args.dryRun,
|
|
157
|
+
state: "running",
|
|
158
|
+
sessions: selection.keys.length,
|
|
159
|
+
processed: 0,
|
|
160
|
+
total: 0,
|
|
161
|
+
};
|
|
162
|
+
const controller = new AbortController();
|
|
163
|
+
this.job = job;
|
|
164
|
+
this.controller = controller;
|
|
165
|
+
void Promise.resolve().then(async () => {
|
|
166
|
+
try {
|
|
167
|
+
let models = {};
|
|
168
|
+
if (needsModel) {
|
|
169
|
+
if (input.modelChoice === "external") {
|
|
170
|
+
const { selectImportModel } = await import("./model-selection.js");
|
|
171
|
+
const selected = selectImportModel({});
|
|
172
|
+
models = { capture: selected.capture, profile: selected.profile };
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
const [providerID = "", ...id] = input.modelChoice.split("/");
|
|
176
|
+
const { loadOpencodeProvider } = await import("../services/ai/opencode-provider-loader.js");
|
|
177
|
+
const { isProviderConnected } = await loadOpencodeProvider();
|
|
178
|
+
if (!isProviderConnected(providerID))
|
|
179
|
+
throw new Error("OpenCode provider is not connected");
|
|
180
|
+
const { createOpencodeImportModels } = await import("../services/ai/opencode-import-models.js");
|
|
181
|
+
models = await createOpencodeImportModels({ providerID, modelID: id.join("/") }, directory);
|
|
182
|
+
}
|
|
183
|
+
const { memoryClient } = await import("../services/client.js");
|
|
184
|
+
await memoryClient.warmup();
|
|
185
|
+
}
|
|
186
|
+
const report = await this.runner(input.host, args, {
|
|
187
|
+
cwd: directory,
|
|
188
|
+
models,
|
|
189
|
+
signal: controller.signal,
|
|
190
|
+
selection: {
|
|
191
|
+
keys: selection.keys,
|
|
192
|
+
cutoff: selection.cutoff,
|
|
193
|
+
...(selection.identity.kind === "opencode-db"
|
|
194
|
+
? { snapshotKey: importSourceKey(selection.identity) }
|
|
195
|
+
: {}),
|
|
196
|
+
},
|
|
197
|
+
onProgress: (processed, total) => {
|
|
198
|
+
job.processed = processed;
|
|
199
|
+
job.total = total;
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
job.summary = summarizeHistoryImportReport(report);
|
|
203
|
+
job.report = safeHealthError(formatHistoryImportReport(input.host, report, input.modelChoice), [CONFIG.memoryApiKey]);
|
|
204
|
+
job.state = controller.signal.aborted ? "cancelled" : "done";
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
job.error = safeHealthError(error, [CONFIG.memoryApiKey, CONFIG.embeddingApiKey]);
|
|
208
|
+
job.state = controller.signal.aborted ? "cancelled" : "failed";
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
// A preview keeps the OpenCode copy for the import that follows; an import is the end.
|
|
212
|
+
if (!args.dryRun && selection.identity.kind === "opencode-db") {
|
|
213
|
+
const { opencodeSnapshots } = await import("./opencode-snapshot.js");
|
|
214
|
+
opencodeSnapshots.discard(importSourceKey(selection.identity));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
return { ...job };
|
|
219
|
+
}
|
|
220
|
+
cancel() {
|
|
221
|
+
if (!this.job || !this.controller || this.job.state !== "running")
|
|
222
|
+
throw new Error("No running import");
|
|
223
|
+
this.controller.abort();
|
|
224
|
+
this.job.state = "cancelling";
|
|
225
|
+
return { ...this.job };
|
|
226
|
+
}
|
|
227
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -36,6 +36,9 @@ function getManualProviderStatus(config) {
|
|
|
36
36
|
export function getAutoCaptureProviderStatus(config) {
|
|
37
37
|
const hasOpencodeProvider = hasValue(config.opencodeProvider);
|
|
38
38
|
const hasOpencodeModel = hasValue(config.opencodeModel);
|
|
39
|
+
if (config.opencodeModel?.trim() === "inherit") {
|
|
40
|
+
return { ready: true, mode: "session", issues: [] };
|
|
41
|
+
}
|
|
39
42
|
if (hasOpencodeProvider && hasOpencodeModel) {
|
|
40
43
|
return { ready: true, mode: "opencode", issues: [] };
|
|
41
44
|
}
|
|
@@ -65,10 +68,10 @@ export function resolveOpencodeHostModel(config) {
|
|
|
65
68
|
}
|
|
66
69
|
/** Pi's live model, by the same rule as OpenCode's (`piProvider`/`piModel` in place of `opencode*`). */
|
|
67
70
|
export function resolvePiLiveModel(config) {
|
|
71
|
+
if (config.piModel?.trim() === "inherit")
|
|
72
|
+
return { kind: "session" };
|
|
68
73
|
if (hasValue(config.piProvider) && hasValue(config.piModel)) {
|
|
69
|
-
return config.piModel.trim()
|
|
70
|
-
? { kind: "session" }
|
|
71
|
-
: { kind: "pi", provider: config.piProvider.trim(), model: config.piModel.trim() };
|
|
74
|
+
return { kind: "pi", provider: config.piProvider.trim(), model: config.piModel.trim() };
|
|
72
75
|
}
|
|
73
76
|
const manual = getManualProviderStatus(config);
|
|
74
77
|
if (manual.ready)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CaptureSummaryProvider } from "../../core/host.js";
|
|
2
|
+
import type { ModelPort } from "../../core/profile-analysis.js";
|
|
3
|
+
export interface OpencodeModelRef {
|
|
4
|
+
providerID: string;
|
|
5
|
+
modelID: string;
|
|
6
|
+
}
|
|
7
|
+
/** Capture and profile calls routed through OpenCode's structured-output sessions. */
|
|
8
|
+
export declare function createOpencodeImportModels(ref: OpencodeModelRef, directory: string): Promise<{
|
|
9
|
+
capture: CaptureSummaryProvider;
|
|
10
|
+
profile: ModelPort;
|
|
11
|
+
}>;
|