om-memory-system 3.2.0-next.9 → 3.3.0-next.18
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 +3 -5
- package/dist/adapters/opencode/auto-capture-summary.js +50 -8
- package/dist/adapters/opencode/backfill-models.d.ts +25 -0
- package/dist/adapters/opencode/backfill-models.js +39 -0
- package/dist/adapters/opencode/backfill-startup.d.ts +7 -0
- package/dist/adapters/opencode/backfill-startup.js +12 -0
- package/dist/adapters/opencode/import-command.d.ts +3 -7
- package/dist/adapters/opencode/import-command.js +6 -54
- package/dist/adapters/pi/backfill-models.d.ts +9 -0
- package/dist/adapters/pi/backfill-models.js +30 -0
- package/dist/adapters/pi/extension.js +53 -0
- package/dist/adapters/pi/import-command.js +3 -0
- package/dist/adapters/pi/profile.js +2 -1
- package/dist/adapters/pi/provider.js +41 -14
- package/dist/cli/index.js +13 -1
- package/dist/cli/web-command.d.ts +3 -0
- package/dist/cli/web-command.js +72 -0
- package/dist/config.d.ts +30 -1
- package/dist/config.js +131 -9
- package/dist/core/capture-context.js +1 -1
- package/dist/core/capture.d.ts +4 -0
- package/dist/core/capture.js +30 -0
- package/dist/core/extraction.d.ts +23 -1
- package/dist/core/extraction.js +41 -0
- package/dist/core/host.d.ts +23 -0
- package/dist/core/host.js +9 -1
- package/dist/core/profile-analysis.js +9 -1
- package/dist/importer/auto-backfill.d.ts +20 -0
- package/dist/importer/auto-backfill.js +165 -0
- package/dist/importer/backfill-lock.d.ts +3 -0
- package/dist/importer/backfill-lock.js +44 -0
- package/dist/importer/backfill-model.d.ts +10 -0
- package/dist/importer/backfill-model.js +13 -0
- 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 +96 -24
- package/dist/importer/manual-import-guard.d.ts +4 -0
- package/dist/importer/manual-import-guard.js +9 -0
- 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 +24 -0
- package/dist/importer/settings-health.js +127 -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 +34 -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/ai/opencode-provider.d.ts +9 -0
- package/dist/services/ai/opencode-provider.js +31 -4
- package/dist/services/ai/providers/anthropic-messages.js +14 -6
- package/dist/services/ai/providers/base-provider.d.ts +8 -0
- package/dist/services/ai/providers/base-provider.js +12 -0
- package/dist/services/ai/providers/google-gemini.js +21 -7
- package/dist/services/ai/providers/openai-chat-completion.js +20 -7
- package/dist/services/ai/providers/openai-responses.js +11 -0
- package/dist/services/backfill-state.d.ts +25 -0
- package/dist/services/backfill-state.js +68 -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.d.ts +66 -0
- package/dist/services/capture-diagnostics.js +175 -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 +110 -0
- package/dist/services/live-captured-entries.d.ts +3 -0
- package/dist/services/live-captured-entries.js +34 -0
- package/dist/services/log-path.d.ts +2 -0
- package/dist/services/log-path.js +15 -0
- package/dist/services/logger.js +1 -12
- package/dist/services/safe-health-error.d.ts +2 -0
- package/dist/services/safe-health-error.js +11 -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 +83 -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-autostart.d.ts +29 -0
- package/dist/services/web-autostart.js +208 -0
- package/dist/services/web-server.d.ts +5 -0
- package/dist/services/web-server.js +187 -3
- package/dist/v2/legacy-client.js +19 -2
- package/dist/web/assets/index-B-_gMSn9.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
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { closeSync, existsSync, openSync, readdirSync, readSync, realpathSync, statSync, } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
5
|
+
import { DEFAULT_PI_SESSION_ROOT, readPiSessionHeader } from "./discovery.js";
|
|
6
|
+
import { DEFAULT_OPENCODE_DB } from "./opencode-reader.js";
|
|
7
|
+
export class ImportSourceError extends Error {
|
|
8
|
+
status;
|
|
9
|
+
constructor(message, status = 400) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.name = "ImportSourceError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
// Tokens only need to outlive the server process that issued them.
|
|
16
|
+
const TOKEN_KEY = randomBytes(32);
|
|
17
|
+
function sign(payload) {
|
|
18
|
+
return createHmac("sha256", TOKEN_KEY).update(payload).digest("base64url");
|
|
19
|
+
}
|
|
20
|
+
export function signImportSource(identity) {
|
|
21
|
+
const payload = Buffer.from(JSON.stringify(identity)).toString("base64url");
|
|
22
|
+
return `${payload}.${sign(payload)}`;
|
|
23
|
+
}
|
|
24
|
+
export function readImportSourceToken(token, host) {
|
|
25
|
+
if (typeof token !== "string")
|
|
26
|
+
throw new ImportSourceError("Choose a history source");
|
|
27
|
+
const [payload = "", signature = ""] = token.split(".");
|
|
28
|
+
const expected = Buffer.from(sign(payload));
|
|
29
|
+
const actual = Buffer.from(signature);
|
|
30
|
+
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
|
|
31
|
+
throw new ImportSourceError("The history source is no longer valid. Choose it again.");
|
|
32
|
+
}
|
|
33
|
+
const identity = JSON.parse(Buffer.from(payload, "base64url").toString());
|
|
34
|
+
if (identity.host !== host)
|
|
35
|
+
throw new ImportSourceError("The source belongs to the other host");
|
|
36
|
+
return identity;
|
|
37
|
+
}
|
|
38
|
+
/** Stable key for sharing snapshots and computing revisions. */
|
|
39
|
+
export function importSourceKey(identity) {
|
|
40
|
+
return `${identity.realPath}:${identity.dev}:${identity.ino}`;
|
|
41
|
+
}
|
|
42
|
+
/** Refuse a source whose path now names another file, such as a swapped-in symlink. */
|
|
43
|
+
export function assertImportSourceUnchanged(identity) {
|
|
44
|
+
let current;
|
|
45
|
+
try {
|
|
46
|
+
current = statSync(identity.realPath);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
throw new ImportSourceError("The history source no longer exists. Choose it again.", 409);
|
|
50
|
+
}
|
|
51
|
+
if (current.dev !== identity.dev || current.ino !== identity.ino) {
|
|
52
|
+
throw new ImportSourceError("The history source changed. Choose it again.", 409);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function checkedPath(path) {
|
|
56
|
+
if (typeof path !== "string" || !path.trim())
|
|
57
|
+
throw new ImportSourceError("Enter a path");
|
|
58
|
+
if (!isAbsolute(path))
|
|
59
|
+
throw new ImportSourceError("Enter an absolute path");
|
|
60
|
+
if (path.split(/[\\/]/).includes("..")) {
|
|
61
|
+
throw new ImportSourceError("The path must not contain '..' segments");
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
// Symlinks at or above the chosen path are fine: macOS /tmp, a linked ~/.pi.
|
|
65
|
+
return realpathSync.native(path);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
throw new ImportSourceError("Path not found");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function isSqliteFile(path) {
|
|
72
|
+
const fd = openSync(path, "r");
|
|
73
|
+
try {
|
|
74
|
+
const buffer = Buffer.alloc(16);
|
|
75
|
+
readSync(fd, buffer, 0, 16, 0);
|
|
76
|
+
return buffer.toString("latin1") === "SQLite format 3\0";
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
closeSync(fd);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export function defaultImportSourcePath(host) {
|
|
83
|
+
return host === "pi" ? DEFAULT_PI_SESSION_ROOT : DEFAULT_OPENCODE_DB;
|
|
84
|
+
}
|
|
85
|
+
/** Check an absolute path's kind and format; never returns file contents. */
|
|
86
|
+
export function validateImportSource(host, path) {
|
|
87
|
+
const realPath = checkedPath(path);
|
|
88
|
+
const info = statSync(realPath);
|
|
89
|
+
let kind;
|
|
90
|
+
if (host === "pi") {
|
|
91
|
+
if (info.isDirectory())
|
|
92
|
+
kind = "pi-folder";
|
|
93
|
+
else if (info.isFile() && realPath.endsWith(".jsonl")) {
|
|
94
|
+
if ("reason" in readPiSessionHeader(realPath, "")) {
|
|
95
|
+
throw new ImportSourceError("The file is not a Pi session file");
|
|
96
|
+
}
|
|
97
|
+
kind = "pi-file";
|
|
98
|
+
}
|
|
99
|
+
else
|
|
100
|
+
throw new ImportSourceError("Choose a Pi sessions folder or one .jsonl session file");
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
if (!info.isFile() || !isSqliteFile(realPath)) {
|
|
104
|
+
throw new ImportSourceError("Choose an OpenCode database file");
|
|
105
|
+
}
|
|
106
|
+
kind = "opencode-db";
|
|
107
|
+
}
|
|
108
|
+
const identity = { host, kind, realPath, dev: info.dev, ino: info.ino };
|
|
109
|
+
return { kind, displayPath: realPath, sourceToken: signImportSource(identity) };
|
|
110
|
+
}
|
|
111
|
+
const BROWSE_LIMIT = 500;
|
|
112
|
+
/**
|
|
113
|
+
* One folder at a time, with no recursion: subfolders and files the host can
|
|
114
|
+
* import. Symlinked entries are hidden. Only offered on loopback binds.
|
|
115
|
+
*/
|
|
116
|
+
export function browseImportSources(host, path) {
|
|
117
|
+
let start = path;
|
|
118
|
+
if (start === undefined || start === null || start === "") {
|
|
119
|
+
const preferred = host === "pi" ? DEFAULT_PI_SESSION_ROOT : dirname(DEFAULT_OPENCODE_DB);
|
|
120
|
+
start = existsSync(preferred) ? preferred : homedir();
|
|
121
|
+
}
|
|
122
|
+
const folder = checkedPath(start);
|
|
123
|
+
if (!statSync(folder).isDirectory())
|
|
124
|
+
throw new ImportSourceError("Choose a folder to browse");
|
|
125
|
+
const entries = [];
|
|
126
|
+
for (const entry of readdirSync(folder, { withFileTypes: true })) {
|
|
127
|
+
if (entry.isSymbolicLink())
|
|
128
|
+
continue;
|
|
129
|
+
const full = join(folder, entry.name);
|
|
130
|
+
if (entry.isDirectory())
|
|
131
|
+
entries.push({ name: entry.name, path: full, kind: "folder" });
|
|
132
|
+
else if (entry.isFile() && host === "pi" && entry.name.endsWith(".jsonl")) {
|
|
133
|
+
entries.push({ name: entry.name, path: full, kind: "pi-file" });
|
|
134
|
+
}
|
|
135
|
+
else if (entry.isFile() && host === "opencode" && entry.name.endsWith(".db")) {
|
|
136
|
+
entries.push({ name: entry.name, path: full, kind: "opencode-db" });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
entries.sort((a, b) => Number(b.kind === "folder") - Number(a.kind === "folder") || a.name.localeCompare(b.name));
|
|
140
|
+
const parent = dirname(folder);
|
|
141
|
+
return {
|
|
142
|
+
path: folder,
|
|
143
|
+
parent: parent === folder ? null : parent,
|
|
144
|
+
entries: entries.slice(0, BROWSE_LIMIT),
|
|
145
|
+
truncated: entries.length > BROWSE_LIMIT,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -29,6 +29,13 @@ export interface ImportFilters {
|
|
|
29
29
|
skipMemories?: boolean;
|
|
30
30
|
pathMaps?: ImportPathMap[];
|
|
31
31
|
root?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Web selection: only these discovery keys (paths relative to `root`).
|
|
34
|
+
* Never combined with `session` or `maxSessions`.
|
|
35
|
+
*/
|
|
36
|
+
selectionKeys?: string[];
|
|
37
|
+
/** Web selection: user turns after this epoch ms are held back for a later run. */
|
|
38
|
+
cutoff?: number;
|
|
32
39
|
}
|
|
33
40
|
export type ImportUnitStatus = "imported" | "would-import" | "skipped" | "failed" | "already-handled";
|
|
34
41
|
export interface ImportUnitReport {
|
|
@@ -72,11 +79,16 @@ export interface ImportReport {
|
|
|
72
79
|
projects: ImportProjectReport[];
|
|
73
80
|
units: ImportUnitReport[];
|
|
74
81
|
profile?: ProfileImportReport;
|
|
82
|
+
/** Turns newer than the web listing time, left for a later run. */
|
|
83
|
+
unitsHeldBack?: number;
|
|
84
|
+
/** Turns without a timestamp; date limits cannot exclude them. */
|
|
85
|
+
unitsUntimed?: number;
|
|
75
86
|
}
|
|
76
87
|
export interface ImporterDeps {
|
|
77
88
|
loadSession: (file: string) => LoadedPiSession;
|
|
78
89
|
provider: CaptureSummaryProvider;
|
|
79
90
|
onProgress?: (processed: number, total: number, promptPreview: string) => void;
|
|
91
|
+
signal?: AbortSignal;
|
|
80
92
|
ledger?: PiImportLedger;
|
|
81
93
|
profile?: {
|
|
82
94
|
model?: ModelPort;
|
|
@@ -106,6 +118,24 @@ export declare function findMemoryIdByImportId(projectHash: string, importId: st
|
|
|
106
118
|
* /private/var) name one project, so compare through the real path.
|
|
107
119
|
*/
|
|
108
120
|
export declare function projectFilterTag(directory: string): string;
|
|
121
|
+
/**
|
|
122
|
+
* Keep the windows a run would import: inside the date range and not newer
|
|
123
|
+
* than the web listing cutoff. Held-back windows are only counted; they never
|
|
124
|
+
* reach the ledger, so a later run imports them under the same keys.
|
|
125
|
+
*/
|
|
126
|
+
export declare function selectImportWindows<T extends ImportWindow>(windows: T[], filters: Pick<ImportFilters, "since" | "until" | "cutoff">): {
|
|
127
|
+
kept: T[];
|
|
128
|
+
heldBack: number;
|
|
129
|
+
untimed: number;
|
|
130
|
+
};
|
|
131
|
+
/** Units of one session that `importHistorySource` would consider, for a counting pass. */
|
|
132
|
+
export declare function countImportableUnits(session: ImportSourceSession, host: MemoryHost, filters: Pick<ImportFilters, "since" | "until">): number;
|
|
133
|
+
/** Sessions produced on demand, so a large source never holds every turn at once. */
|
|
134
|
+
export interface LazyImportSource {
|
|
135
|
+
/** Units that pass the date and key filters, counted in an earlier pass. */
|
|
136
|
+
total: number;
|
|
137
|
+
open: () => AsyncIterable<ImportSourceSession>;
|
|
138
|
+
}
|
|
109
139
|
/** Process host-labelled work units through the shared capture and ledger pipeline. */
|
|
110
|
-
export declare function importHistorySource(source: AsyncIterable<ImportSourceSession> | Iterable<ImportSourceSession
|
|
140
|
+
export declare function importHistorySource(source: AsyncIterable<ImportSourceSession> | Iterable<ImportSourceSession> | LazyImportSource, host: MemoryHost, deps: Pick<ImporterDeps, "provider" | "ledger" | "onProgress" | "signal">, filters: Pick<ImportFilters, "dryRun" | "force" | "since" | "until">, report: ImportReport): Promise<void>;
|
|
111
141
|
export declare function importPiHistory(deps: ImporterDeps, filters: ImportFilters): Promise<ImportReport>;
|