om-memory-system 3.2.0-next.11 → 3.2.0-next.12
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 +30 -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/web/assets/index-BO6gokx9.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
|
@@ -7,7 +7,7 @@ import type { ImportPathMap } from "./importer.js";
|
|
|
7
7
|
* external API and key).
|
|
8
8
|
*/
|
|
9
9
|
export type ImportHost = "pi" | "opencode";
|
|
10
|
-
export type ImportSurface = "session" | "cli";
|
|
10
|
+
export type ImportSurface = "session" | "cli" | "web";
|
|
11
11
|
export interface HistoryImportArgs {
|
|
12
12
|
help: boolean;
|
|
13
13
|
dryRun: boolean;
|
|
@@ -39,6 +39,28 @@ export declare function parseHistoryImportArgs(tokens: string[], options: {
|
|
|
39
39
|
host: ImportHost;
|
|
40
40
|
surface: ImportSurface;
|
|
41
41
|
}): HistoryImportArgs;
|
|
42
|
+
/**
|
|
43
|
+
* Options the Settings page sends. Sessions come from the page's selection
|
|
44
|
+
* and the source from a signed token, so there is no `session`,
|
|
45
|
+
* `maxSessions`, or `source` here.
|
|
46
|
+
*/
|
|
47
|
+
export interface WebImportOptions {
|
|
48
|
+
dryRun?: boolean;
|
|
49
|
+
force?: boolean;
|
|
50
|
+
skipMemories?: boolean;
|
|
51
|
+
skipProfile?: boolean;
|
|
52
|
+
model?: string;
|
|
53
|
+
scope?: HistoryImportArgs["scope"];
|
|
54
|
+
project?: string;
|
|
55
|
+
/** Epoch ms from the browser's local day start, or an ISO date. */
|
|
56
|
+
since?: string | number;
|
|
57
|
+
/** Epoch ms from the browser's local day end, or an ISO date. */
|
|
58
|
+
until?: string | number;
|
|
59
|
+
profileBatch?: number;
|
|
60
|
+
pathMaps?: ImportPathMap[];
|
|
61
|
+
}
|
|
62
|
+
/** Convert page fields to the shared CLI grammar, which performs validation. */
|
|
63
|
+
export declare function webImportTokens(options: WebImportOptions, _host: ImportHost): string[];
|
|
42
64
|
/** True when the run calls a model: a real import of memories or the profile. */
|
|
43
65
|
export declare function importNeedsModel(args: HistoryImportArgs): boolean;
|
|
44
66
|
export declare function historyImportUsage(host: ImportHost, surface: ImportSurface): string;
|
|
@@ -168,7 +168,7 @@ export function parseHistoryImportArgs(tokens, options) {
|
|
|
168
168
|
if (result.project !== undefined && result.scope === "all-projects") {
|
|
169
169
|
result.errors.push("--project cannot be combined with --scope=all-projects");
|
|
170
170
|
}
|
|
171
|
-
if (options.surface
|
|
171
|
+
if (options.surface !== "cli" && result.model !== undefined) {
|
|
172
172
|
const separator = result.model.indexOf("/");
|
|
173
173
|
if (separator < 1 || separator === result.model.length - 1) {
|
|
174
174
|
result.errors.push(`--model must be provider/id, got "${result.model}"`);
|
|
@@ -176,6 +176,34 @@ export function parseHistoryImportArgs(tokens, options) {
|
|
|
176
176
|
}
|
|
177
177
|
return result;
|
|
178
178
|
}
|
|
179
|
+
/** Convert page fields to the shared CLI grammar, which performs validation. */
|
|
180
|
+
export function webImportTokens(options, _host) {
|
|
181
|
+
const tokens = [];
|
|
182
|
+
for (const [field, flag] of [
|
|
183
|
+
["dryRun", "--dry-run"],
|
|
184
|
+
["force", "--force"],
|
|
185
|
+
["skipMemories", "--skip-memories"],
|
|
186
|
+
["skipProfile", "--skip-profile"],
|
|
187
|
+
]) {
|
|
188
|
+
if (options[field])
|
|
189
|
+
tokens.push(flag);
|
|
190
|
+
}
|
|
191
|
+
for (const [field, flag] of [
|
|
192
|
+
["model", "--model"],
|
|
193
|
+
["scope", "--scope"],
|
|
194
|
+
["project", "--project"],
|
|
195
|
+
["since", "--since"],
|
|
196
|
+
["until", "--until"],
|
|
197
|
+
["profileBatch", "--profile-batch"],
|
|
198
|
+
]) {
|
|
199
|
+
const value = options[field];
|
|
200
|
+
if (value !== undefined)
|
|
201
|
+
tokens.push(flag, String(value));
|
|
202
|
+
}
|
|
203
|
+
for (const map of options.pathMaps ?? [])
|
|
204
|
+
tokens.push("--map", `${map.from}=${map.to}`);
|
|
205
|
+
return tokens;
|
|
206
|
+
}
|
|
179
207
|
/** True when the run calls a model: a real import of memories or the profile. */
|
|
180
208
|
export function importNeedsModel(args) {
|
|
181
209
|
return !args.dryRun && (!args.skipMemories || !args.skipProfile);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ImportPathMap } from "./importer.js";
|
|
2
|
+
/** How a recorded session directory was turned into a project directory. */
|
|
3
|
+
export type ProjectResolution = "mapped" | "recorded" | "worktree" | "unresolved";
|
|
4
|
+
export interface ResolvedImportProject {
|
|
5
|
+
directory: string | null;
|
|
6
|
+
via: ProjectResolution;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* One rule for both hosts, shared by the web session list and the importers so
|
|
10
|
+
* the listed set and the imported set always agree. An exact directory map
|
|
11
|
+
* wins, because it is an explicit user choice; a map whose target is missing
|
|
12
|
+
* leaves the session unresolved rather than silently falling back. OpenCode
|
|
13
|
+
* alone passes a project worktree, which stands in for a deleted sub-directory
|
|
14
|
+
* of the same project.
|
|
15
|
+
*/
|
|
16
|
+
export declare function resolveImportProject(recordedDirectory: string | null, maps?: ImportPathMap[], projectWorktree?: string | null): ResolvedImportProject;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
function isDirectory(path) {
|
|
3
|
+
if (!path)
|
|
4
|
+
return false;
|
|
5
|
+
try {
|
|
6
|
+
return statSync(path).isDirectory();
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* One rule for both hosts, shared by the web session list and the importers so
|
|
14
|
+
* the listed set and the imported set always agree. An exact directory map
|
|
15
|
+
* wins, because it is an explicit user choice; a map whose target is missing
|
|
16
|
+
* leaves the session unresolved rather than silently falling back. OpenCode
|
|
17
|
+
* alone passes a project worktree, which stands in for a deleted sub-directory
|
|
18
|
+
* of the same project.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveImportProject(recordedDirectory, maps = [], projectWorktree = null) {
|
|
21
|
+
if (!recordedDirectory)
|
|
22
|
+
return { directory: null, via: "unresolved" };
|
|
23
|
+
const map = maps.find((item) => item.from === recordedDirectory);
|
|
24
|
+
if (map) {
|
|
25
|
+
return isDirectory(map.to)
|
|
26
|
+
? { directory: map.to, via: "mapped" }
|
|
27
|
+
: { directory: null, via: "unresolved" };
|
|
28
|
+
}
|
|
29
|
+
if (isDirectory(recordedDirectory))
|
|
30
|
+
return { directory: recordedDirectory, via: "recorded" };
|
|
31
|
+
if (projectWorktree && projectWorktree !== "/" && isDirectory(projectWorktree)) {
|
|
32
|
+
return { directory: projectWorktree, via: "worktree" };
|
|
33
|
+
}
|
|
34
|
+
return { directory: null, via: "unresolved" };
|
|
35
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a real web import could use right now, checked inside the OpenCode
|
|
3
|
+
* process that runs it. `env://` and `file://` keys were resolved when the
|
|
4
|
+
* config loaded, so a variable set only in the user's shell shows as missing.
|
|
5
|
+
* "Ready" means configured, not tested: the Health section runs test calls.
|
|
6
|
+
*/
|
|
7
|
+
export type ExternalApiState = "ready" | "missing-model" | "missing-url" | "missing-key" | "unsupported-provider";
|
|
8
|
+
export interface ImportReadiness {
|
|
9
|
+
external: {
|
|
10
|
+
state: ExternalApiState;
|
|
11
|
+
provider: string;
|
|
12
|
+
model: string | null;
|
|
13
|
+
};
|
|
14
|
+
opencode: {
|
|
15
|
+
available: boolean;
|
|
16
|
+
models: Array<{
|
|
17
|
+
provider: string;
|
|
18
|
+
model: string;
|
|
19
|
+
name: string;
|
|
20
|
+
}>;
|
|
21
|
+
};
|
|
22
|
+
piReader: {
|
|
23
|
+
available: boolean;
|
|
24
|
+
reason?: string;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export interface ReadinessDeps {
|
|
28
|
+
supportedProviders?: () => Promise<string[]>;
|
|
29
|
+
listOpencodeModels?: () => Promise<{
|
|
30
|
+
available: boolean;
|
|
31
|
+
models?: Array<{
|
|
32
|
+
provider: string;
|
|
33
|
+
model: string;
|
|
34
|
+
name: string;
|
|
35
|
+
}>;
|
|
36
|
+
}>;
|
|
37
|
+
loadPiSdk?: () => Promise<unknown>;
|
|
38
|
+
}
|
|
39
|
+
/** Mirrors the checks in `selectImportModel`, without building a provider. */
|
|
40
|
+
export declare function externalApiState(supportedProviders?: () => Promise<string[]>): Promise<ExternalApiState>;
|
|
41
|
+
export declare function importReadiness(deps?: ReadinessDeps): Promise<ImportReadiness>;
|
|
42
|
+
/** Why a job cannot start, or `null` when it can. */
|
|
43
|
+
export declare function importBlockedReason(readiness: ImportReadiness, request: {
|
|
44
|
+
host: "pi" | "opencode";
|
|
45
|
+
needsModel: boolean;
|
|
46
|
+
modelChoice?: string;
|
|
47
|
+
}): string | null;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { CONFIG } from "../config.js";
|
|
2
|
+
const EXTERNAL_REASONS = {
|
|
3
|
+
"missing-model": "The external API has no memoryModel in the global config",
|
|
4
|
+
"missing-url": "The external API has no memoryApiUrl in the global config",
|
|
5
|
+
"missing-key": "The external API key is missing in the OpenCode process; check memoryApiKey and its env:// or file:// source",
|
|
6
|
+
"unsupported-provider": "The memoryProvider in the global config is not supported for imports",
|
|
7
|
+
};
|
|
8
|
+
/** Mirrors the checks in `selectImportModel`, without building a provider. */
|
|
9
|
+
export async function externalApiState(supportedProviders = async () => (await import("../services/ai/ai-provider-factory.js")).AIProviderFactory.getSupportedProviders()) {
|
|
10
|
+
const provider = CONFIG.memoryProvider;
|
|
11
|
+
if (!(await supportedProviders()).includes(provider))
|
|
12
|
+
return "unsupported-provider";
|
|
13
|
+
if (provider !== "orcarouter") {
|
|
14
|
+
if (!CONFIG.memoryModel)
|
|
15
|
+
return "missing-model";
|
|
16
|
+
if (!CONFIG.memoryApiUrl)
|
|
17
|
+
return "missing-url";
|
|
18
|
+
}
|
|
19
|
+
if (!CONFIG.memoryApiKey)
|
|
20
|
+
return "missing-key";
|
|
21
|
+
return "ready";
|
|
22
|
+
}
|
|
23
|
+
export async function importReadiness(deps = {}) {
|
|
24
|
+
const [state, opencode, piReader] = await Promise.all([
|
|
25
|
+
externalApiState(deps.supportedProviders),
|
|
26
|
+
(deps.listOpencodeModels ??
|
|
27
|
+
(async () => (await import("../services/settings-models.js")).listOpencodeSettingsModels()))(),
|
|
28
|
+
(deps.loadPiSdk ?? (() => import("@earendil-works/pi-coding-agent")))().then(() => ({ available: true }), () => ({
|
|
29
|
+
available: false,
|
|
30
|
+
reason: "The Pi SDK (@earendil-works/pi-coding-agent) is not installed for OpenCode",
|
|
31
|
+
})),
|
|
32
|
+
]);
|
|
33
|
+
return {
|
|
34
|
+
external: { state, provider: CONFIG.memoryProvider, model: CONFIG.memoryModel ?? null },
|
|
35
|
+
opencode: { available: opencode.available, models: opencode.models ?? [] },
|
|
36
|
+
piReader,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** Why a job cannot start, or `null` when it can. */
|
|
40
|
+
export function importBlockedReason(readiness, request) {
|
|
41
|
+
if (request.host === "pi" && !readiness.piReader.available) {
|
|
42
|
+
return readiness.piReader.reason ?? "The Pi session reader is unavailable";
|
|
43
|
+
}
|
|
44
|
+
if (!request.needsModel)
|
|
45
|
+
return null;
|
|
46
|
+
if (!request.modelChoice)
|
|
47
|
+
return "Choose an import model";
|
|
48
|
+
if (request.modelChoice === "external") {
|
|
49
|
+
const state = readiness.external.state;
|
|
50
|
+
return state === "ready" ? null : EXTERNAL_REASONS[state];
|
|
51
|
+
}
|
|
52
|
+
const connected = readiness.opencode.models.some((model) => `${model.provider}/${model.model}` === request.modelChoice);
|
|
53
|
+
return connected ? null : "The chosen OpenCode model is not connected";
|
|
54
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { HistoryImportArgs, ImportHost } from "./import-args.js";
|
|
2
|
+
import { type ProjectResolution } from "./import-project.js";
|
|
3
|
+
import { type ImportSourceIdentity } from "./import-sources.js";
|
|
4
|
+
import type { ImportPathMap } from "./importer.js";
|
|
5
|
+
/**
|
|
6
|
+
* Session metadata for the Settings page and the selection a job imports.
|
|
7
|
+
* Both come from `matchImportSessions`, so the list, its revision, and the
|
|
8
|
+
* imported set always use the same discovery and project rules.
|
|
9
|
+
*/
|
|
10
|
+
export declare const MAX_SELECTED_KEYS = 1000;
|
|
11
|
+
export interface ImportSessionRow {
|
|
12
|
+
/** Selection key: the OpenCode session ID, or the Pi file path relative to the source. */
|
|
13
|
+
key: string;
|
|
14
|
+
sessionId: string | null;
|
|
15
|
+
createdAt: number | null;
|
|
16
|
+
recordedDirectory: string | null;
|
|
17
|
+
directory: string | null;
|
|
18
|
+
via: ProjectResolution;
|
|
19
|
+
}
|
|
20
|
+
export interface ImportMatchOptions {
|
|
21
|
+
host: ImportHost;
|
|
22
|
+
scope: HistoryImportArgs["scope"];
|
|
23
|
+
/** Absolute project directory; defaults to the server's working directory. */
|
|
24
|
+
project?: string;
|
|
25
|
+
pathMaps: ImportPathMap[];
|
|
26
|
+
cwd: string;
|
|
27
|
+
}
|
|
28
|
+
export declare class StaleSelectionError extends Error {
|
|
29
|
+
readonly status = 409;
|
|
30
|
+
constructor(message?: string);
|
|
31
|
+
}
|
|
32
|
+
interface Matched {
|
|
33
|
+
identity: ImportSourceIdentity;
|
|
34
|
+
/** Resolvable sessions in scope, newest first. */
|
|
35
|
+
matching: ImportSessionRow[];
|
|
36
|
+
/** Sessions whose directory cannot be resolved, in any scope. */
|
|
37
|
+
unresolved: ImportSessionRow[];
|
|
38
|
+
revision: string;
|
|
39
|
+
}
|
|
40
|
+
export declare function matchImportSessions(identity: ImportSourceIdentity, options: ImportMatchOptions, snapshotMode: "fresh" | "reuse" | "any"): Promise<Matched>;
|
|
41
|
+
export interface ImportSessionPage {
|
|
42
|
+
source: {
|
|
43
|
+
kind: ImportSourceIdentity["kind"];
|
|
44
|
+
displayPath: string;
|
|
45
|
+
sourceToken: string;
|
|
46
|
+
};
|
|
47
|
+
total: number;
|
|
48
|
+
offset: number;
|
|
49
|
+
rows: Array<ImportSessionRow & {
|
|
50
|
+
selectable: boolean;
|
|
51
|
+
}>;
|
|
52
|
+
/** Sessions whose recorded directory no longer exists. */
|
|
53
|
+
unresolvedCount: number;
|
|
54
|
+
revision: string;
|
|
55
|
+
listedAt: number;
|
|
56
|
+
}
|
|
57
|
+
export interface SessionListRequest {
|
|
58
|
+
sourceToken?: unknown;
|
|
59
|
+
offset?: number;
|
|
60
|
+
limit?: number;
|
|
61
|
+
refresh?: boolean;
|
|
62
|
+
match: Omit<ImportMatchOptions, "cwd">;
|
|
63
|
+
}
|
|
64
|
+
/** Check a session-list request body from the page. */
|
|
65
|
+
export declare function validateSessionListRequest(value: unknown): SessionListRequest;
|
|
66
|
+
/** One page of session metadata; no prompts, replies, or tool data. */
|
|
67
|
+
export declare function listImportSessions(request: {
|
|
68
|
+
sourceToken?: unknown;
|
|
69
|
+
offset?: number;
|
|
70
|
+
limit?: number;
|
|
71
|
+
refresh?: boolean;
|
|
72
|
+
}, options: ImportMatchOptions): Promise<ImportSessionPage>;
|
|
73
|
+
export type ImportSelection = {
|
|
74
|
+
mode: "ids";
|
|
75
|
+
sessions: Array<{
|
|
76
|
+
key: string;
|
|
77
|
+
directory: string;
|
|
78
|
+
}>;
|
|
79
|
+
listedAt: number;
|
|
80
|
+
} | {
|
|
81
|
+
mode: "all";
|
|
82
|
+
excludedKeys: string[];
|
|
83
|
+
revision: string;
|
|
84
|
+
listedAt: number;
|
|
85
|
+
};
|
|
86
|
+
export declare function validateImportSelection(value: unknown): ImportSelection;
|
|
87
|
+
export interface ResolvedImportSelection {
|
|
88
|
+
identity: ImportSourceIdentity;
|
|
89
|
+
keys: string[];
|
|
90
|
+
cutoff: number;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Re-resolve a selection against the source right before a job. New sessions
|
|
94
|
+
* make an "all" selection stale; a missing or re-projected session makes an
|
|
95
|
+
* "ids" selection stale. Neither ever imports a session the user did not see.
|
|
96
|
+
*/
|
|
97
|
+
export declare function resolveImportSelection(sourceToken: unknown, selection: ImportSelection, options: ImportMatchOptions): Promise<ResolvedImportSelection>;
|
|
98
|
+
export {};
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { discoverPiSessions } from "./discovery.js";
|
|
4
|
+
import { resolveImportProject } from "./import-project.js";
|
|
5
|
+
import { assertImportSourceUnchanged, importSourceKey, readImportSourceToken, validateImportSource, defaultImportSourcePath, } from "./import-sources.js";
|
|
6
|
+
/**
|
|
7
|
+
* Session metadata for the Settings page and the selection a job imports.
|
|
8
|
+
* Both come from `matchImportSessions`, so the list, its revision, and the
|
|
9
|
+
* imported set always use the same discovery and project rules.
|
|
10
|
+
*/
|
|
11
|
+
export const MAX_SELECTED_KEYS = 1000;
|
|
12
|
+
const MAX_PAGE = 200;
|
|
13
|
+
export class StaleSelectionError extends Error {
|
|
14
|
+
status = 409;
|
|
15
|
+
constructor(message = "The session list is out of date. Refresh the list and try again.") {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "StaleSelectionError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
async function readRows(identity, options, snapshotMode) {
|
|
21
|
+
if (options.host === "pi") {
|
|
22
|
+
return discoverPiSessions({ root: identity.realPath }).sessions.map((session) => ({
|
|
23
|
+
key: session.key,
|
|
24
|
+
sessionId: session.sessionId,
|
|
25
|
+
createdAt: session.timestamp,
|
|
26
|
+
recordedDirectory: session.cwd,
|
|
27
|
+
...resolveImportProject(session.cwd, options.pathMaps),
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
const { openOpencodeHistory } = await import("./opencode-reader.js");
|
|
31
|
+
const history = await openOpencodeHistory(identity.realPath, {}, { shared: { key: importSourceKey(identity), mode: snapshotMode } });
|
|
32
|
+
try {
|
|
33
|
+
return history.sessions.map((session) => ({
|
|
34
|
+
key: session.sessionId,
|
|
35
|
+
sessionId: session.sessionId,
|
|
36
|
+
createdAt: session.timeCreated,
|
|
37
|
+
recordedDirectory: session.recordedDirectory,
|
|
38
|
+
...resolveImportProject(session.recordedDirectory, options.pathMaps, session.projectWorktree),
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
await history.close();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The revision covers the source, the options that decide membership, and the
|
|
47
|
+
* sorted keys. It never uses file sizes or times: a live database changes on
|
|
48
|
+
* every message, and new turns in a listed session do not change the set.
|
|
49
|
+
*/
|
|
50
|
+
function revisionOf(identity, options, projectTag, keys) {
|
|
51
|
+
return createHash("sha256")
|
|
52
|
+
.update(JSON.stringify({
|
|
53
|
+
host: options.host,
|
|
54
|
+
source: importSourceKey(identity),
|
|
55
|
+
scope: options.scope,
|
|
56
|
+
project: projectTag,
|
|
57
|
+
maps: options.pathMaps,
|
|
58
|
+
keys: [...keys].sort(),
|
|
59
|
+
}))
|
|
60
|
+
.digest("hex");
|
|
61
|
+
}
|
|
62
|
+
export async function matchImportSessions(identity, options, snapshotMode) {
|
|
63
|
+
assertImportSourceUnchanged(identity);
|
|
64
|
+
const { projectFilterTag } = await import("./importer.js");
|
|
65
|
+
const projectTag = options.scope === "current-project"
|
|
66
|
+
? projectFilterTag(resolve(options.cwd, options.project ?? "."))
|
|
67
|
+
: null;
|
|
68
|
+
// Map targets resolve against the working directory, as `runHistoryImport` does.
|
|
69
|
+
const rows = await readRows(identity, {
|
|
70
|
+
...options,
|
|
71
|
+
pathMaps: options.pathMaps.map((map) => ({
|
|
72
|
+
from: map.from,
|
|
73
|
+
to: resolve(options.cwd, map.to),
|
|
74
|
+
})),
|
|
75
|
+
}, snapshotMode);
|
|
76
|
+
const unresolved = rows.filter((row) => row.directory === null);
|
|
77
|
+
const matching = rows
|
|
78
|
+
.filter((row) => row.directory !== null)
|
|
79
|
+
.filter((row) => projectTag === null || projectFilterTag(row.directory) === projectTag)
|
|
80
|
+
.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0) || a.key.localeCompare(b.key));
|
|
81
|
+
return {
|
|
82
|
+
identity,
|
|
83
|
+
matching,
|
|
84
|
+
unresolved,
|
|
85
|
+
revision: revisionOf(identity, options, projectTag, matching.map((row) => row.key)),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** Check a session-list request body from the page. */
|
|
89
|
+
export function validateSessionListRequest(value) {
|
|
90
|
+
const input = value;
|
|
91
|
+
if (!input || typeof input !== "object")
|
|
92
|
+
throw new Error("Invalid session list request");
|
|
93
|
+
if (input.host !== "pi" && input.host !== "opencode")
|
|
94
|
+
throw new Error("Choose Pi or OpenCode");
|
|
95
|
+
const scope = input.scope ?? "current-project";
|
|
96
|
+
if (scope !== "current-project" && scope !== "all-projects")
|
|
97
|
+
throw new Error("Invalid scope");
|
|
98
|
+
const project = input.project;
|
|
99
|
+
if (project !== undefined && (typeof project !== "string" || !isAbsolute(project))) {
|
|
100
|
+
throw new Error("The project must be an absolute path");
|
|
101
|
+
}
|
|
102
|
+
const maps = input.pathMaps ?? [];
|
|
103
|
+
if (!Array.isArray(maps) ||
|
|
104
|
+
!maps.every((map) => map &&
|
|
105
|
+
typeof map.from === "string" &&
|
|
106
|
+
typeof map.to === "string")) {
|
|
107
|
+
throw new Error("Invalid directory maps");
|
|
108
|
+
}
|
|
109
|
+
for (const key of ["offset", "limit"]) {
|
|
110
|
+
if (input[key] !== undefined && !Number.isSafeInteger(input[key])) {
|
|
111
|
+
throw new Error(`Invalid ${key}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
...(input.source !== undefined ? { sourceToken: input.source } : {}),
|
|
116
|
+
...(input.offset !== undefined ? { offset: input.offset } : {}),
|
|
117
|
+
...(input.limit !== undefined ? { limit: input.limit } : {}),
|
|
118
|
+
refresh: input.refresh === true,
|
|
119
|
+
match: {
|
|
120
|
+
host: input.host,
|
|
121
|
+
scope,
|
|
122
|
+
...(project ? { project } : {}),
|
|
123
|
+
pathMaps: maps,
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/** One page of session metadata; no prompts, replies, or tool data. */
|
|
128
|
+
export async function listImportSessions(request, options) {
|
|
129
|
+
const source = request.sourceToken === undefined
|
|
130
|
+
? validateImportSource(options.host, defaultImportSourcePath(options.host))
|
|
131
|
+
: null;
|
|
132
|
+
const token = source?.sourceToken ?? request.sourceToken;
|
|
133
|
+
const identity = readImportSourceToken(token, options.host);
|
|
134
|
+
const offset = Math.max(0, Math.floor(request.offset ?? 0));
|
|
135
|
+
const limit = Math.min(MAX_PAGE, Math.max(1, Math.floor(request.limit ?? 50)));
|
|
136
|
+
// A refresh takes a fresh copy of a live database; paging reuses the current one.
|
|
137
|
+
const listedAt = Date.now();
|
|
138
|
+
const matched = await matchImportSessions(identity, options, request.refresh ? "fresh" : "any");
|
|
139
|
+
const rows = [
|
|
140
|
+
...matched.matching.map((row) => ({ ...row, selectable: true })),
|
|
141
|
+
...(options.scope === "all-projects"
|
|
142
|
+
? matched.unresolved.map((row) => ({ ...row, selectable: false }))
|
|
143
|
+
: []),
|
|
144
|
+
];
|
|
145
|
+
return {
|
|
146
|
+
source: { kind: identity.kind, displayPath: identity.realPath, sourceToken: token },
|
|
147
|
+
total: rows.length,
|
|
148
|
+
offset,
|
|
149
|
+
rows: rows.slice(offset, offset + limit),
|
|
150
|
+
unresolvedCount: matched.unresolved.length,
|
|
151
|
+
revision: matched.revision,
|
|
152
|
+
listedAt,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
export function validateImportSelection(value) {
|
|
156
|
+
const input = value;
|
|
157
|
+
if (!input || typeof input !== "object")
|
|
158
|
+
throw new Error("Choose sessions to import");
|
|
159
|
+
const listedAt = input.listedAt;
|
|
160
|
+
if (typeof listedAt !== "number" || !Number.isFinite(listedAt) || listedAt > Date.now()) {
|
|
161
|
+
throw new Error("Invalid listing time");
|
|
162
|
+
}
|
|
163
|
+
if (input.mode === "ids") {
|
|
164
|
+
const sessions = input.sessions;
|
|
165
|
+
if (!Array.isArray(sessions) ||
|
|
166
|
+
sessions.length === 0 ||
|
|
167
|
+
!sessions.every((item) => item &&
|
|
168
|
+
typeof item.key === "string" &&
|
|
169
|
+
typeof item.directory === "string")) {
|
|
170
|
+
throw new Error("Choose sessions to import");
|
|
171
|
+
}
|
|
172
|
+
if (sessions.length > MAX_SELECTED_KEYS) {
|
|
173
|
+
throw new Error(`Select at most ${MAX_SELECTED_KEYS} sessions one by one, or use Select all matching`);
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
mode: "ids",
|
|
177
|
+
sessions: sessions,
|
|
178
|
+
listedAt,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
if (input.mode === "all") {
|
|
182
|
+
const excluded = input.excludedKeys ?? [];
|
|
183
|
+
if (!Array.isArray(excluded) || !excluded.every((key) => typeof key === "string")) {
|
|
184
|
+
throw new Error("Invalid excluded sessions");
|
|
185
|
+
}
|
|
186
|
+
if (typeof input.revision !== "string")
|
|
187
|
+
throw new Error("Invalid session list revision");
|
|
188
|
+
return { mode: "all", excludedKeys: excluded, revision: input.revision, listedAt };
|
|
189
|
+
}
|
|
190
|
+
throw new Error("Choose sessions to import");
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Re-resolve a selection against the source right before a job. New sessions
|
|
194
|
+
* make an "all" selection stale; a missing or re-projected session makes an
|
|
195
|
+
* "ids" selection stale. Neither ever imports a session the user did not see.
|
|
196
|
+
*/
|
|
197
|
+
export async function resolveImportSelection(sourceToken, selection, options) {
|
|
198
|
+
const identity = readImportSourceToken(sourceToken, options.host);
|
|
199
|
+
const matched = await matchImportSessions(identity, options, "reuse").catch((error) => {
|
|
200
|
+
if (error.code === "expired")
|
|
201
|
+
throw new StaleSelectionError();
|
|
202
|
+
throw error;
|
|
203
|
+
});
|
|
204
|
+
let keys;
|
|
205
|
+
if (selection.mode === "all") {
|
|
206
|
+
if (matched.revision !== selection.revision)
|
|
207
|
+
throw new StaleSelectionError();
|
|
208
|
+
const excluded = new Set(selection.excludedKeys);
|
|
209
|
+
keys = matched.matching.map((row) => row.key).filter((key) => !excluded.has(key));
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
const current = new Map(matched.matching.map((row) => [row.key, row.directory]));
|
|
213
|
+
for (const item of selection.sessions) {
|
|
214
|
+
if (current.get(item.key) !== item.directory)
|
|
215
|
+
throw new StaleSelectionError();
|
|
216
|
+
}
|
|
217
|
+
keys = selection.sessions.map((item) => item.key);
|
|
218
|
+
}
|
|
219
|
+
if (keys.length === 0)
|
|
220
|
+
throw new Error("Choose sessions to import");
|
|
221
|
+
return { identity, keys, cutoff: selection.listedAt };
|
|
222
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ImportHost } from "./import-args.js";
|
|
2
|
+
/**
|
|
3
|
+
* History sources chosen on the Settings page. The browser never uploads a
|
|
4
|
+
* file: it names a server-side path, the server checks it once, and later
|
|
5
|
+
* requests carry a signed token that pins the real path, device, and inode.
|
|
6
|
+
*/
|
|
7
|
+
export type ImportSourceKind = "pi-folder" | "pi-file" | "opencode-db";
|
|
8
|
+
export interface ImportSourceIdentity {
|
|
9
|
+
host: ImportHost;
|
|
10
|
+
kind: ImportSourceKind;
|
|
11
|
+
realPath: string;
|
|
12
|
+
dev: number;
|
|
13
|
+
ino: number;
|
|
14
|
+
}
|
|
15
|
+
export declare class ImportSourceError extends Error {
|
|
16
|
+
readonly status: number;
|
|
17
|
+
constructor(message: string, status?: number);
|
|
18
|
+
}
|
|
19
|
+
export declare function signImportSource(identity: ImportSourceIdentity): string;
|
|
20
|
+
export declare function readImportSourceToken(token: unknown, host: ImportHost): ImportSourceIdentity;
|
|
21
|
+
/** Stable key for sharing snapshots and computing revisions. */
|
|
22
|
+
export declare function importSourceKey(identity: ImportSourceIdentity): string;
|
|
23
|
+
/** Refuse a source whose path now names another file, such as a swapped-in symlink. */
|
|
24
|
+
export declare function assertImportSourceUnchanged(identity: ImportSourceIdentity): void;
|
|
25
|
+
export declare function defaultImportSourcePath(host: ImportHost): string;
|
|
26
|
+
export interface ValidatedImportSource {
|
|
27
|
+
kind: ImportSourceKind;
|
|
28
|
+
displayPath: string;
|
|
29
|
+
sourceToken: string;
|
|
30
|
+
}
|
|
31
|
+
/** Check an absolute path's kind and format; never returns file contents. */
|
|
32
|
+
export declare function validateImportSource(host: ImportHost, path: unknown): ValidatedImportSource;
|
|
33
|
+
export interface BrowseEntry {
|
|
34
|
+
name: string;
|
|
35
|
+
path: string;
|
|
36
|
+
kind: "folder" | "pi-file" | "opencode-db";
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* One folder at a time, with no recursion: subfolders and files the host can
|
|
40
|
+
* import. Symlinked entries are hidden. Only offered on loopback binds.
|
|
41
|
+
*/
|
|
42
|
+
export declare function browseImportSources(host: ImportHost, path?: unknown): {
|
|
43
|
+
path: string;
|
|
44
|
+
parent: string | null;
|
|
45
|
+
entries: BrowseEntry[];
|
|
46
|
+
truncated: boolean;
|
|
47
|
+
};
|