om-memory-system 3.5.0 → 3.6.0-next.38
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 +24 -16
- package/dist/adapters/claude-code/hook-client.d.ts +73 -0
- package/dist/adapters/claude-code/hook-client.js +285 -0
- package/dist/adapters/claude-code/hook-command.d.ts +6 -0
- package/dist/adapters/claude-code/hook-command.js +25 -0
- package/dist/cli/index.js +19 -3
- package/dist/cli/memory-command.d.ts +24 -0
- package/dist/cli/memory-command.js +180 -0
- package/dist/core/retrieval.d.ts +21 -0
- package/dist/core/retrieval.js +35 -0
- package/dist/importer/backfill-controls.d.ts +10 -0
- package/dist/importer/backfill-controls.js +40 -11
- package/dist/importer/backfill-model.d.ts +1 -1
- package/dist/importer/backfill-model.js +3 -0
- package/dist/importer/claude-conversation.d.ts +74 -0
- package/dist/importer/claude-conversation.js +216 -0
- package/dist/importer/claude-hook-api.d.ts +50 -0
- package/dist/importer/claude-hook-api.js +441 -0
- package/dist/importer/claude-import.d.ts +15 -0
- package/dist/importer/claude-import.js +62 -0
- package/dist/importer/claude-reader.d.ts +99 -0
- package/dist/importer/claude-reader.js +292 -0
- package/dist/importer/external-backfill-models.js +2 -2
- package/dist/importer/import-args.d.ts +1 -1
- package/dist/importer/import-args.js +11 -4
- package/dist/importer/import-readiness.d.ts +13 -1
- package/dist/importer/import-readiness.js +19 -0
- package/dist/importer/import-runs.js +1 -1
- package/dist/importer/import-sessions.d.ts +4 -1
- package/dist/importer/import-sessions.js +14 -2
- package/dist/importer/import-sources.d.ts +8 -2
- package/dist/importer/import-sources.js +21 -4
- package/dist/importer/map-suggestions.d.ts +1 -0
- package/dist/importer/map-suggestions.js +6 -1
- package/dist/importer/run-import.js +14 -10
- package/dist/importer/web-import-jobs.js +10 -3
- package/dist/index.js +9 -23
- package/dist/services/ai/live-model-choice.d.ts +5 -0
- package/dist/services/ai/live-model-choice.js +7 -0
- package/dist/services/backfill-state.d.ts +1 -1
- package/dist/services/capture-retry-queue.js +3 -2
- package/dist/services/claude-capture-cursor.d.ts +10 -0
- package/dist/services/claude-capture-cursor.js +44 -0
- package/dist/services/settings-snapshot.d.ts +1 -0
- package/dist/services/settings-snapshot.js +3 -1
- package/dist/services/web-server.d.ts +2 -0
- package/dist/services/web-server.js +61 -28
- package/dist/types/host-label.d.ts +3 -0
- package/dist/types/host-label.js +9 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/web/assets/index-BFiVixOK.js +124 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/dist/web/assets/index-DIy--9g_.js +0 -124
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, statSync, } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { StringDecoder } from "node:string_decoder";
|
|
5
|
+
import { extractScopeFromContainerTag } from "../services/memory-scope.js";
|
|
6
|
+
import { getTags } from "../services/tags.js";
|
|
7
|
+
import { extractClaudeConversationWindows, parseClaudeTranscript, parseClaudeTranscriptLine, } from "./claude-conversation.js";
|
|
8
|
+
import { resolveImportProject } from "./import-project.js";
|
|
9
|
+
import { countImportableUnits, projectFilterTag, selectImportWindows, } from "./importer.js";
|
|
10
|
+
/**
|
|
11
|
+
* Discovery and loading of Claude Code transcripts for the history import.
|
|
12
|
+
* Read-only: transcripts are never written.
|
|
13
|
+
*
|
|
14
|
+
* Claude Code keeps one folder per project under `~/.claude/projects/` and one
|
|
15
|
+
* `<session-id>.jsonl` file per session in it. Subagent transcripts sit one
|
|
16
|
+
* level lower (`<session-id>/subagents/*.jsonl`) and are not sessions, so the
|
|
17
|
+
* walk reads only the root and its direct sub-folders. The root may also be a
|
|
18
|
+
* single project folder or one `.jsonl` file. Symlinks are never followed.
|
|
19
|
+
*
|
|
20
|
+
* Discovery reads each file only up to its first user entry, which carries
|
|
21
|
+
* the working directory and date; units are parsed later, one session at a
|
|
22
|
+
* time.
|
|
23
|
+
*/
|
|
24
|
+
export const CLAUDE_HEADER_READ_LIMIT = 1024 * 1024;
|
|
25
|
+
const READ_CHUNK = 64 * 1024;
|
|
26
|
+
export function defaultClaudeProjectsRoot() {
|
|
27
|
+
return join(homedir(), ".claude", "projects");
|
|
28
|
+
}
|
|
29
|
+
/** A missing folder is an empty history. Any other read error is reported, not hidden. */
|
|
30
|
+
function readEntries(dir, unreadable) {
|
|
31
|
+
try {
|
|
32
|
+
return readdirSync(dir, { withFileTypes: true });
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
const code = error.code;
|
|
36
|
+
if (code !== "ENOENT")
|
|
37
|
+
unreadable.push({ file: dir, reason: `unreadable: ${code ?? "error"}` });
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function jsonlCandidate(dir, entry, keyPrefix) {
|
|
42
|
+
if (!entry.name.endsWith(".jsonl"))
|
|
43
|
+
return null;
|
|
44
|
+
const file = join(dir, entry.name);
|
|
45
|
+
if (!entry.isFile())
|
|
46
|
+
return { file, reason: "not a regular file" };
|
|
47
|
+
return { file, key: keyPrefix + entry.name };
|
|
48
|
+
}
|
|
49
|
+
/** `.jsonl` files in the root and in its direct sub-folders, sorted by key. */
|
|
50
|
+
function listCandidates(root) {
|
|
51
|
+
const candidates = [];
|
|
52
|
+
for (const entry of readEntries(root, candidates)) {
|
|
53
|
+
if (entry.isDirectory()) {
|
|
54
|
+
const dir = join(root, entry.name);
|
|
55
|
+
for (const child of readEntries(dir, candidates)) {
|
|
56
|
+
const candidate = jsonlCandidate(dir, child, `${entry.name}/`);
|
|
57
|
+
if (candidate)
|
|
58
|
+
candidates.push(candidate);
|
|
59
|
+
}
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const candidate = jsonlCandidate(root, entry, "");
|
|
63
|
+
if (candidate)
|
|
64
|
+
candidates.push(candidate);
|
|
65
|
+
}
|
|
66
|
+
return candidates.sort((a, b) => a.file.localeCompare(b.file));
|
|
67
|
+
}
|
|
68
|
+
/** Read lines from the start of a file until `visit` returns true or the limit is reached. */
|
|
69
|
+
function scanLines(file, visit, limit) {
|
|
70
|
+
const fd = openSync(file, "r");
|
|
71
|
+
try {
|
|
72
|
+
const decoder = new StringDecoder("utf8");
|
|
73
|
+
const buffer = Buffer.alloc(READ_CHUNK);
|
|
74
|
+
let pending = "";
|
|
75
|
+
let offset = 0;
|
|
76
|
+
while (offset < limit) {
|
|
77
|
+
const read = readSync(fd, buffer, 0, Math.min(READ_CHUNK, limit - offset), offset);
|
|
78
|
+
if (read === 0)
|
|
79
|
+
break;
|
|
80
|
+
offset += read;
|
|
81
|
+
const lines = (pending + decoder.write(buffer.subarray(0, read))).split("\n");
|
|
82
|
+
pending = lines.pop() ?? "";
|
|
83
|
+
if (lines.some(visit))
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (offset < limit)
|
|
87
|
+
visit(pending + decoder.end());
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
closeSync(fd);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Read one file's first user entry, or say why the file is not a usable session. */
|
|
94
|
+
export function readClaudeSessionHead(file, key) {
|
|
95
|
+
let head = null;
|
|
96
|
+
try {
|
|
97
|
+
scanLines(file, (line) => {
|
|
98
|
+
const parsed = parseClaudeTranscriptLine(line);
|
|
99
|
+
if (parsed.status !== "entry")
|
|
100
|
+
return false;
|
|
101
|
+
const entry = parsed.entry;
|
|
102
|
+
if (entry.type !== "user" || entry.isSidechain === true)
|
|
103
|
+
return false;
|
|
104
|
+
const timestamp = Date.parse(entry.timestamp ?? "");
|
|
105
|
+
head = {
|
|
106
|
+
file,
|
|
107
|
+
key,
|
|
108
|
+
sessionId: typeof entry.sessionId === "string" ? entry.sessionId : basename(file, ".jsonl"),
|
|
109
|
+
cwd: typeof entry.cwd === "string" ? entry.cwd : null,
|
|
110
|
+
timestamp: Number.isNaN(timestamp) ? null : timestamp,
|
|
111
|
+
};
|
|
112
|
+
return true;
|
|
113
|
+
}, CLAUDE_HEADER_READ_LIMIT);
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
return { file, reason: `unreadable: ${error.code ?? "error"}` };
|
|
117
|
+
}
|
|
118
|
+
return head ?? { file, reason: "no user entry" };
|
|
119
|
+
}
|
|
120
|
+
function isFile(path) {
|
|
121
|
+
try {
|
|
122
|
+
return statSync(path).isFile();
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
export function discoverClaudeSessions(options = {}) {
|
|
129
|
+
const root = options.root ?? defaultClaudeProjectsRoot();
|
|
130
|
+
const sessions = [];
|
|
131
|
+
const unrecognized = [];
|
|
132
|
+
if (!existsSync(root))
|
|
133
|
+
return { sessions, unrecognized };
|
|
134
|
+
const candidates = isFile(root) ? [{ file: root, key: basename(root) }] : listCandidates(root);
|
|
135
|
+
for (const candidate of candidates) {
|
|
136
|
+
if ("reason" in candidate) {
|
|
137
|
+
unrecognized.push(candidate);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const result = readClaudeSessionHead(candidate.file, candidate.key);
|
|
141
|
+
if ("reason" in result)
|
|
142
|
+
unrecognized.push(result);
|
|
143
|
+
else
|
|
144
|
+
sessions.push(result);
|
|
145
|
+
}
|
|
146
|
+
// Chronological order, oldest first; files without a timestamp sort last.
|
|
147
|
+
sessions.sort((a, b) => (a.timestamp ?? Infinity) - (b.timestamp ?? Infinity));
|
|
148
|
+
const limited = options.maxSessions !== undefined && options.maxSessions >= 0
|
|
149
|
+
? sessions.slice(0, options.maxSessions)
|
|
150
|
+
: sessions;
|
|
151
|
+
return { sessions: limited, unrecognized };
|
|
152
|
+
}
|
|
153
|
+
/** Parse one transcript into capture windows. Throws when the file cannot be read. */
|
|
154
|
+
export function loadClaudeSession(file) {
|
|
155
|
+
const parsed = parseClaudeTranscript(readFileSync(file, "utf8"));
|
|
156
|
+
const first = parsed.entries.find((entry) => entry.type === "user" && entry.isSidechain !== true);
|
|
157
|
+
const withId = parsed.entries.find((entry) => typeof entry.sessionId === "string");
|
|
158
|
+
return {
|
|
159
|
+
sessionId: withId?.sessionId ?? basename(file, ".jsonl"),
|
|
160
|
+
sourceFile: file,
|
|
161
|
+
cwd: typeof first?.cwd === "string" ? first.cwd : null,
|
|
162
|
+
windows: extractClaudeConversationWindows(parsed.entries),
|
|
163
|
+
unreadableLines: parsed.unreadableLines,
|
|
164
|
+
unknownTypes: parsed.unknownTypes,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/** Let the web server and live capture run between sessions of a long read. */
|
|
168
|
+
const yieldToEventLoop = () => new Promise((resolve) => setImmediate(resolve));
|
|
169
|
+
function errorMessage(error) {
|
|
170
|
+
return error instanceof Error ? error.message : String(error);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Discover Claude Code sessions and return them as a `LazyImportSource`. A
|
|
174
|
+
* counting pass loads each selected session once for the totals and the
|
|
175
|
+
* per-project report; `open()` loads them again one at a time, so a large
|
|
176
|
+
* history never holds every turn at once.
|
|
177
|
+
*/
|
|
178
|
+
export async function openClaudeHistorySource(options = {}, deps = {}) {
|
|
179
|
+
const loadSession = deps.loadSession ?? loadClaudeSession;
|
|
180
|
+
const discovery = discoverClaudeSessions({
|
|
181
|
+
...(options.root !== undefined ? { root: options.root } : {}),
|
|
182
|
+
...(options.maxSessions !== undefined ? { maxSessions: options.maxSessions } : {}),
|
|
183
|
+
});
|
|
184
|
+
const selectedKeys = options.selectionKeys ? new Set(options.selectionKeys) : null;
|
|
185
|
+
const projectTag = options.project ? projectFilterTag(options.project) : null;
|
|
186
|
+
const source = {
|
|
187
|
+
total: 0,
|
|
188
|
+
open: () => sessions(),
|
|
189
|
+
sessionsDiscovered: discovery.sessions.length,
|
|
190
|
+
sessionsLoaded: 0,
|
|
191
|
+
sessionsFilteredOut: 0,
|
|
192
|
+
unrecognized: discovery.unrecognized,
|
|
193
|
+
unresolvableSessions: [],
|
|
194
|
+
loadErrors: [],
|
|
195
|
+
projects: [],
|
|
196
|
+
unitsKept: 0,
|
|
197
|
+
unitsHeldBack: 0,
|
|
198
|
+
unitsUntimed: 0,
|
|
199
|
+
unreadableLines: 0,
|
|
200
|
+
unknownTypes: 0,
|
|
201
|
+
};
|
|
202
|
+
const selected = [];
|
|
203
|
+
for (const discovered of discovery.sessions) {
|
|
204
|
+
if (selectedKeys && !selectedKeys.has(discovered.key)) {
|
|
205
|
+
source.sessionsFilteredOut++;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (options.session &&
|
|
209
|
+
discovered.sessionId !== options.session &&
|
|
210
|
+
discovered.file !== options.session) {
|
|
211
|
+
source.sessionsFilteredOut++;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const { directory } = resolveImportProject(discovered.cwd, options.pathMaps ?? []);
|
|
215
|
+
if (!directory) {
|
|
216
|
+
source.unresolvableSessions.push({ file: discovered.file, cwd: discovered.cwd });
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (projectTag && projectFilterTag(directory) !== projectTag) {
|
|
220
|
+
source.sessionsFilteredOut++;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
selected.push({ file: discovered.file, directory });
|
|
224
|
+
}
|
|
225
|
+
const build = (session, directory) => {
|
|
226
|
+
const windows = selectImportWindows(session.windows, options);
|
|
227
|
+
const built = {
|
|
228
|
+
sessionId: session.sessionId,
|
|
229
|
+
directory,
|
|
230
|
+
sourceFile: session.sourceFile,
|
|
231
|
+
units: windows.kept,
|
|
232
|
+
};
|
|
233
|
+
return { built, windows };
|
|
234
|
+
};
|
|
235
|
+
// Counting pass: totals for progress and the report, one session in memory at a time.
|
|
236
|
+
const projects = new Map();
|
|
237
|
+
const counted = [];
|
|
238
|
+
for (const item of selected) {
|
|
239
|
+
if (options.signal?.aborted)
|
|
240
|
+
break;
|
|
241
|
+
let session;
|
|
242
|
+
try {
|
|
243
|
+
session = loadSession(item.file);
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
source.loadErrors.push({ file: item.file, error: errorMessage(error) });
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
const { built, windows } = build(session, item.directory);
|
|
250
|
+
const tag = getTags(item.directory).project.tag;
|
|
251
|
+
const project = projects.get(tag) ?? {
|
|
252
|
+
tag,
|
|
253
|
+
hash: extractScopeFromContainerTag(tag).hash,
|
|
254
|
+
directory: item.directory,
|
|
255
|
+
sessions: 0,
|
|
256
|
+
units: 0,
|
|
257
|
+
sessionIds: new Set(),
|
|
258
|
+
};
|
|
259
|
+
project.sessionIds.add(session.sessionId);
|
|
260
|
+
project.sessions = project.sessionIds.size;
|
|
261
|
+
project.units += windows.kept.length;
|
|
262
|
+
projects.set(tag, project);
|
|
263
|
+
source.total += countImportableUnits(built, "claude-code", options);
|
|
264
|
+
source.unitsKept += windows.kept.length;
|
|
265
|
+
source.unitsHeldBack += windows.heldBack;
|
|
266
|
+
source.unitsUntimed += windows.untimed;
|
|
267
|
+
source.unreadableLines += session.unreadableLines;
|
|
268
|
+
source.unknownTypes += session.unknownTypes;
|
|
269
|
+
source.sessionsLoaded++;
|
|
270
|
+
counted.push(item);
|
|
271
|
+
await yieldToEventLoop();
|
|
272
|
+
}
|
|
273
|
+
source.projects = [...projects.values()].map(({ sessionIds: _ids, ...project }) => project);
|
|
274
|
+
async function* sessions() {
|
|
275
|
+
for (const item of counted) {
|
|
276
|
+
if (options.signal?.aborted)
|
|
277
|
+
return;
|
|
278
|
+
await yieldToEventLoop();
|
|
279
|
+
let session;
|
|
280
|
+
try {
|
|
281
|
+
session = loadSession(item.file);
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
// The file changed or vanished after the counting pass.
|
|
285
|
+
source.loadErrors.push({ file: item.file, error: errorMessage(error) });
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
yield build(session, item.directory).built;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return source;
|
|
292
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { externalModelIssues } from "../services/ai/live-model-choice.js";
|
|
2
|
+
import { hostLabel } from "../types/host-label.js";
|
|
2
3
|
/** A backfill set to `external`: the external API, or an error naming the missing settings. */
|
|
3
4
|
export async function resolveExternalBackfillModels(host, config, externalModels) {
|
|
4
5
|
const issues = externalModelIssues(config);
|
|
5
|
-
const label = host === "pi" ? "Pi" : "OpenCode";
|
|
6
6
|
if (issues.length > 0)
|
|
7
|
-
throw new Error(`${
|
|
7
|
+
throw new Error(`${hostLabel(host)} backfill: ${issues.join("; ")}`);
|
|
8
8
|
const select = externalModels ?? (await import("./model-selection.js")).selectImportModel;
|
|
9
9
|
const external = select({});
|
|
10
10
|
return {
|
|
@@ -6,7 +6,7 @@ import type { ImportPathMap } from "./importer.js";
|
|
|
6
6
|
* model is chosen (a session uses its host's sign-in; the CLI uses an
|
|
7
7
|
* external API and key).
|
|
8
8
|
*/
|
|
9
|
-
export type ImportHost = "pi" | "opencode";
|
|
9
|
+
export type ImportHost = "pi" | "opencode" | "claude-code";
|
|
10
10
|
export type ImportSurface = "session" | "cli" | "web";
|
|
11
11
|
export interface HistoryImportArgs {
|
|
12
12
|
help: boolean;
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
export const SOURCE_FLAG = {
|
|
1
|
+
export const SOURCE_FLAG = {
|
|
2
|
+
pi: "--root",
|
|
3
|
+
opencode: "--db",
|
|
4
|
+
"claude-code": "--root",
|
|
5
|
+
};
|
|
2
6
|
const CLI_ONLY_FLAGS = new Set(["--provider", "--api-url", "--api-key-env"]);
|
|
3
7
|
const BOOLEAN_FLAGS = new Set([
|
|
4
8
|
"--help",
|
|
@@ -209,13 +213,16 @@ export function importNeedsModel(args) {
|
|
|
209
213
|
return !args.dryRun && (!args.skipMemories || !args.skipProfile);
|
|
210
214
|
}
|
|
211
215
|
export function historyImportUsage(host, surface) {
|
|
212
|
-
const name = host === "
|
|
213
|
-
|
|
216
|
+
const name = host === "claude-code" ? "claude" : host;
|
|
217
|
+
// Claude Code has no session command surface, so its usage names the terminal command.
|
|
218
|
+
const invoke = surface === "session" && host !== "claude-code"
|
|
214
219
|
? `/memory-import-${name}-history`
|
|
215
220
|
: `om-memory-system import-${name}-history`;
|
|
216
221
|
const source = host === "pi"
|
|
217
222
|
? " --root <dir> Pi session root (default ~/.pi/agent/sessions)"
|
|
218
|
-
:
|
|
223
|
+
: host === "claude-code"
|
|
224
|
+
? " --root <dir> Claude Code transcripts folder (default ~/.claude/projects)"
|
|
225
|
+
: " --db <path> OpenCode database (default ~/.local/share/opencode/opencode.db)";
|
|
219
226
|
const model = surface === "session"
|
|
220
227
|
? ` --model <provider/id> Use another signed-in model (default: this session's model)`
|
|
221
228
|
: ` --model <id> Model id (default: saved memoryModel)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ImportHost } from "./import-args.js";
|
|
1
2
|
/**
|
|
2
3
|
* What a real web import could use right now, checked inside the OpenCode
|
|
3
4
|
* process that runs it. `env://` and `file://` keys were resolved when the
|
|
@@ -23,6 +24,16 @@ export interface ImportReadiness {
|
|
|
23
24
|
available: boolean;
|
|
24
25
|
reason?: string;
|
|
25
26
|
};
|
|
27
|
+
/**
|
|
28
|
+
* The Claude Code reader ships with OMMS, so it is always available. Its
|
|
29
|
+
* imports have no session model and always use the external API.
|
|
30
|
+
*/
|
|
31
|
+
claudeCode: {
|
|
32
|
+
available: true;
|
|
33
|
+
defaultRoot: string;
|
|
34
|
+
defaultRootFound: boolean;
|
|
35
|
+
modelChoices: ["external"];
|
|
36
|
+
};
|
|
26
37
|
}
|
|
27
38
|
export interface ReadinessDeps {
|
|
28
39
|
supportedProviders?: () => Promise<string[]>;
|
|
@@ -35,13 +46,14 @@ export interface ReadinessDeps {
|
|
|
35
46
|
}>;
|
|
36
47
|
}>;
|
|
37
48
|
loadPiSdk?: () => Promise<unknown>;
|
|
49
|
+
claudeRoot?: () => string;
|
|
38
50
|
}
|
|
39
51
|
/** Mirrors the checks in `selectImportModel`, without building a provider. */
|
|
40
52
|
export declare function externalApiState(supportedProviders?: () => Promise<string[]>): Promise<ExternalApiState>;
|
|
41
53
|
export declare function importReadiness(deps?: ReadinessDeps): Promise<ImportReadiness>;
|
|
42
54
|
/** Why a job cannot start, or `null` when it can. */
|
|
43
55
|
export declare function importBlockedReason(readiness: ImportReadiness, request: {
|
|
44
|
-
host:
|
|
56
|
+
host: ImportHost;
|
|
45
57
|
needsModel: boolean;
|
|
46
58
|
modelChoice?: string;
|
|
47
59
|
}): string | null;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
1
2
|
import { CONFIG } from "../config.js";
|
|
3
|
+
import { defaultClaudeSourcePath } from "./import-sources.js";
|
|
2
4
|
const EXTERNAL_REASONS = {
|
|
3
5
|
"missing-model": "The external API has no memoryModel in the global config",
|
|
4
6
|
"missing-url": "The external API has no memoryApiUrl in the global config",
|
|
@@ -35,6 +37,15 @@ export async function importReadiness(deps = {}) {
|
|
|
35
37
|
external: { state, provider: CONFIG.memoryProvider, model: CONFIG.memoryModel ?? null },
|
|
36
38
|
opencode: { available: opencode.available, models: opencode.models ?? [] },
|
|
37
39
|
piReader,
|
|
40
|
+
claudeCode: claudeReadiness(deps.claudeRoot?.() ?? defaultClaudeSourcePath()),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function claudeReadiness(defaultRoot) {
|
|
44
|
+
return {
|
|
45
|
+
available: true,
|
|
46
|
+
defaultRoot,
|
|
47
|
+
defaultRootFound: existsSync(defaultRoot),
|
|
48
|
+
modelChoices: ["external"],
|
|
38
49
|
};
|
|
39
50
|
}
|
|
40
51
|
/** Why a job cannot start, or `null` when it can. */
|
|
@@ -44,6 +55,14 @@ export function importBlockedReason(readiness, request) {
|
|
|
44
55
|
}
|
|
45
56
|
if (!request.needsModel)
|
|
46
57
|
return null;
|
|
58
|
+
if (request.host === "claude-code") {
|
|
59
|
+
// Claude Code has no host models to offer.
|
|
60
|
+
if (request.modelChoice !== undefined && request.modelChoice !== "external") {
|
|
61
|
+
return "Claude Code imports use the external API";
|
|
62
|
+
}
|
|
63
|
+
const state = readiness.external.state;
|
|
64
|
+
return state === "ready" ? null : EXTERNAL_REASONS[state];
|
|
65
|
+
}
|
|
47
66
|
if (!request.modelChoice)
|
|
48
67
|
return "Choose an import model";
|
|
49
68
|
if (request.modelChoice === "external") {
|
|
@@ -16,7 +16,7 @@ async function table(storagePath) {
|
|
|
16
16
|
samples TEXT, paused INTEGER NOT NULL DEFAULT 0
|
|
17
17
|
)`);
|
|
18
18
|
// A row per host keeps every later statement a plain UPDATE.
|
|
19
|
-
await db.run("INSERT OR IGNORE INTO import_runs (host) VALUES ('pi'), ('opencode')");
|
|
19
|
+
await db.run("INSERT OR IGNORE INTO import_runs (host) VALUES ('pi'), ('opencode'), ('claude-code')");
|
|
20
20
|
return db;
|
|
21
21
|
}
|
|
22
22
|
const count = (value) => (Number.isSafeInteger(value) && value >= 0 ? value : 0);
|
|
@@ -9,7 +9,10 @@ import type { ImportPathMap } from "./importer.js";
|
|
|
9
9
|
*/
|
|
10
10
|
export declare const MAX_SELECTED_KEYS = 1000;
|
|
11
11
|
export interface ImportSessionRow {
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Selection key: the OpenCode session ID, or the Pi or Claude Code file path
|
|
14
|
+
* relative to the source.
|
|
15
|
+
*/
|
|
13
16
|
key: string;
|
|
14
17
|
sessionId: string | null;
|
|
15
18
|
createdAt: number | null;
|
|
@@ -27,6 +27,17 @@ async function readRows(identity, options, snapshotMode) {
|
|
|
27
27
|
...resolveImportProject(session.cwd, options.pathMaps),
|
|
28
28
|
}));
|
|
29
29
|
}
|
|
30
|
+
if (options.host === "claude-code") {
|
|
31
|
+
// The reader loads the storage engine, so it stays out of this module's static imports.
|
|
32
|
+
const { discoverClaudeSessions } = await import("./claude-reader.js");
|
|
33
|
+
return discoverClaudeSessions({ root: identity.realPath }).sessions.map((session) => ({
|
|
34
|
+
key: session.key,
|
|
35
|
+
sessionId: session.sessionId,
|
|
36
|
+
createdAt: session.timestamp,
|
|
37
|
+
recordedDirectory: session.cwd,
|
|
38
|
+
...resolveImportProject(session.cwd, options.pathMaps),
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
30
41
|
const { openOpencodeHistory } = await import("./opencode-reader.js");
|
|
31
42
|
const history = await openOpencodeHistory(identity.realPath, {}, { shared: { key: importSourceKey(identity), mode: snapshotMode } });
|
|
32
43
|
try {
|
|
@@ -87,8 +98,9 @@ export function validateSessionListRequest(value) {
|
|
|
87
98
|
const input = value;
|
|
88
99
|
if (!input || typeof input !== "object")
|
|
89
100
|
throw new Error("Invalid session list request");
|
|
90
|
-
if (input.host !== "pi" && input.host !== "opencode")
|
|
91
|
-
throw new Error("Choose Pi or
|
|
101
|
+
if (input.host !== "pi" && input.host !== "opencode" && input.host !== "claude-code") {
|
|
102
|
+
throw new Error("Choose Pi, OpenCode, or Claude Code");
|
|
103
|
+
}
|
|
92
104
|
const scope = input.scope ?? "current-project";
|
|
93
105
|
if (scope !== "current-project" && scope !== "all-projects")
|
|
94
106
|
throw new Error("Invalid scope");
|
|
@@ -4,7 +4,7 @@ import type { ImportHost } from "./import-args.js";
|
|
|
4
4
|
* file: it names a server-side path, the server checks it once, and later
|
|
5
5
|
* requests carry a signed token that pins the real path, device, and inode.
|
|
6
6
|
*/
|
|
7
|
-
export type ImportSourceKind = "pi-folder" | "pi-file" | "opencode-db";
|
|
7
|
+
export type ImportSourceKind = "pi-folder" | "pi-file" | "opencode-db" | "claude-projects";
|
|
8
8
|
export interface ImportSourceIdentity {
|
|
9
9
|
host: ImportHost;
|
|
10
10
|
kind: ImportSourceKind;
|
|
@@ -22,6 +22,12 @@ export declare function readImportSourceToken(token: unknown, host: ImportHost):
|
|
|
22
22
|
export declare function importSourceKey(identity: ImportSourceIdentity): string;
|
|
23
23
|
/** Refuse a source whose path now names another file, such as a swapped-in symlink. */
|
|
24
24
|
export declare function assertImportSourceUnchanged(identity: ImportSourceIdentity): void;
|
|
25
|
+
/**
|
|
26
|
+
* Same folder as `defaultClaudeProjectsRoot()` in `claude-reader.ts`. That
|
|
27
|
+
* module loads the storage engine, so this light module keeps its own copy;
|
|
28
|
+
* `tests/import-sources.test.ts` checks that the two agree.
|
|
29
|
+
*/
|
|
30
|
+
export declare function defaultClaudeSourcePath(): string;
|
|
25
31
|
export declare function defaultImportSourcePath(host: ImportHost): string;
|
|
26
32
|
export interface ValidatedImportSource {
|
|
27
33
|
kind: ImportSourceKind;
|
|
@@ -37,7 +43,7 @@ export interface BrowseEntry {
|
|
|
37
43
|
}
|
|
38
44
|
/**
|
|
39
45
|
* 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.
|
|
46
|
+
* import. A Claude Code source is always a folder, so it lists folders only. Symlinked entries are hidden. Only offered on loopback binds.
|
|
41
47
|
*/
|
|
42
48
|
export declare function browseImportSources(host: ImportHost, path?: unknown): {
|
|
43
49
|
path: string;
|
|
@@ -32,7 +32,7 @@ export function readImportSourceToken(token, host) {
|
|
|
32
32
|
}
|
|
33
33
|
const identity = JSON.parse(Buffer.from(payload, "base64url").toString());
|
|
34
34
|
if (identity.host !== host)
|
|
35
|
-
throw new ImportSourceError("The source belongs to
|
|
35
|
+
throw new ImportSourceError("The source belongs to another host");
|
|
36
36
|
return identity;
|
|
37
37
|
}
|
|
38
38
|
/** Stable key for sharing snapshots and computing revisions. */
|
|
@@ -79,8 +79,20 @@ function isSqliteFile(path) {
|
|
|
79
79
|
closeSync(fd);
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Same folder as `defaultClaudeProjectsRoot()` in `claude-reader.ts`. That
|
|
84
|
+
* module loads the storage engine, so this light module keeps its own copy;
|
|
85
|
+
* `tests/import-sources.test.ts` checks that the two agree.
|
|
86
|
+
*/
|
|
87
|
+
export function defaultClaudeSourcePath() {
|
|
88
|
+
return join(homedir(), ".claude", "projects");
|
|
89
|
+
}
|
|
82
90
|
export function defaultImportSourcePath(host) {
|
|
83
|
-
|
|
91
|
+
if (host === "pi")
|
|
92
|
+
return DEFAULT_PI_SESSION_ROOT;
|
|
93
|
+
if (host === "claude-code")
|
|
94
|
+
return defaultClaudeSourcePath();
|
|
95
|
+
return DEFAULT_OPENCODE_DB;
|
|
84
96
|
}
|
|
85
97
|
/** Check an absolute path's kind and format; never returns file contents. */
|
|
86
98
|
export function validateImportSource(host, path) {
|
|
@@ -99,6 +111,11 @@ export function validateImportSource(host, path) {
|
|
|
99
111
|
else
|
|
100
112
|
throw new ImportSourceError("Choose a Pi sessions folder or one .jsonl session file");
|
|
101
113
|
}
|
|
114
|
+
else if (host === "claude-code") {
|
|
115
|
+
if (!info.isDirectory())
|
|
116
|
+
throw new ImportSourceError("Choose a Claude Code transcripts folder");
|
|
117
|
+
kind = "claude-projects";
|
|
118
|
+
}
|
|
102
119
|
else {
|
|
103
120
|
if (!info.isFile() || !isSqliteFile(realPath)) {
|
|
104
121
|
throw new ImportSourceError("Choose an OpenCode database file");
|
|
@@ -111,12 +128,12 @@ export function validateImportSource(host, path) {
|
|
|
111
128
|
const BROWSE_LIMIT = 500;
|
|
112
129
|
/**
|
|
113
130
|
* 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.
|
|
131
|
+
* import. A Claude Code source is always a folder, so it lists folders only. Symlinked entries are hidden. Only offered on loopback binds.
|
|
115
132
|
*/
|
|
116
133
|
export function browseImportSources(host, path) {
|
|
117
134
|
let start = path;
|
|
118
135
|
if (start === undefined || start === null || start === "") {
|
|
119
|
-
const preferred = host === "
|
|
136
|
+
const preferred = host === "opencode" ? dirname(DEFAULT_OPENCODE_DB) : defaultImportSourcePath(host);
|
|
120
137
|
start = existsSync(preferred) ? preferred : homedir();
|
|
121
138
|
}
|
|
122
139
|
const folder = checkedPath(start);
|
|
@@ -129,5 +129,10 @@ export async function directoryMapsView(options = {}) {
|
|
|
129
129
|
...(name === "opencode" ? { opencodeWorktree: (dir) => worktrees.get(dir) } : {}),
|
|
130
130
|
});
|
|
131
131
|
};
|
|
132
|
-
return {
|
|
132
|
+
return {
|
|
133
|
+
saved: CONFIG.importPathMaps,
|
|
134
|
+
pi: await host("pi"),
|
|
135
|
+
opencode: await host("opencode"),
|
|
136
|
+
"claude-code": await host("claude-code"),
|
|
137
|
+
};
|
|
133
138
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
|
+
import { hostLabel } from "../types/host-label.js";
|
|
2
3
|
const dryRunCapture = {
|
|
3
4
|
summarize: async () => {
|
|
4
5
|
throw new Error("A dry-run cannot call the capture model");
|
|
@@ -7,7 +8,7 @@ const dryRunCapture = {
|
|
|
7
8
|
/** Another process or surface holds this host's import lock. */
|
|
8
9
|
export class ImportAlreadyRunningError extends Error {
|
|
9
10
|
constructor(host) {
|
|
10
|
-
super(`${host === "
|
|
11
|
+
super(`${host === "opencode" ? "An" : "A"} ${hostLabel(host)} import is already running`);
|
|
11
12
|
this.name = "ImportAlreadyRunningError";
|
|
12
13
|
}
|
|
13
14
|
}
|
|
@@ -120,10 +121,7 @@ async function runUntracked(host, args, run) {
|
|
|
120
121
|
...(run.models.profile ? { profileModel: run.models.profile } : {}),
|
|
121
122
|
});
|
|
122
123
|
}
|
|
123
|
-
const
|
|
124
|
-
const { loadPiSessionForImport } = await import("./session-loader.js");
|
|
125
|
-
return importPiHistory({
|
|
126
|
-
loadSession: loadPiSessionForImport,
|
|
124
|
+
const deps = {
|
|
127
125
|
provider: capture,
|
|
128
126
|
...(run.onProgress ? { onProgress: run.onProgress } : {}),
|
|
129
127
|
...(run.signal ? { signal: run.signal } : {}),
|
|
@@ -135,7 +133,8 @@ async function runUntracked(host, args, run) {
|
|
|
135
133
|
},
|
|
136
134
|
}
|
|
137
135
|
: {}),
|
|
138
|
-
}
|
|
136
|
+
};
|
|
137
|
+
const filters = {
|
|
139
138
|
scope: args.scope,
|
|
140
139
|
currentDirectory: project ?? run.cwd,
|
|
141
140
|
...(args.session ? { session: args.session } : {}),
|
|
@@ -148,13 +147,18 @@ async function runUntracked(host, args, run) {
|
|
|
148
147
|
dryRun: args.dryRun,
|
|
149
148
|
skipMemories: args.skipMemories,
|
|
150
149
|
pathMaps,
|
|
151
|
-
}
|
|
150
|
+
};
|
|
151
|
+
if (host === "claude-code") {
|
|
152
|
+
const { importClaudeHistory } = await import("./claude-import.js");
|
|
153
|
+
return importClaudeHistory(deps, filters);
|
|
154
|
+
}
|
|
155
|
+
const { importPiHistory } = await import("./importer.js");
|
|
156
|
+
const { loadPiSessionForImport } = await import("./session-loader.js");
|
|
157
|
+
return importPiHistory({ ...deps, loadSession: loadPiSessionForImport }, filters);
|
|
152
158
|
}
|
|
153
159
|
/** The same plain-text report for every host and surface. */
|
|
154
160
|
export function formatHistoryImportReport(host, report, model) {
|
|
155
|
-
const lines = [
|
|
156
|
-
`${host === "pi" ? "Pi" : "OpenCode"} history import${report.dryRun ? " (dry-run)" : ""}`,
|
|
157
|
-
];
|
|
161
|
+
const lines = [`${hostLabel(host)} history import${report.dryRun ? " (dry-run)" : ""}`];
|
|
158
162
|
if (model)
|
|
159
163
|
lines.push(` model: ${model}`);
|
|
160
164
|
lines.push(` sessions: ${report.sessionsLoaded}/${report.sessionsDiscovered} loaded, ${report.sessionsFilteredOut} filtered out` +
|