@zzusp/ccsm 1.0.1 → 1.0.2
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/LICENSE +21 -21
- package/README.md +236 -236
- package/bin/cli.mjs +52 -52
- package/dist/assets/{DiskUsage-CKhggLs5.js → DiskUsage-BY6XwffG.js} +2 -2
- package/dist/assets/DiskUsage-BY6XwffG.js.map +1 -0
- package/dist/assets/{ImportPage-wge4VhZ-.js → ImportPage-Cwq5bx7G.js} +2 -2
- package/dist/assets/ImportPage-Cwq5bx7G.js.map +1 -0
- package/dist/assets/MarkdownContent-BFu7Nkk_.js +2 -0
- package/dist/assets/MarkdownContent-BFu7Nkk_.js.map +1 -0
- package/dist/assets/{ProjectMemory-Q4XX40j_.js → ProjectMemory-CcE3KbUK.js} +2 -2
- package/dist/assets/ProjectMemory-CcE3KbUK.js.map +1 -0
- package/dist/assets/index-CrWxV6sb.css +1 -0
- package/dist/assets/index-DTbWl1jb.js +11 -0
- package/dist/assets/index-DTbWl1jb.js.map +1 -0
- package/dist/assets/markdown-Bag5rX3T.js +30 -0
- package/dist/assets/markdown-Bag5rX3T.js.map +1 -0
- package/dist/index.html +26 -26
- package/package.json +85 -83
- package/server/index.ts +130 -130
- package/server/lib/active-sessions.test.ts +119 -119
- package/server/lib/active-sessions.ts +95 -95
- package/server/lib/bundle.test.ts +182 -182
- package/server/lib/bundle.ts +86 -86
- package/server/lib/claude-paths.test.ts +126 -126
- package/server/lib/claude-paths.ts +43 -43
- package/server/lib/cleanup-suggestions.ts +131 -131
- package/server/lib/constants.ts +8 -8
- package/server/lib/delete-project.ts +100 -100
- package/server/lib/delete.test.ts +244 -244
- package/server/lib/delete.ts +192 -192
- package/server/lib/disk-usage.ts +81 -81
- package/server/lib/encode-cwd.ts +24 -24
- package/server/lib/export-bundle.ts +236 -236
- package/server/lib/export-import-bundle.test.ts +337 -337
- package/server/lib/fs-size.ts +38 -38
- package/server/lib/import-bundle.ts +488 -488
- package/server/lib/load-memory.ts +120 -120
- package/server/lib/load-session.ts +209 -209
- package/server/lib/modified-files.test.ts +280 -280
- package/server/lib/modified-files.ts +228 -228
- package/server/lib/open-folder.ts +47 -47
- package/server/lib/parse-jsonl.ts +160 -139
- package/server/lib/port.ts +23 -23
- package/server/lib/safe-id.test.ts +41 -41
- package/server/lib/safe-id.ts +6 -6
- package/server/lib/safe-remove.test.ts +73 -73
- package/server/lib/safe-remove.ts +25 -25
- package/server/lib/scan.ts +289 -286
- package/server/lib/search-all.ts +130 -130
- package/server/lib/search-session.ts +203 -203
- package/server/lib/system-tags.ts +20 -20
- package/server/lib/update.ts +67 -67
- package/server/lib/version.test.ts +39 -39
- package/server/lib/version.ts +117 -117
- package/server/routes/disk-cleanup.ts +54 -54
- package/server/routes/disk.ts +9 -9
- package/server/routes/import.ts +87 -87
- package/server/routes/projects.ts +104 -104
- package/server/routes/search.ts +79 -79
- package/server/routes/sessions.ts +130 -130
- package/server/routes/version.ts +34 -34
- package/server/types.ts +1 -1
- package/shared/constants.ts +7 -7
- package/shared/types.ts +513 -511
- package/dist/assets/DiskUsage-CKhggLs5.js.map +0 -1
- package/dist/assets/ImportPage-wge4VhZ-.js.map +0 -1
- package/dist/assets/ProjectMemory-Q4XX40j_.js.map +0 -1
- package/dist/assets/index-7aMrnHJG.js +0 -7
- package/dist/assets/index-7aMrnHJG.js.map +0 -1
- package/dist/assets/index-BOeI_J4B.css +0 -1
|
@@ -1,488 +1,488 @@
|
|
|
1
|
-
import crypto from 'node:crypto';
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import os from 'node:os';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
import readline from 'node:readline';
|
|
6
|
-
import { buildActiveSessionMap } from './active-sessions.ts';
|
|
7
|
-
import { isUnderClaudeRoot, PATHS } from './claude-paths.ts';
|
|
8
|
-
import { RECENT_ACTIVITY_WINDOW_MS } from './constants.ts';
|
|
9
|
-
import { encodeCwd } from './encode-cwd.ts';
|
|
10
|
-
import { parseJsonlMeta } from './parse-jsonl.ts';
|
|
11
|
-
import { isSafeId } from './safe-id.ts';
|
|
12
|
-
import { listProjects } from './scan.ts';
|
|
13
|
-
import { rewriteLineField, sha256, sha256File, SENTINEL } from './bundle.ts';
|
|
14
|
-
import {
|
|
15
|
-
BUNDLE_KIND,
|
|
16
|
-
BUNDLE_SCHEMA_VERSION,
|
|
17
|
-
type BundleManifest,
|
|
18
|
-
type BundleSource,
|
|
19
|
-
type ImportCollisionPolicy,
|
|
20
|
-
type ImportCommitRequest,
|
|
21
|
-
type ImportMemoryAction,
|
|
22
|
-
type ImportMemoryPlan,
|
|
23
|
-
type ImportPreviewRequest,
|
|
24
|
-
type ImportPreviewResult,
|
|
25
|
-
type ImportRemapPlan,
|
|
26
|
-
type ImportResult,
|
|
27
|
-
type ImportSessionAction,
|
|
28
|
-
type ImportSessionPlan,
|
|
29
|
-
type ImportTargetSuggestion,
|
|
30
|
-
type ImportedSession,
|
|
31
|
-
type SkippedItem,
|
|
32
|
-
} from '../types.ts';
|
|
33
|
-
|
|
34
|
-
const HISTORY_TMP_SUFFIX = '.tmp-import';
|
|
35
|
-
|
|
36
|
-
export class ImportError extends Error {}
|
|
37
|
-
|
|
38
|
-
interface Plan {
|
|
39
|
-
remap: ImportRemapPlan;
|
|
40
|
-
sessions: ImportSessionPlan[];
|
|
41
|
-
memory: ImportMemoryPlan[];
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export async function previewImport(req: ImportPreviewRequest): Promise<ImportPreviewResult> {
|
|
45
|
-
const bundleDir = path.resolve(req.bundleDir);
|
|
46
|
-
const manifest = readManifest(bundleDir);
|
|
47
|
-
const suggestions = await computeSuggestions(manifest.source);
|
|
48
|
-
const targetCwd = req.targetCwd ?? suggestions[0]?.cwd ?? manifest.source.cwd;
|
|
49
|
-
|
|
50
|
-
const plan = await buildPlan(bundleDir, manifest, targetCwd, req.collisionPolicy);
|
|
51
|
-
const additions = await gatherHistoryAdditions(bundleDir, plan.sessions, plan.remap.targetCwd);
|
|
52
|
-
|
|
53
|
-
return {
|
|
54
|
-
source: manifest.source,
|
|
55
|
-
remap: plan.remap,
|
|
56
|
-
suggestions,
|
|
57
|
-
sessions: plan.sessions,
|
|
58
|
-
memory: plan.memory,
|
|
59
|
-
historyLinesToAdd: additions.length,
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export async function commitImport(req: ImportCommitRequest): Promise<ImportResult> {
|
|
64
|
-
const bundleDir = path.resolve(req.bundleDir);
|
|
65
|
-
const manifest = readManifest(bundleDir);
|
|
66
|
-
if (!path.isAbsolute(req.targetCwd)) throw new ImportError('target path must be absolute');
|
|
67
|
-
|
|
68
|
-
const plan = await buildPlan(bundleDir, manifest, req.targetCwd, req.collisionPolicy);
|
|
69
|
-
const { targetProjectId, targetCwd } = plan.remap;
|
|
70
|
-
const projectDir = path.join(PATHS.projects, targetProjectId);
|
|
71
|
-
|
|
72
|
-
const imported: ImportedSession[] = [];
|
|
73
|
-
const skipped: SkippedItem[] = [];
|
|
74
|
-
let madeProjectDir = false;
|
|
75
|
-
|
|
76
|
-
for (const s of plan.sessions) {
|
|
77
|
-
if (s.action === 'skip') {
|
|
78
|
-
skipped.push({ projectId: targetProjectId, sessionId: s.sessionId, reason: s.reason ?? 'skipped' });
|
|
79
|
-
continue;
|
|
80
|
-
}
|
|
81
|
-
const convSrc = path.join(bundleDir, 'sessions', s.sessionId, 'conversation.jsonl');
|
|
82
|
-
if (!isSafeId(s.sessionId) || !fs.existsSync(convSrc)) {
|
|
83
|
-
skipped.push({ projectId: targetProjectId, sessionId: s.sessionId, reason: 'bundle conversation file missing' });
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
const destSid = s.newSessionId ?? s.sessionId;
|
|
87
|
-
const destJsonl = path.join(projectDir, `${destSid}.jsonl`);
|
|
88
|
-
if (!isUnderClaudeRoot(destJsonl)) {
|
|
89
|
-
skipped.push({ projectId: targetProjectId, sessionId: s.sessionId, reason: 'path escapes ~/.claude' });
|
|
90
|
-
continue;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (!madeProjectDir) {
|
|
94
|
-
fs.mkdirSync(projectDir, { recursive: true });
|
|
95
|
-
madeProjectDir = true;
|
|
96
|
-
}
|
|
97
|
-
const tmp = destJsonl + HISTORY_TMP_SUFFIX;
|
|
98
|
-
await writeConversation(convSrc, tmp, targetCwd, s.sessionId, s.newSessionId);
|
|
99
|
-
fs.renameSync(tmp, destJsonl); // atomic; overwrites in place for 'overwrite'
|
|
100
|
-
imported.push({ sessionId: s.sessionId, action: s.action, newSessionId: s.newSessionId });
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const memoryWritten = writeMemory(bundleDir, projectDir, plan.memory);
|
|
104
|
-
|
|
105
|
-
const additions = await gatherHistoryAdditions(bundleDir, plan.sessions, targetCwd);
|
|
106
|
-
const historyLinesAdded = await appendHistoryLines(additions);
|
|
107
|
-
|
|
108
|
-
return { targetProjectId, targetCwd, imported, skipped, historyLinesAdded, memoryWritten };
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// ── manifest + suggestions ──────────────────────────────────────────────────
|
|
112
|
-
|
|
113
|
-
function readManifest(bundleDir: string): BundleManifest {
|
|
114
|
-
const manifestPath = path.join(bundleDir, 'manifest.json');
|
|
115
|
-
let raw: string;
|
|
116
|
-
try {
|
|
117
|
-
raw = fs.readFileSync(manifestPath, 'utf8');
|
|
118
|
-
} catch {
|
|
119
|
-
throw new ImportError('no manifest.json found in the bundle directory');
|
|
120
|
-
}
|
|
121
|
-
let obj: BundleManifest;
|
|
122
|
-
try {
|
|
123
|
-
obj = JSON.parse(raw) as BundleManifest;
|
|
124
|
-
} catch {
|
|
125
|
-
throw new ImportError('manifest.json is not valid JSON');
|
|
126
|
-
}
|
|
127
|
-
if (obj.kind !== BUNDLE_KIND) throw new ImportError('not a Claude session bundle');
|
|
128
|
-
if (typeof obj.schemaVersion !== 'number' || obj.schemaVersion > BUNDLE_SCHEMA_VERSION) {
|
|
129
|
-
throw new ImportError(`unsupported bundle schemaVersion ${String(obj.schemaVersion)}`);
|
|
130
|
-
}
|
|
131
|
-
if (!obj.source || typeof obj.source.cwd !== 'string') {
|
|
132
|
-
throw new ImportError('bundle manifest is missing its source');
|
|
133
|
-
}
|
|
134
|
-
if (!Array.isArray(obj.sessions)) throw new ImportError('bundle manifest is missing sessions');
|
|
135
|
-
if (!obj.memory || !Array.isArray(obj.memory.entries)) {
|
|
136
|
-
obj.memory = { hasIndex: false, entries: [] };
|
|
137
|
-
}
|
|
138
|
-
return obj;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
async function computeSuggestions(source: BundleSource): Promise<ImportTargetSuggestion[]> {
|
|
142
|
-
const out: ImportTargetSuggestion[] = [];
|
|
143
|
-
const seen = new Set<string>();
|
|
144
|
-
const add = (cwd: string, reason: ImportTargetSuggestion['reason']) => {
|
|
145
|
-
const projectId = encodeCwd(cwd);
|
|
146
|
-
if (seen.has(projectId)) return;
|
|
147
|
-
seen.add(projectId);
|
|
148
|
-
out.push({ cwd, projectId, reason, resolved: statDir(cwd) });
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
const projects = await listProjects();
|
|
152
|
-
const exact = projects.find((p) => p.id === source.projectId);
|
|
153
|
-
if (exact) add(exact.decodedCwd, 'existing-project');
|
|
154
|
-
if (statDir(source.cwd)) add(source.cwd, 'original-path');
|
|
155
|
-
|
|
156
|
-
const base = baseName(source.cwd);
|
|
157
|
-
if (base) {
|
|
158
|
-
for (const p of projects) {
|
|
159
|
-
if (p.cwdResolved && baseName(p.decodedCwd) === base) add(p.decodedCwd, 'same-basename');
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
return out;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// ── plan ────────────────────────────────────────────────────────────────────
|
|
166
|
-
|
|
167
|
-
async function buildPlan(
|
|
168
|
-
bundleDir: string,
|
|
169
|
-
manifest: BundleManifest,
|
|
170
|
-
targetCwd: string,
|
|
171
|
-
policy: ImportCollisionPolicy,
|
|
172
|
-
): Promise<Plan> {
|
|
173
|
-
const targetProjectId = encodeCwd(targetCwd);
|
|
174
|
-
if (!isSafeId(targetProjectId)) throw new ImportError('target path produces an unsafe project id');
|
|
175
|
-
const projectDir = path.join(PATHS.projects, targetProjectId);
|
|
176
|
-
if (!isUnderClaudeRoot(projectDir)) throw new ImportError('target escapes ~/.claude');
|
|
177
|
-
|
|
178
|
-
const remap: ImportRemapPlan = {
|
|
179
|
-
sourceCwd: manifest.source.cwd,
|
|
180
|
-
targetCwd,
|
|
181
|
-
targetProjectId,
|
|
182
|
-
targetProjectExists: fs.existsSync(projectDir),
|
|
183
|
-
};
|
|
184
|
-
|
|
185
|
-
const liveMap = buildActiveSessionMap();
|
|
186
|
-
const sessions: ImportSessionPlan[] = [];
|
|
187
|
-
for (const s of manifest.sessions) {
|
|
188
|
-
const sid = s.sessionId;
|
|
189
|
-
if (!isSafeId(sid)) {
|
|
190
|
-
sessions.push({
|
|
191
|
-
sessionId: sid,
|
|
192
|
-
title: s.title,
|
|
193
|
-
action: 'skip',
|
|
194
|
-
reason: 'invalid session id',
|
|
195
|
-
isLivePid: false,
|
|
196
|
-
isRecentlyActive: false,
|
|
197
|
-
localLastAt: null,
|
|
198
|
-
bundleLastAt: s.lastAt,
|
|
199
|
-
});
|
|
200
|
-
continue;
|
|
201
|
-
}
|
|
202
|
-
const destJsonl = path.join(projectDir, `${sid}.jsonl`);
|
|
203
|
-
const exists = fs.existsSync(destJsonl);
|
|
204
|
-
const isLive = liveMap.has(sid);
|
|
205
|
-
const isRecent = exists && recentlyActive(destJsonl);
|
|
206
|
-
const localLastAt = exists ? (await parseJsonlMeta(destJsonl)).lastAt : null;
|
|
207
|
-
|
|
208
|
-
const decided = decideSessionAction(exists, isLive, isRecent, s.lastAt, localLastAt, policy);
|
|
209
|
-
sessions.push({
|
|
210
|
-
sessionId: sid,
|
|
211
|
-
title: s.title,
|
|
212
|
-
action: decided.action,
|
|
213
|
-
reason: decided.reason,
|
|
214
|
-
newSessionId: decided.mintNewSid ? crypto.randomUUID() : undefined,
|
|
215
|
-
isLivePid: isLive,
|
|
216
|
-
isRecentlyActive: isRecent,
|
|
217
|
-
localLastAt,
|
|
218
|
-
bundleLastAt: s.lastAt,
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const memDir = path.join(projectDir, 'memory');
|
|
223
|
-
const memory: ImportMemoryPlan[] = [];
|
|
224
|
-
for (const e of manifest.memory.entries) {
|
|
225
|
-
if (!isSafeId(e.filename)) continue;
|
|
226
|
-
const localPath = path.join(memDir, e.filename);
|
|
227
|
-
let action: ImportMemoryAction;
|
|
228
|
-
let writtenAs: string | undefined;
|
|
229
|
-
if (!fs.existsSync(localPath)) {
|
|
230
|
-
action = 'create';
|
|
231
|
-
} else if (sha256File(localPath) === e.sha256) {
|
|
232
|
-
action = 'skip';
|
|
233
|
-
} else {
|
|
234
|
-
action = 'conflict';
|
|
235
|
-
writtenAs = conflictName(e.filename, e.isIndex, e.sha256);
|
|
236
|
-
}
|
|
237
|
-
memory.push({ filename: e.filename, isIndex: e.isIndex, action, writtenAs });
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
return { remap, sessions, memory };
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
function decideSessionAction(
|
|
244
|
-
exists: boolean,
|
|
245
|
-
isLive: boolean,
|
|
246
|
-
isRecent: boolean,
|
|
247
|
-
bundleLastAt: string | null,
|
|
248
|
-
localLastAt: string | null,
|
|
249
|
-
policy: ImportCollisionPolicy,
|
|
250
|
-
): { action: ImportSessionAction; reason?: string; mintNewSid: boolean } {
|
|
251
|
-
// A live session anywhere owns this id; only keep-both (fresh id) is safe.
|
|
252
|
-
if (isLive) {
|
|
253
|
-
if (policy === 'keep-both') return { action: 'keep-both', mintNewSid: true };
|
|
254
|
-
return {
|
|
255
|
-
action: 'skip',
|
|
256
|
-
reason: 'a live session owns this id — use keep-both to import a copy',
|
|
257
|
-
mintNewSid: false,
|
|
258
|
-
};
|
|
259
|
-
}
|
|
260
|
-
if (!exists) return { action: 'create', mintNewSid: false };
|
|
261
|
-
|
|
262
|
-
switch (policy) {
|
|
263
|
-
case 'skip':
|
|
264
|
-
return { action: 'skip', reason: 'already present', mintNewSid: false };
|
|
265
|
-
case 'keep-both':
|
|
266
|
-
return { action: 'keep-both', mintNewSid: true };
|
|
267
|
-
case 'overwrite-if-newer':
|
|
268
|
-
if (isRecent) {
|
|
269
|
-
return { action: 'skip', reason: 'modified within the last 5 minutes', mintNewSid: false };
|
|
270
|
-
}
|
|
271
|
-
if (bundleLastAt && localLastAt && bundleLastAt > localLastAt) {
|
|
272
|
-
return { action: 'overwrite', mintNewSid: false };
|
|
273
|
-
}
|
|
274
|
-
return { action: 'skip', reason: 'local copy is newer or equal', mintNewSid: false };
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// ── writers ──────────────────────────────────────────────────────────────────
|
|
279
|
-
|
|
280
|
-
async function writeConversation(
|
|
281
|
-
src: string,
|
|
282
|
-
dest: string,
|
|
283
|
-
targetCwd: string,
|
|
284
|
-
oldSid: string,
|
|
285
|
-
newSid: string | undefined,
|
|
286
|
-
): Promise<void> {
|
|
287
|
-
const out = fs.createWriteStream(dest, { encoding: 'utf8' });
|
|
288
|
-
const rl = readline.createInterface({
|
|
289
|
-
input: fs.createReadStream(src, { encoding: 'utf8' }),
|
|
290
|
-
crlfDelay: Infinity,
|
|
291
|
-
});
|
|
292
|
-
try {
|
|
293
|
-
for await (const raw of rl) {
|
|
294
|
-
if (!raw) continue;
|
|
295
|
-
let line = rewriteLineField(raw, 'cwd', SENTINEL, targetCwd);
|
|
296
|
-
if (newSid) line = rewriteLineField(line, 'sessionId', oldSid, newSid);
|
|
297
|
-
out.write(line + '\n');
|
|
298
|
-
}
|
|
299
|
-
await new Promise<void>((resolve, reject) => {
|
|
300
|
-
out.end((err?: Error | null) => (err ? reject(err) : resolve()));
|
|
301
|
-
});
|
|
302
|
-
} catch (err) {
|
|
303
|
-
out.destroy();
|
|
304
|
-
throw err;
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
function writeMemory(bundleDir: string, projectDir: string, plans: ImportMemoryPlan[]): string[] {
|
|
309
|
-
const written: string[] = [];
|
|
310
|
-
const memDir = path.join(projectDir, 'memory');
|
|
311
|
-
let made = false;
|
|
312
|
-
for (const p of plans) {
|
|
313
|
-
if (p.action === 'skip') continue;
|
|
314
|
-
if (!isSafeId(p.filename)) continue;
|
|
315
|
-
const src = path.join(bundleDir, 'memory', p.filename);
|
|
316
|
-
if (!fs.existsSync(src)) continue;
|
|
317
|
-
const destName = p.action === 'conflict' ? (p.writtenAs ?? p.filename) : p.filename;
|
|
318
|
-
if (!isSafeId(destName)) continue;
|
|
319
|
-
const dest = path.join(memDir, destName);
|
|
320
|
-
if (!isUnderClaudeRoot(dest)) continue;
|
|
321
|
-
|
|
322
|
-
if (!made) {
|
|
323
|
-
fs.mkdirSync(memDir, { recursive: true });
|
|
324
|
-
made = true;
|
|
325
|
-
}
|
|
326
|
-
const tmp = dest + HISTORY_TMP_SUFFIX;
|
|
327
|
-
fs.copyFileSync(src, tmp);
|
|
328
|
-
fs.renameSync(tmp, dest);
|
|
329
|
-
written.push(destName);
|
|
330
|
-
}
|
|
331
|
-
return written;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// ── history merge (append + dedup, atomic backup -> tmp -> rename) ────────────
|
|
335
|
-
|
|
336
|
-
async function gatherHistoryAdditions(
|
|
337
|
-
bundleDir: string,
|
|
338
|
-
sessions: ImportSessionPlan[],
|
|
339
|
-
targetCwd: string,
|
|
340
|
-
): Promise<string[]> {
|
|
341
|
-
const importing = sessions.filter((s) => s.action !== 'skip');
|
|
342
|
-
if (importing.length === 0) return [];
|
|
343
|
-
|
|
344
|
-
const seen = await loadHistoryKeys();
|
|
345
|
-
const out: string[] = [];
|
|
346
|
-
for (const s of importing) {
|
|
347
|
-
const histPath = path.join(bundleDir, 'sessions', s.sessionId, 'history.ndjson');
|
|
348
|
-
if (!fs.existsSync(histPath)) continue;
|
|
349
|
-
const rl = readline.createInterface({
|
|
350
|
-
input: fs.createReadStream(histPath, { encoding: 'utf8' }),
|
|
351
|
-
crlfDelay: Infinity,
|
|
352
|
-
});
|
|
353
|
-
for await (const raw of rl) {
|
|
354
|
-
if (!raw.trim()) continue;
|
|
355
|
-
let line = rewriteLineField(raw, 'project', SENTINEL, targetCwd);
|
|
356
|
-
if (s.newSessionId) line = rewriteLineField(line, 'sessionId', s.sessionId, s.newSessionId);
|
|
357
|
-
let obj: Record<string, unknown>;
|
|
358
|
-
try {
|
|
359
|
-
obj = JSON.parse(line) as Record<string, unknown>;
|
|
360
|
-
} catch {
|
|
361
|
-
continue;
|
|
362
|
-
}
|
|
363
|
-
const key = historyKey(obj);
|
|
364
|
-
if (seen.has(key)) continue;
|
|
365
|
-
seen.add(key);
|
|
366
|
-
out.push(line);
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
return out;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
async function loadHistoryKeys(): Promise<Set<string>> {
|
|
373
|
-
const set = new Set<string>();
|
|
374
|
-
if (!fs.existsSync(PATHS.history)) return set;
|
|
375
|
-
const rl = readline.createInterface({
|
|
376
|
-
input: fs.createReadStream(PATHS.history, { encoding: 'utf8' }),
|
|
377
|
-
crlfDelay: Infinity,
|
|
378
|
-
});
|
|
379
|
-
for await (const raw of rl) {
|
|
380
|
-
if (!raw.trim()) continue;
|
|
381
|
-
try {
|
|
382
|
-
set.add(historyKey(JSON.parse(raw) as Record<string, unknown>));
|
|
383
|
-
} catch {
|
|
384
|
-
/* skip malformed */
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
return set;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
function historyKey(obj: Record<string, unknown>): string {
|
|
391
|
-
const sid = typeof obj.sessionId === 'string' ? obj.sessionId : '';
|
|
392
|
-
const ts = typeof obj.timestamp === 'string' ? obj.timestamp : '';
|
|
393
|
-
const project = typeof obj.project === 'string' ? obj.project : '';
|
|
394
|
-
const display = typeof obj.display === 'string' ? obj.display : '';
|
|
395
|
-
// `project` is part of the identity: the same session+prompt remapped to a
|
|
396
|
-
// different target cwd is a distinct history entry, while a re-import to the
|
|
397
|
-
// same target collides and is correctly deduped (idempotent).
|
|
398
|
-
return [sid, ts, project, sha256(display)].join(' ');
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
async function appendHistoryLines(lines: string[]): Promise<number> {
|
|
402
|
-
if (lines.length === 0) return 0;
|
|
403
|
-
|
|
404
|
-
const tmpPath = PATHS.history + HISTORY_TMP_SUFFIX;
|
|
405
|
-
if (fs.existsSync(tmpPath)) fs.rmSync(tmpPath, { force: true });
|
|
406
|
-
|
|
407
|
-
try {
|
|
408
|
-
const out = fs.createWriteStream(tmpPath, { encoding: 'utf8' });
|
|
409
|
-
if (fs.existsSync(PATHS.history)) {
|
|
410
|
-
const rl = readline.createInterface({
|
|
411
|
-
input: fs.createReadStream(PATHS.history, { encoding: 'utf8' }),
|
|
412
|
-
crlfDelay: Infinity,
|
|
413
|
-
});
|
|
414
|
-
for await (const raw of rl) {
|
|
415
|
-
if (!raw) {
|
|
416
|
-
out.write(os.EOL);
|
|
417
|
-
continue;
|
|
418
|
-
}
|
|
419
|
-
out.write(raw);
|
|
420
|
-
out.write(os.EOL);
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
for (const line of lines) {
|
|
424
|
-
out.write(line);
|
|
425
|
-
out.write(os.EOL);
|
|
426
|
-
}
|
|
427
|
-
await new Promise<void>((resolve, reject) => {
|
|
428
|
-
out.end((err?: Error | null) => (err ? reject(err) : resolve()));
|
|
429
|
-
});
|
|
430
|
-
} catch (err) {
|
|
431
|
-
fs.rmSync(tmpPath, { force: true });
|
|
432
|
-
throw err;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
if (!fs.existsSync(PATHS.history)) {
|
|
436
|
-
fs.renameSync(tmpPath, PATHS.history);
|
|
437
|
-
return lines.length;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
// Windows-safe atomic-ish replace: backup original, swap tmp in, drop backup.
|
|
441
|
-
const backup = PATHS.history + '.bak-' + Date.now();
|
|
442
|
-
fs.renameSync(PATHS.history, backup);
|
|
443
|
-
try {
|
|
444
|
-
fs.renameSync(tmpPath, PATHS.history);
|
|
445
|
-
fs.rmSync(backup, { force: true });
|
|
446
|
-
} catch (err) {
|
|
447
|
-
if (fs.existsSync(backup)) {
|
|
448
|
-
try {
|
|
449
|
-
fs.renameSync(backup, PATHS.history);
|
|
450
|
-
} catch {
|
|
451
|
-
/* keep backup for manual recovery */
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
fs.rmSync(tmpPath, { force: true });
|
|
455
|
-
throw err;
|
|
456
|
-
}
|
|
457
|
-
return lines.length;
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
// ── small helpers ─────────────────────────────────────────────────────────────
|
|
461
|
-
|
|
462
|
-
function recentlyActive(jsonlPath: string): boolean {
|
|
463
|
-
try {
|
|
464
|
-
return Date.now() - fs.statSync(jsonlPath).mtimeMs < RECENT_ACTIVITY_WINDOW_MS;
|
|
465
|
-
} catch {
|
|
466
|
-
return false;
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
function statDir(p: string): boolean {
|
|
471
|
-
try {
|
|
472
|
-
return fs.statSync(p).isDirectory();
|
|
473
|
-
} catch {
|
|
474
|
-
return false;
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
function baseName(p: string): string {
|
|
479
|
-
const parts = p.split(/[\\/]+/).filter(Boolean);
|
|
480
|
-
return parts.at(-1) ?? '';
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
function conflictName(filename: string, isIndex: boolean, sha: string): string {
|
|
484
|
-
if (isIndex) return 'MEMORY.imported.md';
|
|
485
|
-
const ext = path.extname(filename);
|
|
486
|
-
const base = filename.slice(0, filename.length - ext.length);
|
|
487
|
-
return `${base}.imported-${sha.slice(0, 8)}${ext}`;
|
|
488
|
-
}
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import readline from 'node:readline';
|
|
6
|
+
import { buildActiveSessionMap } from './active-sessions.ts';
|
|
7
|
+
import { isUnderClaudeRoot, PATHS } from './claude-paths.ts';
|
|
8
|
+
import { RECENT_ACTIVITY_WINDOW_MS } from './constants.ts';
|
|
9
|
+
import { encodeCwd } from './encode-cwd.ts';
|
|
10
|
+
import { parseJsonlMeta } from './parse-jsonl.ts';
|
|
11
|
+
import { isSafeId } from './safe-id.ts';
|
|
12
|
+
import { listProjects } from './scan.ts';
|
|
13
|
+
import { rewriteLineField, sha256, sha256File, SENTINEL } from './bundle.ts';
|
|
14
|
+
import {
|
|
15
|
+
BUNDLE_KIND,
|
|
16
|
+
BUNDLE_SCHEMA_VERSION,
|
|
17
|
+
type BundleManifest,
|
|
18
|
+
type BundleSource,
|
|
19
|
+
type ImportCollisionPolicy,
|
|
20
|
+
type ImportCommitRequest,
|
|
21
|
+
type ImportMemoryAction,
|
|
22
|
+
type ImportMemoryPlan,
|
|
23
|
+
type ImportPreviewRequest,
|
|
24
|
+
type ImportPreviewResult,
|
|
25
|
+
type ImportRemapPlan,
|
|
26
|
+
type ImportResult,
|
|
27
|
+
type ImportSessionAction,
|
|
28
|
+
type ImportSessionPlan,
|
|
29
|
+
type ImportTargetSuggestion,
|
|
30
|
+
type ImportedSession,
|
|
31
|
+
type SkippedItem,
|
|
32
|
+
} from '../types.ts';
|
|
33
|
+
|
|
34
|
+
const HISTORY_TMP_SUFFIX = '.tmp-import';
|
|
35
|
+
|
|
36
|
+
export class ImportError extends Error {}
|
|
37
|
+
|
|
38
|
+
interface Plan {
|
|
39
|
+
remap: ImportRemapPlan;
|
|
40
|
+
sessions: ImportSessionPlan[];
|
|
41
|
+
memory: ImportMemoryPlan[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function previewImport(req: ImportPreviewRequest): Promise<ImportPreviewResult> {
|
|
45
|
+
const bundleDir = path.resolve(req.bundleDir);
|
|
46
|
+
const manifest = readManifest(bundleDir);
|
|
47
|
+
const suggestions = await computeSuggestions(manifest.source);
|
|
48
|
+
const targetCwd = req.targetCwd ?? suggestions[0]?.cwd ?? manifest.source.cwd;
|
|
49
|
+
|
|
50
|
+
const plan = await buildPlan(bundleDir, manifest, targetCwd, req.collisionPolicy);
|
|
51
|
+
const additions = await gatherHistoryAdditions(bundleDir, plan.sessions, plan.remap.targetCwd);
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
source: manifest.source,
|
|
55
|
+
remap: plan.remap,
|
|
56
|
+
suggestions,
|
|
57
|
+
sessions: plan.sessions,
|
|
58
|
+
memory: plan.memory,
|
|
59
|
+
historyLinesToAdd: additions.length,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function commitImport(req: ImportCommitRequest): Promise<ImportResult> {
|
|
64
|
+
const bundleDir = path.resolve(req.bundleDir);
|
|
65
|
+
const manifest = readManifest(bundleDir);
|
|
66
|
+
if (!path.isAbsolute(req.targetCwd)) throw new ImportError('target path must be absolute');
|
|
67
|
+
|
|
68
|
+
const plan = await buildPlan(bundleDir, manifest, req.targetCwd, req.collisionPolicy);
|
|
69
|
+
const { targetProjectId, targetCwd } = plan.remap;
|
|
70
|
+
const projectDir = path.join(PATHS.projects, targetProjectId);
|
|
71
|
+
|
|
72
|
+
const imported: ImportedSession[] = [];
|
|
73
|
+
const skipped: SkippedItem[] = [];
|
|
74
|
+
let madeProjectDir = false;
|
|
75
|
+
|
|
76
|
+
for (const s of plan.sessions) {
|
|
77
|
+
if (s.action === 'skip') {
|
|
78
|
+
skipped.push({ projectId: targetProjectId, sessionId: s.sessionId, reason: s.reason ?? 'skipped' });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const convSrc = path.join(bundleDir, 'sessions', s.sessionId, 'conversation.jsonl');
|
|
82
|
+
if (!isSafeId(s.sessionId) || !fs.existsSync(convSrc)) {
|
|
83
|
+
skipped.push({ projectId: targetProjectId, sessionId: s.sessionId, reason: 'bundle conversation file missing' });
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const destSid = s.newSessionId ?? s.sessionId;
|
|
87
|
+
const destJsonl = path.join(projectDir, `${destSid}.jsonl`);
|
|
88
|
+
if (!isUnderClaudeRoot(destJsonl)) {
|
|
89
|
+
skipped.push({ projectId: targetProjectId, sessionId: s.sessionId, reason: 'path escapes ~/.claude' });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!madeProjectDir) {
|
|
94
|
+
fs.mkdirSync(projectDir, { recursive: true });
|
|
95
|
+
madeProjectDir = true;
|
|
96
|
+
}
|
|
97
|
+
const tmp = destJsonl + HISTORY_TMP_SUFFIX;
|
|
98
|
+
await writeConversation(convSrc, tmp, targetCwd, s.sessionId, s.newSessionId);
|
|
99
|
+
fs.renameSync(tmp, destJsonl); // atomic; overwrites in place for 'overwrite'
|
|
100
|
+
imported.push({ sessionId: s.sessionId, action: s.action, newSessionId: s.newSessionId });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const memoryWritten = writeMemory(bundleDir, projectDir, plan.memory);
|
|
104
|
+
|
|
105
|
+
const additions = await gatherHistoryAdditions(bundleDir, plan.sessions, targetCwd);
|
|
106
|
+
const historyLinesAdded = await appendHistoryLines(additions);
|
|
107
|
+
|
|
108
|
+
return { targetProjectId, targetCwd, imported, skipped, historyLinesAdded, memoryWritten };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── manifest + suggestions ──────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
function readManifest(bundleDir: string): BundleManifest {
|
|
114
|
+
const manifestPath = path.join(bundleDir, 'manifest.json');
|
|
115
|
+
let raw: string;
|
|
116
|
+
try {
|
|
117
|
+
raw = fs.readFileSync(manifestPath, 'utf8');
|
|
118
|
+
} catch {
|
|
119
|
+
throw new ImportError('no manifest.json found in the bundle directory');
|
|
120
|
+
}
|
|
121
|
+
let obj: BundleManifest;
|
|
122
|
+
try {
|
|
123
|
+
obj = JSON.parse(raw) as BundleManifest;
|
|
124
|
+
} catch {
|
|
125
|
+
throw new ImportError('manifest.json is not valid JSON');
|
|
126
|
+
}
|
|
127
|
+
if (obj.kind !== BUNDLE_KIND) throw new ImportError('not a Claude session bundle');
|
|
128
|
+
if (typeof obj.schemaVersion !== 'number' || obj.schemaVersion > BUNDLE_SCHEMA_VERSION) {
|
|
129
|
+
throw new ImportError(`unsupported bundle schemaVersion ${String(obj.schemaVersion)}`);
|
|
130
|
+
}
|
|
131
|
+
if (!obj.source || typeof obj.source.cwd !== 'string') {
|
|
132
|
+
throw new ImportError('bundle manifest is missing its source');
|
|
133
|
+
}
|
|
134
|
+
if (!Array.isArray(obj.sessions)) throw new ImportError('bundle manifest is missing sessions');
|
|
135
|
+
if (!obj.memory || !Array.isArray(obj.memory.entries)) {
|
|
136
|
+
obj.memory = { hasIndex: false, entries: [] };
|
|
137
|
+
}
|
|
138
|
+
return obj;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function computeSuggestions(source: BundleSource): Promise<ImportTargetSuggestion[]> {
|
|
142
|
+
const out: ImportTargetSuggestion[] = [];
|
|
143
|
+
const seen = new Set<string>();
|
|
144
|
+
const add = (cwd: string, reason: ImportTargetSuggestion['reason']) => {
|
|
145
|
+
const projectId = encodeCwd(cwd);
|
|
146
|
+
if (seen.has(projectId)) return;
|
|
147
|
+
seen.add(projectId);
|
|
148
|
+
out.push({ cwd, projectId, reason, resolved: statDir(cwd) });
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const projects = await listProjects();
|
|
152
|
+
const exact = projects.find((p) => p.id === source.projectId);
|
|
153
|
+
if (exact) add(exact.decodedCwd, 'existing-project');
|
|
154
|
+
if (statDir(source.cwd)) add(source.cwd, 'original-path');
|
|
155
|
+
|
|
156
|
+
const base = baseName(source.cwd);
|
|
157
|
+
if (base) {
|
|
158
|
+
for (const p of projects) {
|
|
159
|
+
if (p.cwdResolved && baseName(p.decodedCwd) === base) add(p.decodedCwd, 'same-basename');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── plan ────────────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
async function buildPlan(
|
|
168
|
+
bundleDir: string,
|
|
169
|
+
manifest: BundleManifest,
|
|
170
|
+
targetCwd: string,
|
|
171
|
+
policy: ImportCollisionPolicy,
|
|
172
|
+
): Promise<Plan> {
|
|
173
|
+
const targetProjectId = encodeCwd(targetCwd);
|
|
174
|
+
if (!isSafeId(targetProjectId)) throw new ImportError('target path produces an unsafe project id');
|
|
175
|
+
const projectDir = path.join(PATHS.projects, targetProjectId);
|
|
176
|
+
if (!isUnderClaudeRoot(projectDir)) throw new ImportError('target escapes ~/.claude');
|
|
177
|
+
|
|
178
|
+
const remap: ImportRemapPlan = {
|
|
179
|
+
sourceCwd: manifest.source.cwd,
|
|
180
|
+
targetCwd,
|
|
181
|
+
targetProjectId,
|
|
182
|
+
targetProjectExists: fs.existsSync(projectDir),
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const liveMap = buildActiveSessionMap();
|
|
186
|
+
const sessions: ImportSessionPlan[] = [];
|
|
187
|
+
for (const s of manifest.sessions) {
|
|
188
|
+
const sid = s.sessionId;
|
|
189
|
+
if (!isSafeId(sid)) {
|
|
190
|
+
sessions.push({
|
|
191
|
+
sessionId: sid,
|
|
192
|
+
title: s.title,
|
|
193
|
+
action: 'skip',
|
|
194
|
+
reason: 'invalid session id',
|
|
195
|
+
isLivePid: false,
|
|
196
|
+
isRecentlyActive: false,
|
|
197
|
+
localLastAt: null,
|
|
198
|
+
bundleLastAt: s.lastAt,
|
|
199
|
+
});
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const destJsonl = path.join(projectDir, `${sid}.jsonl`);
|
|
203
|
+
const exists = fs.existsSync(destJsonl);
|
|
204
|
+
const isLive = liveMap.has(sid);
|
|
205
|
+
const isRecent = exists && recentlyActive(destJsonl);
|
|
206
|
+
const localLastAt = exists ? (await parseJsonlMeta(destJsonl)).lastAt : null;
|
|
207
|
+
|
|
208
|
+
const decided = decideSessionAction(exists, isLive, isRecent, s.lastAt, localLastAt, policy);
|
|
209
|
+
sessions.push({
|
|
210
|
+
sessionId: sid,
|
|
211
|
+
title: s.title,
|
|
212
|
+
action: decided.action,
|
|
213
|
+
reason: decided.reason,
|
|
214
|
+
newSessionId: decided.mintNewSid ? crypto.randomUUID() : undefined,
|
|
215
|
+
isLivePid: isLive,
|
|
216
|
+
isRecentlyActive: isRecent,
|
|
217
|
+
localLastAt,
|
|
218
|
+
bundleLastAt: s.lastAt,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const memDir = path.join(projectDir, 'memory');
|
|
223
|
+
const memory: ImportMemoryPlan[] = [];
|
|
224
|
+
for (const e of manifest.memory.entries) {
|
|
225
|
+
if (!isSafeId(e.filename)) continue;
|
|
226
|
+
const localPath = path.join(memDir, e.filename);
|
|
227
|
+
let action: ImportMemoryAction;
|
|
228
|
+
let writtenAs: string | undefined;
|
|
229
|
+
if (!fs.existsSync(localPath)) {
|
|
230
|
+
action = 'create';
|
|
231
|
+
} else if (sha256File(localPath) === e.sha256) {
|
|
232
|
+
action = 'skip';
|
|
233
|
+
} else {
|
|
234
|
+
action = 'conflict';
|
|
235
|
+
writtenAs = conflictName(e.filename, e.isIndex, e.sha256);
|
|
236
|
+
}
|
|
237
|
+
memory.push({ filename: e.filename, isIndex: e.isIndex, action, writtenAs });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return { remap, sessions, memory };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function decideSessionAction(
|
|
244
|
+
exists: boolean,
|
|
245
|
+
isLive: boolean,
|
|
246
|
+
isRecent: boolean,
|
|
247
|
+
bundleLastAt: string | null,
|
|
248
|
+
localLastAt: string | null,
|
|
249
|
+
policy: ImportCollisionPolicy,
|
|
250
|
+
): { action: ImportSessionAction; reason?: string; mintNewSid: boolean } {
|
|
251
|
+
// A live session anywhere owns this id; only keep-both (fresh id) is safe.
|
|
252
|
+
if (isLive) {
|
|
253
|
+
if (policy === 'keep-both') return { action: 'keep-both', mintNewSid: true };
|
|
254
|
+
return {
|
|
255
|
+
action: 'skip',
|
|
256
|
+
reason: 'a live session owns this id — use keep-both to import a copy',
|
|
257
|
+
mintNewSid: false,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
if (!exists) return { action: 'create', mintNewSid: false };
|
|
261
|
+
|
|
262
|
+
switch (policy) {
|
|
263
|
+
case 'skip':
|
|
264
|
+
return { action: 'skip', reason: 'already present', mintNewSid: false };
|
|
265
|
+
case 'keep-both':
|
|
266
|
+
return { action: 'keep-both', mintNewSid: true };
|
|
267
|
+
case 'overwrite-if-newer':
|
|
268
|
+
if (isRecent) {
|
|
269
|
+
return { action: 'skip', reason: 'modified within the last 5 minutes', mintNewSid: false };
|
|
270
|
+
}
|
|
271
|
+
if (bundleLastAt && localLastAt && bundleLastAt > localLastAt) {
|
|
272
|
+
return { action: 'overwrite', mintNewSid: false };
|
|
273
|
+
}
|
|
274
|
+
return { action: 'skip', reason: 'local copy is newer or equal', mintNewSid: false };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── writers ──────────────────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
async function writeConversation(
|
|
281
|
+
src: string,
|
|
282
|
+
dest: string,
|
|
283
|
+
targetCwd: string,
|
|
284
|
+
oldSid: string,
|
|
285
|
+
newSid: string | undefined,
|
|
286
|
+
): Promise<void> {
|
|
287
|
+
const out = fs.createWriteStream(dest, { encoding: 'utf8' });
|
|
288
|
+
const rl = readline.createInterface({
|
|
289
|
+
input: fs.createReadStream(src, { encoding: 'utf8' }),
|
|
290
|
+
crlfDelay: Infinity,
|
|
291
|
+
});
|
|
292
|
+
try {
|
|
293
|
+
for await (const raw of rl) {
|
|
294
|
+
if (!raw) continue;
|
|
295
|
+
let line = rewriteLineField(raw, 'cwd', SENTINEL, targetCwd);
|
|
296
|
+
if (newSid) line = rewriteLineField(line, 'sessionId', oldSid, newSid);
|
|
297
|
+
out.write(line + '\n');
|
|
298
|
+
}
|
|
299
|
+
await new Promise<void>((resolve, reject) => {
|
|
300
|
+
out.end((err?: Error | null) => (err ? reject(err) : resolve()));
|
|
301
|
+
});
|
|
302
|
+
} catch (err) {
|
|
303
|
+
out.destroy();
|
|
304
|
+
throw err;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function writeMemory(bundleDir: string, projectDir: string, plans: ImportMemoryPlan[]): string[] {
|
|
309
|
+
const written: string[] = [];
|
|
310
|
+
const memDir = path.join(projectDir, 'memory');
|
|
311
|
+
let made = false;
|
|
312
|
+
for (const p of plans) {
|
|
313
|
+
if (p.action === 'skip') continue;
|
|
314
|
+
if (!isSafeId(p.filename)) continue;
|
|
315
|
+
const src = path.join(bundleDir, 'memory', p.filename);
|
|
316
|
+
if (!fs.existsSync(src)) continue;
|
|
317
|
+
const destName = p.action === 'conflict' ? (p.writtenAs ?? p.filename) : p.filename;
|
|
318
|
+
if (!isSafeId(destName)) continue;
|
|
319
|
+
const dest = path.join(memDir, destName);
|
|
320
|
+
if (!isUnderClaudeRoot(dest)) continue;
|
|
321
|
+
|
|
322
|
+
if (!made) {
|
|
323
|
+
fs.mkdirSync(memDir, { recursive: true });
|
|
324
|
+
made = true;
|
|
325
|
+
}
|
|
326
|
+
const tmp = dest + HISTORY_TMP_SUFFIX;
|
|
327
|
+
fs.copyFileSync(src, tmp);
|
|
328
|
+
fs.renameSync(tmp, dest);
|
|
329
|
+
written.push(destName);
|
|
330
|
+
}
|
|
331
|
+
return written;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ── history merge (append + dedup, atomic backup -> tmp -> rename) ────────────
|
|
335
|
+
|
|
336
|
+
async function gatherHistoryAdditions(
|
|
337
|
+
bundleDir: string,
|
|
338
|
+
sessions: ImportSessionPlan[],
|
|
339
|
+
targetCwd: string,
|
|
340
|
+
): Promise<string[]> {
|
|
341
|
+
const importing = sessions.filter((s) => s.action !== 'skip');
|
|
342
|
+
if (importing.length === 0) return [];
|
|
343
|
+
|
|
344
|
+
const seen = await loadHistoryKeys();
|
|
345
|
+
const out: string[] = [];
|
|
346
|
+
for (const s of importing) {
|
|
347
|
+
const histPath = path.join(bundleDir, 'sessions', s.sessionId, 'history.ndjson');
|
|
348
|
+
if (!fs.existsSync(histPath)) continue;
|
|
349
|
+
const rl = readline.createInterface({
|
|
350
|
+
input: fs.createReadStream(histPath, { encoding: 'utf8' }),
|
|
351
|
+
crlfDelay: Infinity,
|
|
352
|
+
});
|
|
353
|
+
for await (const raw of rl) {
|
|
354
|
+
if (!raw.trim()) continue;
|
|
355
|
+
let line = rewriteLineField(raw, 'project', SENTINEL, targetCwd);
|
|
356
|
+
if (s.newSessionId) line = rewriteLineField(line, 'sessionId', s.sessionId, s.newSessionId);
|
|
357
|
+
let obj: Record<string, unknown>;
|
|
358
|
+
try {
|
|
359
|
+
obj = JSON.parse(line) as Record<string, unknown>;
|
|
360
|
+
} catch {
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
const key = historyKey(obj);
|
|
364
|
+
if (seen.has(key)) continue;
|
|
365
|
+
seen.add(key);
|
|
366
|
+
out.push(line);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function loadHistoryKeys(): Promise<Set<string>> {
|
|
373
|
+
const set = new Set<string>();
|
|
374
|
+
if (!fs.existsSync(PATHS.history)) return set;
|
|
375
|
+
const rl = readline.createInterface({
|
|
376
|
+
input: fs.createReadStream(PATHS.history, { encoding: 'utf8' }),
|
|
377
|
+
crlfDelay: Infinity,
|
|
378
|
+
});
|
|
379
|
+
for await (const raw of rl) {
|
|
380
|
+
if (!raw.trim()) continue;
|
|
381
|
+
try {
|
|
382
|
+
set.add(historyKey(JSON.parse(raw) as Record<string, unknown>));
|
|
383
|
+
} catch {
|
|
384
|
+
/* skip malformed */
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return set;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function historyKey(obj: Record<string, unknown>): string {
|
|
391
|
+
const sid = typeof obj.sessionId === 'string' ? obj.sessionId : '';
|
|
392
|
+
const ts = typeof obj.timestamp === 'string' ? obj.timestamp : '';
|
|
393
|
+
const project = typeof obj.project === 'string' ? obj.project : '';
|
|
394
|
+
const display = typeof obj.display === 'string' ? obj.display : '';
|
|
395
|
+
// `project` is part of the identity: the same session+prompt remapped to a
|
|
396
|
+
// different target cwd is a distinct history entry, while a re-import to the
|
|
397
|
+
// same target collides and is correctly deduped (idempotent).
|
|
398
|
+
return [sid, ts, project, sha256(display)].join(' ');
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function appendHistoryLines(lines: string[]): Promise<number> {
|
|
402
|
+
if (lines.length === 0) return 0;
|
|
403
|
+
|
|
404
|
+
const tmpPath = PATHS.history + HISTORY_TMP_SUFFIX;
|
|
405
|
+
if (fs.existsSync(tmpPath)) fs.rmSync(tmpPath, { force: true });
|
|
406
|
+
|
|
407
|
+
try {
|
|
408
|
+
const out = fs.createWriteStream(tmpPath, { encoding: 'utf8' });
|
|
409
|
+
if (fs.existsSync(PATHS.history)) {
|
|
410
|
+
const rl = readline.createInterface({
|
|
411
|
+
input: fs.createReadStream(PATHS.history, { encoding: 'utf8' }),
|
|
412
|
+
crlfDelay: Infinity,
|
|
413
|
+
});
|
|
414
|
+
for await (const raw of rl) {
|
|
415
|
+
if (!raw) {
|
|
416
|
+
out.write(os.EOL);
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
out.write(raw);
|
|
420
|
+
out.write(os.EOL);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
for (const line of lines) {
|
|
424
|
+
out.write(line);
|
|
425
|
+
out.write(os.EOL);
|
|
426
|
+
}
|
|
427
|
+
await new Promise<void>((resolve, reject) => {
|
|
428
|
+
out.end((err?: Error | null) => (err ? reject(err) : resolve()));
|
|
429
|
+
});
|
|
430
|
+
} catch (err) {
|
|
431
|
+
fs.rmSync(tmpPath, { force: true });
|
|
432
|
+
throw err;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (!fs.existsSync(PATHS.history)) {
|
|
436
|
+
fs.renameSync(tmpPath, PATHS.history);
|
|
437
|
+
return lines.length;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Windows-safe atomic-ish replace: backup original, swap tmp in, drop backup.
|
|
441
|
+
const backup = PATHS.history + '.bak-' + Date.now();
|
|
442
|
+
fs.renameSync(PATHS.history, backup);
|
|
443
|
+
try {
|
|
444
|
+
fs.renameSync(tmpPath, PATHS.history);
|
|
445
|
+
fs.rmSync(backup, { force: true });
|
|
446
|
+
} catch (err) {
|
|
447
|
+
if (fs.existsSync(backup)) {
|
|
448
|
+
try {
|
|
449
|
+
fs.renameSync(backup, PATHS.history);
|
|
450
|
+
} catch {
|
|
451
|
+
/* keep backup for manual recovery */
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
fs.rmSync(tmpPath, { force: true });
|
|
455
|
+
throw err;
|
|
456
|
+
}
|
|
457
|
+
return lines.length;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ── small helpers ─────────────────────────────────────────────────────────────
|
|
461
|
+
|
|
462
|
+
function recentlyActive(jsonlPath: string): boolean {
|
|
463
|
+
try {
|
|
464
|
+
return Date.now() - fs.statSync(jsonlPath).mtimeMs < RECENT_ACTIVITY_WINDOW_MS;
|
|
465
|
+
} catch {
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function statDir(p: string): boolean {
|
|
471
|
+
try {
|
|
472
|
+
return fs.statSync(p).isDirectory();
|
|
473
|
+
} catch {
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function baseName(p: string): string {
|
|
479
|
+
const parts = p.split(/[\\/]+/).filter(Boolean);
|
|
480
|
+
return parts.at(-1) ?? '';
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function conflictName(filename: string, isIndex: boolean, sha: string): string {
|
|
484
|
+
if (isIndex) return 'MEMORY.imported.md';
|
|
485
|
+
const ext = path.extname(filename);
|
|
486
|
+
const base = filename.slice(0, filename.length - ext.length);
|
|
487
|
+
return `${base}.imported-${sha.slice(0, 8)}${ext}`;
|
|
488
|
+
}
|