@zzusp/ccsm 1.0.1 → 1.0.3
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 +81 -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,236 +1,236 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import readline from 'node:readline';
|
|
4
|
-
import { isUnderClaudeRoot, PATHS } from './claude-paths.ts';
|
|
5
|
-
import { parseJsonlMeta } from './parse-jsonl.ts';
|
|
6
|
-
import { isSafeId } from './safe-id.ts';
|
|
7
|
-
import { resolveProjectCwd } from './scan.ts';
|
|
8
|
-
import { rewriteLineField, sha256, sha256File, SENTINEL, transformFile } from './bundle.ts';
|
|
9
|
-
import {
|
|
10
|
-
BUNDLE_KIND,
|
|
11
|
-
BUNDLE_SCHEMA_VERSION,
|
|
12
|
-
type BundleManifest,
|
|
13
|
-
type BundleMemoryFileMeta,
|
|
14
|
-
type BundleMemoryInventory,
|
|
15
|
-
type BundleSessionMeta,
|
|
16
|
-
type ExportResult,
|
|
17
|
-
} from '../types.ts';
|
|
18
|
-
|
|
19
|
-
const JSONL_EXT = '.jsonl';
|
|
20
|
-
|
|
21
|
-
export class ExportError extends Error {}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Write a path-independent bundle of a project's memory + conversations to
|
|
25
|
-
* `destDir`. The structural project root is replaced with {@link SENTINEL} so the
|
|
26
|
-
* bundle can be moved to a device where the project lives at a different path.
|
|
27
|
-
*/
|
|
28
|
-
export async function exportBundle(
|
|
29
|
-
projectId: string,
|
|
30
|
-
sessionIds: string[] | 'all',
|
|
31
|
-
destDir: string,
|
|
32
|
-
): Promise<ExportResult> {
|
|
33
|
-
if (!isSafeId(projectId)) throw new ExportError('invalid project id');
|
|
34
|
-
|
|
35
|
-
const projectDir = path.join(PATHS.projects, projectId);
|
|
36
|
-
if (!fs.existsSync(projectDir)) throw new ExportError('project not found');
|
|
37
|
-
|
|
38
|
-
// The real source cwd — the golden record we replace with the sentinel.
|
|
39
|
-
const resolved = await resolveProjectCwd(projectId);
|
|
40
|
-
if (!resolved) throw new ExportError('project not found');
|
|
41
|
-
const sourceCwd = resolved.decoded;
|
|
42
|
-
|
|
43
|
-
// Never write the bundle inside ~/.claude — that would corrupt the store.
|
|
44
|
-
const dest = path.resolve(destDir);
|
|
45
|
-
if (!path.isAbsolute(dest)) throw new ExportError('destination must be an absolute path');
|
|
46
|
-
if (isUnderClaudeRoot(dest)) throw new ExportError('destination must be outside ~/.claude');
|
|
47
|
-
prepareDestDir(dest);
|
|
48
|
-
|
|
49
|
-
const allIds = listSessionIds(projectDir);
|
|
50
|
-
const ids =
|
|
51
|
-
sessionIds === 'all'
|
|
52
|
-
? allIds
|
|
53
|
-
: sessionIds.filter((id) => isSafeId(id) && allIds.includes(id));
|
|
54
|
-
if (ids.length === 0) throw new ExportError('no sessions to export');
|
|
55
|
-
const idSet = new Set(ids);
|
|
56
|
-
|
|
57
|
-
// Scan history.jsonl once, bucketing matching lines (project -> sentinel) by sid.
|
|
58
|
-
const historyBuckets = await bucketHistoryLines(idSet, sourceCwd);
|
|
59
|
-
|
|
60
|
-
const sessionsDir = path.join(dest, 'sessions');
|
|
61
|
-
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
62
|
-
|
|
63
|
-
let totalBytes = 0;
|
|
64
|
-
let historyLinesExported = 0;
|
|
65
|
-
const sessionMetas: BundleSessionMeta[] = [];
|
|
66
|
-
|
|
67
|
-
for (const id of ids) {
|
|
68
|
-
const jsonlPath = path.join(projectDir, `${id}${JSONL_EXT}`);
|
|
69
|
-
if (!fs.existsSync(jsonlPath)) continue;
|
|
70
|
-
|
|
71
|
-
const sessDir = path.join(sessionsDir, id);
|
|
72
|
-
fs.mkdirSync(sessDir, { recursive: true });
|
|
73
|
-
|
|
74
|
-
const convPath = path.join(sessDir, 'conversation.jsonl');
|
|
75
|
-
const conv = await transformFile(jsonlPath, convPath, 'cwd', sourceCwd, SENTINEL);
|
|
76
|
-
const convBytes = fs.statSync(convPath).size;
|
|
77
|
-
totalBytes += convBytes;
|
|
78
|
-
|
|
79
|
-
let history: BundleSessionMeta['history'] = null;
|
|
80
|
-
const bucket = historyBuckets.get(id);
|
|
81
|
-
if (bucket && bucket.length > 0) {
|
|
82
|
-
const content = bucket.join('\n') + '\n';
|
|
83
|
-
fs.writeFileSync(path.join(sessDir, 'history.ndjson'), content, 'utf8');
|
|
84
|
-
const histBytes = Buffer.byteLength(content, 'utf8');
|
|
85
|
-
totalBytes += histBytes;
|
|
86
|
-
historyLinesExported += bucket.length;
|
|
87
|
-
history = { sha256: sha256(content), lines: bucket.length, bytes: histBytes };
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const meta = await parseJsonlMeta(jsonlPath);
|
|
91
|
-
sessionMetas.push({
|
|
92
|
-
sessionId: id,
|
|
93
|
-
title: meta.title,
|
|
94
|
-
customTitle: meta.customTitle,
|
|
95
|
-
firstAt: meta.firstAt,
|
|
96
|
-
lastAt: meta.lastAt,
|
|
97
|
-
messageCount: meta.messageCount,
|
|
98
|
-
cwdRewritten: meta.cwdFromMessages === sourceCwd,
|
|
99
|
-
conversation: { sha256: conv.sha256, lines: conv.lines, bytes: convBytes },
|
|
100
|
-
history,
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const memory = copyMemory(projectId, dest);
|
|
105
|
-
totalBytes += memory.bytes;
|
|
106
|
-
|
|
107
|
-
const manifest: BundleManifest = {
|
|
108
|
-
schemaVersion: BUNDLE_SCHEMA_VERSION,
|
|
109
|
-
kind: BUNDLE_KIND,
|
|
110
|
-
exportedAt: new Date().toISOString(),
|
|
111
|
-
placeholder: SENTINEL,
|
|
112
|
-
source: {
|
|
113
|
-
platform: process.platform,
|
|
114
|
-
pathSep: path.sep,
|
|
115
|
-
projectId,
|
|
116
|
-
cwd: sourceCwd,
|
|
117
|
-
cwdResolvedAtExport: resolved.resolved,
|
|
118
|
-
},
|
|
119
|
-
memory: memory.inventory,
|
|
120
|
-
sessions: sessionMetas,
|
|
121
|
-
};
|
|
122
|
-
const manifestStr = JSON.stringify(manifest, null, 2);
|
|
123
|
-
fs.writeFileSync(path.join(dest, 'manifest.json'), manifestStr, 'utf8');
|
|
124
|
-
totalBytes += Buffer.byteLength(manifestStr, 'utf8');
|
|
125
|
-
|
|
126
|
-
return {
|
|
127
|
-
destDir: dest,
|
|
128
|
-
sessionsExported: sessionMetas.length,
|
|
129
|
-
memoryFilesExported: memory.inventory.entries.length,
|
|
130
|
-
historyLinesExported,
|
|
131
|
-
totalBytes,
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function listSessionIds(projectDir: string): string[] {
|
|
136
|
-
const ids: string[] = [];
|
|
137
|
-
for (const ent of fs.readdirSync(projectDir, { withFileTypes: true })) {
|
|
138
|
-
if (ent.isFile() && ent.name.endsWith(JSONL_EXT)) {
|
|
139
|
-
ids.push(ent.name.slice(0, -JSONL_EXT.length));
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
return ids;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/** Create dest if absent; allow an empty dir or a prior bundle (re-export). */
|
|
146
|
-
function prepareDestDir(dest: string): void {
|
|
147
|
-
if (!fs.existsSync(dest)) {
|
|
148
|
-
fs.mkdirSync(dest, { recursive: true });
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
if (!fs.statSync(dest).isDirectory()) throw new ExportError('destination is not a directory');
|
|
152
|
-
if (fs.readdirSync(dest).length === 0) return;
|
|
153
|
-
if (!isPriorBundle(path.join(dest, 'manifest.json'))) {
|
|
154
|
-
throw new ExportError('destination is not empty (and not a prior bundle)');
|
|
155
|
-
}
|
|
156
|
-
for (const name of ['manifest.json', 'sessions', 'memory']) {
|
|
157
|
-
fs.rmSync(path.join(dest, name), { recursive: true, force: true });
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function isPriorBundle(manifestPath: string): boolean {
|
|
162
|
-
try {
|
|
163
|
-
const obj = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { kind?: unknown };
|
|
164
|
-
return obj.kind === BUNDLE_KIND;
|
|
165
|
-
} catch {
|
|
166
|
-
return false;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
async function bucketHistoryLines(
|
|
171
|
-
idSet: Set<string>,
|
|
172
|
-
sourceCwd: string,
|
|
173
|
-
): Promise<Map<string, string[]>> {
|
|
174
|
-
const buckets = new Map<string, string[]>();
|
|
175
|
-
if (!fs.existsSync(PATHS.history)) return buckets;
|
|
176
|
-
|
|
177
|
-
const rl = readline.createInterface({
|
|
178
|
-
input: fs.createReadStream(PATHS.history, { encoding: 'utf8' }),
|
|
179
|
-
crlfDelay: Infinity,
|
|
180
|
-
});
|
|
181
|
-
for await (const raw of rl) {
|
|
182
|
-
if (!raw.trim()) continue;
|
|
183
|
-
let sid: unknown;
|
|
184
|
-
try {
|
|
185
|
-
sid = (JSON.parse(raw) as { sessionId?: unknown }).sessionId;
|
|
186
|
-
} catch {
|
|
187
|
-
continue;
|
|
188
|
-
}
|
|
189
|
-
if (typeof sid !== 'string' || !idSet.has(sid)) continue;
|
|
190
|
-
let bucket = buckets.get(sid);
|
|
191
|
-
if (!bucket) buckets.set(sid, (bucket = []));
|
|
192
|
-
bucket.push(rewriteLineField(raw, 'project', sourceCwd, SENTINEL));
|
|
193
|
-
}
|
|
194
|
-
return buckets;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function copyMemory(
|
|
198
|
-
projectId: string,
|
|
199
|
-
dest: string,
|
|
200
|
-
): { inventory: BundleMemoryInventory; bytes: number } {
|
|
201
|
-
const memDir = path.join(PATHS.projects, projectId, 'memory');
|
|
202
|
-
const inventory: BundleMemoryInventory = { hasIndex: false, entries: [] };
|
|
203
|
-
let bytes = 0;
|
|
204
|
-
if (!fs.existsSync(memDir)) return { inventory, bytes };
|
|
205
|
-
|
|
206
|
-
const outDir = path.join(dest, 'memory');
|
|
207
|
-
let made = false;
|
|
208
|
-
for (const filename of fs.readdirSync(memDir)) {
|
|
209
|
-
if (!filename.toLowerCase().endsWith('.md')) continue;
|
|
210
|
-
const src = path.join(memDir, filename);
|
|
211
|
-
let stat;
|
|
212
|
-
try {
|
|
213
|
-
stat = fs.statSync(src);
|
|
214
|
-
} catch {
|
|
215
|
-
continue;
|
|
216
|
-
}
|
|
217
|
-
if (!stat.isFile()) continue;
|
|
218
|
-
|
|
219
|
-
if (!made) {
|
|
220
|
-
fs.mkdirSync(outDir, { recursive: true });
|
|
221
|
-
made = true;
|
|
222
|
-
}
|
|
223
|
-
fs.copyFileSync(src, path.join(outDir, filename));
|
|
224
|
-
const isIndex = filename.toLowerCase() === 'memory.md';
|
|
225
|
-
const entry: BundleMemoryFileMeta = {
|
|
226
|
-
filename,
|
|
227
|
-
isIndex,
|
|
228
|
-
sha256: sha256File(src),
|
|
229
|
-
bytes: stat.size,
|
|
230
|
-
};
|
|
231
|
-
bytes += stat.size;
|
|
232
|
-
if (isIndex) inventory.hasIndex = true;
|
|
233
|
-
inventory.entries.push(entry);
|
|
234
|
-
}
|
|
235
|
-
return { inventory, bytes };
|
|
236
|
-
}
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import readline from 'node:readline';
|
|
4
|
+
import { isUnderClaudeRoot, PATHS } from './claude-paths.ts';
|
|
5
|
+
import { parseJsonlMeta } from './parse-jsonl.ts';
|
|
6
|
+
import { isSafeId } from './safe-id.ts';
|
|
7
|
+
import { resolveProjectCwd } from './scan.ts';
|
|
8
|
+
import { rewriteLineField, sha256, sha256File, SENTINEL, transformFile } from './bundle.ts';
|
|
9
|
+
import {
|
|
10
|
+
BUNDLE_KIND,
|
|
11
|
+
BUNDLE_SCHEMA_VERSION,
|
|
12
|
+
type BundleManifest,
|
|
13
|
+
type BundleMemoryFileMeta,
|
|
14
|
+
type BundleMemoryInventory,
|
|
15
|
+
type BundleSessionMeta,
|
|
16
|
+
type ExportResult,
|
|
17
|
+
} from '../types.ts';
|
|
18
|
+
|
|
19
|
+
const JSONL_EXT = '.jsonl';
|
|
20
|
+
|
|
21
|
+
export class ExportError extends Error {}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Write a path-independent bundle of a project's memory + conversations to
|
|
25
|
+
* `destDir`. The structural project root is replaced with {@link SENTINEL} so the
|
|
26
|
+
* bundle can be moved to a device where the project lives at a different path.
|
|
27
|
+
*/
|
|
28
|
+
export async function exportBundle(
|
|
29
|
+
projectId: string,
|
|
30
|
+
sessionIds: string[] | 'all',
|
|
31
|
+
destDir: string,
|
|
32
|
+
): Promise<ExportResult> {
|
|
33
|
+
if (!isSafeId(projectId)) throw new ExportError('invalid project id');
|
|
34
|
+
|
|
35
|
+
const projectDir = path.join(PATHS.projects, projectId);
|
|
36
|
+
if (!fs.existsSync(projectDir)) throw new ExportError('project not found');
|
|
37
|
+
|
|
38
|
+
// The real source cwd — the golden record we replace with the sentinel.
|
|
39
|
+
const resolved = await resolveProjectCwd(projectId);
|
|
40
|
+
if (!resolved) throw new ExportError('project not found');
|
|
41
|
+
const sourceCwd = resolved.decoded;
|
|
42
|
+
|
|
43
|
+
// Never write the bundle inside ~/.claude — that would corrupt the store.
|
|
44
|
+
const dest = path.resolve(destDir);
|
|
45
|
+
if (!path.isAbsolute(dest)) throw new ExportError('destination must be an absolute path');
|
|
46
|
+
if (isUnderClaudeRoot(dest)) throw new ExportError('destination must be outside ~/.claude');
|
|
47
|
+
prepareDestDir(dest);
|
|
48
|
+
|
|
49
|
+
const allIds = listSessionIds(projectDir);
|
|
50
|
+
const ids =
|
|
51
|
+
sessionIds === 'all'
|
|
52
|
+
? allIds
|
|
53
|
+
: sessionIds.filter((id) => isSafeId(id) && allIds.includes(id));
|
|
54
|
+
if (ids.length === 0) throw new ExportError('no sessions to export');
|
|
55
|
+
const idSet = new Set(ids);
|
|
56
|
+
|
|
57
|
+
// Scan history.jsonl once, bucketing matching lines (project -> sentinel) by sid.
|
|
58
|
+
const historyBuckets = await bucketHistoryLines(idSet, sourceCwd);
|
|
59
|
+
|
|
60
|
+
const sessionsDir = path.join(dest, 'sessions');
|
|
61
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
62
|
+
|
|
63
|
+
let totalBytes = 0;
|
|
64
|
+
let historyLinesExported = 0;
|
|
65
|
+
const sessionMetas: BundleSessionMeta[] = [];
|
|
66
|
+
|
|
67
|
+
for (const id of ids) {
|
|
68
|
+
const jsonlPath = path.join(projectDir, `${id}${JSONL_EXT}`);
|
|
69
|
+
if (!fs.existsSync(jsonlPath)) continue;
|
|
70
|
+
|
|
71
|
+
const sessDir = path.join(sessionsDir, id);
|
|
72
|
+
fs.mkdirSync(sessDir, { recursive: true });
|
|
73
|
+
|
|
74
|
+
const convPath = path.join(sessDir, 'conversation.jsonl');
|
|
75
|
+
const conv = await transformFile(jsonlPath, convPath, 'cwd', sourceCwd, SENTINEL);
|
|
76
|
+
const convBytes = fs.statSync(convPath).size;
|
|
77
|
+
totalBytes += convBytes;
|
|
78
|
+
|
|
79
|
+
let history: BundleSessionMeta['history'] = null;
|
|
80
|
+
const bucket = historyBuckets.get(id);
|
|
81
|
+
if (bucket && bucket.length > 0) {
|
|
82
|
+
const content = bucket.join('\n') + '\n';
|
|
83
|
+
fs.writeFileSync(path.join(sessDir, 'history.ndjson'), content, 'utf8');
|
|
84
|
+
const histBytes = Buffer.byteLength(content, 'utf8');
|
|
85
|
+
totalBytes += histBytes;
|
|
86
|
+
historyLinesExported += bucket.length;
|
|
87
|
+
history = { sha256: sha256(content), lines: bucket.length, bytes: histBytes };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const meta = await parseJsonlMeta(jsonlPath);
|
|
91
|
+
sessionMetas.push({
|
|
92
|
+
sessionId: id,
|
|
93
|
+
title: meta.title,
|
|
94
|
+
customTitle: meta.customTitle,
|
|
95
|
+
firstAt: meta.firstAt,
|
|
96
|
+
lastAt: meta.lastAt,
|
|
97
|
+
messageCount: meta.messageCount,
|
|
98
|
+
cwdRewritten: meta.cwdFromMessages === sourceCwd,
|
|
99
|
+
conversation: { sha256: conv.sha256, lines: conv.lines, bytes: convBytes },
|
|
100
|
+
history,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const memory = copyMemory(projectId, dest);
|
|
105
|
+
totalBytes += memory.bytes;
|
|
106
|
+
|
|
107
|
+
const manifest: BundleManifest = {
|
|
108
|
+
schemaVersion: BUNDLE_SCHEMA_VERSION,
|
|
109
|
+
kind: BUNDLE_KIND,
|
|
110
|
+
exportedAt: new Date().toISOString(),
|
|
111
|
+
placeholder: SENTINEL,
|
|
112
|
+
source: {
|
|
113
|
+
platform: process.platform,
|
|
114
|
+
pathSep: path.sep,
|
|
115
|
+
projectId,
|
|
116
|
+
cwd: sourceCwd,
|
|
117
|
+
cwdResolvedAtExport: resolved.resolved,
|
|
118
|
+
},
|
|
119
|
+
memory: memory.inventory,
|
|
120
|
+
sessions: sessionMetas,
|
|
121
|
+
};
|
|
122
|
+
const manifestStr = JSON.stringify(manifest, null, 2);
|
|
123
|
+
fs.writeFileSync(path.join(dest, 'manifest.json'), manifestStr, 'utf8');
|
|
124
|
+
totalBytes += Buffer.byteLength(manifestStr, 'utf8');
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
destDir: dest,
|
|
128
|
+
sessionsExported: sessionMetas.length,
|
|
129
|
+
memoryFilesExported: memory.inventory.entries.length,
|
|
130
|
+
historyLinesExported,
|
|
131
|
+
totalBytes,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function listSessionIds(projectDir: string): string[] {
|
|
136
|
+
const ids: string[] = [];
|
|
137
|
+
for (const ent of fs.readdirSync(projectDir, { withFileTypes: true })) {
|
|
138
|
+
if (ent.isFile() && ent.name.endsWith(JSONL_EXT)) {
|
|
139
|
+
ids.push(ent.name.slice(0, -JSONL_EXT.length));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return ids;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Create dest if absent; allow an empty dir or a prior bundle (re-export). */
|
|
146
|
+
function prepareDestDir(dest: string): void {
|
|
147
|
+
if (!fs.existsSync(dest)) {
|
|
148
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (!fs.statSync(dest).isDirectory()) throw new ExportError('destination is not a directory');
|
|
152
|
+
if (fs.readdirSync(dest).length === 0) return;
|
|
153
|
+
if (!isPriorBundle(path.join(dest, 'manifest.json'))) {
|
|
154
|
+
throw new ExportError('destination is not empty (and not a prior bundle)');
|
|
155
|
+
}
|
|
156
|
+
for (const name of ['manifest.json', 'sessions', 'memory']) {
|
|
157
|
+
fs.rmSync(path.join(dest, name), { recursive: true, force: true });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function isPriorBundle(manifestPath: string): boolean {
|
|
162
|
+
try {
|
|
163
|
+
const obj = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { kind?: unknown };
|
|
164
|
+
return obj.kind === BUNDLE_KIND;
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function bucketHistoryLines(
|
|
171
|
+
idSet: Set<string>,
|
|
172
|
+
sourceCwd: string,
|
|
173
|
+
): Promise<Map<string, string[]>> {
|
|
174
|
+
const buckets = new Map<string, string[]>();
|
|
175
|
+
if (!fs.existsSync(PATHS.history)) return buckets;
|
|
176
|
+
|
|
177
|
+
const rl = readline.createInterface({
|
|
178
|
+
input: fs.createReadStream(PATHS.history, { encoding: 'utf8' }),
|
|
179
|
+
crlfDelay: Infinity,
|
|
180
|
+
});
|
|
181
|
+
for await (const raw of rl) {
|
|
182
|
+
if (!raw.trim()) continue;
|
|
183
|
+
let sid: unknown;
|
|
184
|
+
try {
|
|
185
|
+
sid = (JSON.parse(raw) as { sessionId?: unknown }).sessionId;
|
|
186
|
+
} catch {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (typeof sid !== 'string' || !idSet.has(sid)) continue;
|
|
190
|
+
let bucket = buckets.get(sid);
|
|
191
|
+
if (!bucket) buckets.set(sid, (bucket = []));
|
|
192
|
+
bucket.push(rewriteLineField(raw, 'project', sourceCwd, SENTINEL));
|
|
193
|
+
}
|
|
194
|
+
return buckets;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function copyMemory(
|
|
198
|
+
projectId: string,
|
|
199
|
+
dest: string,
|
|
200
|
+
): { inventory: BundleMemoryInventory; bytes: number } {
|
|
201
|
+
const memDir = path.join(PATHS.projects, projectId, 'memory');
|
|
202
|
+
const inventory: BundleMemoryInventory = { hasIndex: false, entries: [] };
|
|
203
|
+
let bytes = 0;
|
|
204
|
+
if (!fs.existsSync(memDir)) return { inventory, bytes };
|
|
205
|
+
|
|
206
|
+
const outDir = path.join(dest, 'memory');
|
|
207
|
+
let made = false;
|
|
208
|
+
for (const filename of fs.readdirSync(memDir)) {
|
|
209
|
+
if (!filename.toLowerCase().endsWith('.md')) continue;
|
|
210
|
+
const src = path.join(memDir, filename);
|
|
211
|
+
let stat;
|
|
212
|
+
try {
|
|
213
|
+
stat = fs.statSync(src);
|
|
214
|
+
} catch {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (!stat.isFile()) continue;
|
|
218
|
+
|
|
219
|
+
if (!made) {
|
|
220
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
221
|
+
made = true;
|
|
222
|
+
}
|
|
223
|
+
fs.copyFileSync(src, path.join(outDir, filename));
|
|
224
|
+
const isIndex = filename.toLowerCase() === 'memory.md';
|
|
225
|
+
const entry: BundleMemoryFileMeta = {
|
|
226
|
+
filename,
|
|
227
|
+
isIndex,
|
|
228
|
+
sha256: sha256File(src),
|
|
229
|
+
bytes: stat.size,
|
|
230
|
+
};
|
|
231
|
+
bytes += stat.size;
|
|
232
|
+
if (isIndex) inventory.hasIndex = true;
|
|
233
|
+
inventory.entries.push(entry);
|
|
234
|
+
}
|
|
235
|
+
return { inventory, bytes };
|
|
236
|
+
}
|