@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,120 +1,120 @@
|
|
|
1
|
-
import fs from 'node:fs/promises';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { isUnderClaudeRoot, PATHS } from './claude-paths.ts';
|
|
4
|
-
import type { MemoryEntry, MemoryResponse, MemoryType } from '../types.ts';
|
|
5
|
-
|
|
6
|
-
const KNOWN_TYPES: ReadonlySet<MemoryType> = new Set([
|
|
7
|
-
'user',
|
|
8
|
-
'feedback',
|
|
9
|
-
'project',
|
|
10
|
-
'reference',
|
|
11
|
-
]);
|
|
12
|
-
|
|
13
|
-
const TYPE_ORDER: ReadonlyArray<MemoryType> = ['user', 'feedback', 'project', 'reference'];
|
|
14
|
-
|
|
15
|
-
export async function loadProjectMemory(projectId: string): Promise<MemoryResponse> {
|
|
16
|
-
const dir = path.join(PATHS.projects, projectId, 'memory');
|
|
17
|
-
if (!isUnderClaudeRoot(dir)) return { index: null, entries: [] };
|
|
18
|
-
|
|
19
|
-
let dirents: string[];
|
|
20
|
-
try {
|
|
21
|
-
dirents = await fs.readdir(dir);
|
|
22
|
-
} catch (err) {
|
|
23
|
-
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
24
|
-
return { index: null, entries: [] };
|
|
25
|
-
}
|
|
26
|
-
throw err;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
let index: string | null = null;
|
|
30
|
-
const entries: MemoryEntry[] = [];
|
|
31
|
-
|
|
32
|
-
for (const filename of dirents) {
|
|
33
|
-
if (!filename.toLowerCase().endsWith('.md')) continue;
|
|
34
|
-
const full = path.join(dir, filename);
|
|
35
|
-
let stat;
|
|
36
|
-
try {
|
|
37
|
-
stat = await fs.stat(full);
|
|
38
|
-
} catch {
|
|
39
|
-
continue;
|
|
40
|
-
}
|
|
41
|
-
if (!stat.isFile()) continue;
|
|
42
|
-
|
|
43
|
-
let raw: string;
|
|
44
|
-
try {
|
|
45
|
-
raw = await fs.readFile(full, 'utf8');
|
|
46
|
-
} catch {
|
|
47
|
-
continue;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
if (filename.toLowerCase() === 'memory.md') {
|
|
51
|
-
index = raw;
|
|
52
|
-
continue;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const { name, description, type, body } = parseFrontmatter(raw);
|
|
56
|
-
entries.push({
|
|
57
|
-
filename,
|
|
58
|
-
name,
|
|
59
|
-
description,
|
|
60
|
-
type,
|
|
61
|
-
body,
|
|
62
|
-
bytes: stat.size,
|
|
63
|
-
mtime: stat.mtime.toISOString(),
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
entries.sort((a, b) => {
|
|
68
|
-
const ai = a.type ? TYPE_ORDER.indexOf(a.type) : TYPE_ORDER.length;
|
|
69
|
-
const bi = b.type ? TYPE_ORDER.indexOf(b.type) : TYPE_ORDER.length;
|
|
70
|
-
if (ai !== bi) return ai - bi;
|
|
71
|
-
const an = a.name ?? a.filename;
|
|
72
|
-
const bn = b.name ?? b.filename;
|
|
73
|
-
return an.localeCompare(bn);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
return { index, entries };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
interface Parsed {
|
|
80
|
-
name: string | null;
|
|
81
|
-
description: string | null;
|
|
82
|
-
type: MemoryType | null;
|
|
83
|
-
body: string;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function parseFrontmatter(raw: string): Parsed {
|
|
87
|
-
const result: Parsed = { name: null, description: null, type: null, body: raw };
|
|
88
|
-
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(raw);
|
|
89
|
-
if (!m) return result;
|
|
90
|
-
|
|
91
|
-
const fmBlock = m[1] ?? '';
|
|
92
|
-
result.body = (m[2] ?? '').replace(/^\r?\n/, '');
|
|
93
|
-
|
|
94
|
-
for (const line of fmBlock.split(/\r?\n/)) {
|
|
95
|
-
const colon = line.indexOf(':');
|
|
96
|
-
if (colon <= 0) continue;
|
|
97
|
-
const key = line.slice(0, colon).trim().toLowerCase();
|
|
98
|
-
const value = stripQuotes(line.slice(colon + 1).trim());
|
|
99
|
-
if (!value) continue;
|
|
100
|
-
if (key === 'name') result.name = value;
|
|
101
|
-
else if (key === 'description') result.description = value;
|
|
102
|
-
else if (key === 'type') {
|
|
103
|
-
const lower = value.toLowerCase() as MemoryType;
|
|
104
|
-
if (KNOWN_TYPES.has(lower)) result.type = lower;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
return result;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function stripQuotes(s: string): string {
|
|
112
|
-
if (s.length >= 2) {
|
|
113
|
-
const first = s[0];
|
|
114
|
-
const last = s[s.length - 1];
|
|
115
|
-
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
116
|
-
return s.slice(1, -1);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
return s;
|
|
120
|
-
}
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { isUnderClaudeRoot, PATHS } from './claude-paths.ts';
|
|
4
|
+
import type { MemoryEntry, MemoryResponse, MemoryType } from '../types.ts';
|
|
5
|
+
|
|
6
|
+
const KNOWN_TYPES: ReadonlySet<MemoryType> = new Set([
|
|
7
|
+
'user',
|
|
8
|
+
'feedback',
|
|
9
|
+
'project',
|
|
10
|
+
'reference',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const TYPE_ORDER: ReadonlyArray<MemoryType> = ['user', 'feedback', 'project', 'reference'];
|
|
14
|
+
|
|
15
|
+
export async function loadProjectMemory(projectId: string): Promise<MemoryResponse> {
|
|
16
|
+
const dir = path.join(PATHS.projects, projectId, 'memory');
|
|
17
|
+
if (!isUnderClaudeRoot(dir)) return { index: null, entries: [] };
|
|
18
|
+
|
|
19
|
+
let dirents: string[];
|
|
20
|
+
try {
|
|
21
|
+
dirents = await fs.readdir(dir);
|
|
22
|
+
} catch (err) {
|
|
23
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
24
|
+
return { index: null, entries: [] };
|
|
25
|
+
}
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let index: string | null = null;
|
|
30
|
+
const entries: MemoryEntry[] = [];
|
|
31
|
+
|
|
32
|
+
for (const filename of dirents) {
|
|
33
|
+
if (!filename.toLowerCase().endsWith('.md')) continue;
|
|
34
|
+
const full = path.join(dir, filename);
|
|
35
|
+
let stat;
|
|
36
|
+
try {
|
|
37
|
+
stat = await fs.stat(full);
|
|
38
|
+
} catch {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (!stat.isFile()) continue;
|
|
42
|
+
|
|
43
|
+
let raw: string;
|
|
44
|
+
try {
|
|
45
|
+
raw = await fs.readFile(full, 'utf8');
|
|
46
|
+
} catch {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (filename.toLowerCase() === 'memory.md') {
|
|
51
|
+
index = raw;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const { name, description, type, body } = parseFrontmatter(raw);
|
|
56
|
+
entries.push({
|
|
57
|
+
filename,
|
|
58
|
+
name,
|
|
59
|
+
description,
|
|
60
|
+
type,
|
|
61
|
+
body,
|
|
62
|
+
bytes: stat.size,
|
|
63
|
+
mtime: stat.mtime.toISOString(),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
entries.sort((a, b) => {
|
|
68
|
+
const ai = a.type ? TYPE_ORDER.indexOf(a.type) : TYPE_ORDER.length;
|
|
69
|
+
const bi = b.type ? TYPE_ORDER.indexOf(b.type) : TYPE_ORDER.length;
|
|
70
|
+
if (ai !== bi) return ai - bi;
|
|
71
|
+
const an = a.name ?? a.filename;
|
|
72
|
+
const bn = b.name ?? b.filename;
|
|
73
|
+
return an.localeCompare(bn);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return { index, entries };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface Parsed {
|
|
80
|
+
name: string | null;
|
|
81
|
+
description: string | null;
|
|
82
|
+
type: MemoryType | null;
|
|
83
|
+
body: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseFrontmatter(raw: string): Parsed {
|
|
87
|
+
const result: Parsed = { name: null, description: null, type: null, body: raw };
|
|
88
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(raw);
|
|
89
|
+
if (!m) return result;
|
|
90
|
+
|
|
91
|
+
const fmBlock = m[1] ?? '';
|
|
92
|
+
result.body = (m[2] ?? '').replace(/^\r?\n/, '');
|
|
93
|
+
|
|
94
|
+
for (const line of fmBlock.split(/\r?\n/)) {
|
|
95
|
+
const colon = line.indexOf(':');
|
|
96
|
+
if (colon <= 0) continue;
|
|
97
|
+
const key = line.slice(0, colon).trim().toLowerCase();
|
|
98
|
+
const value = stripQuotes(line.slice(colon + 1).trim());
|
|
99
|
+
if (!value) continue;
|
|
100
|
+
if (key === 'name') result.name = value;
|
|
101
|
+
else if (key === 'description') result.description = value;
|
|
102
|
+
else if (key === 'type') {
|
|
103
|
+
const lower = value.toLowerCase() as MemoryType;
|
|
104
|
+
if (KNOWN_TYPES.has(lower)) result.type = lower;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function stripQuotes(s: string): string {
|
|
112
|
+
if (s.length >= 2) {
|
|
113
|
+
const first = s[0];
|
|
114
|
+
const last = s[s.length - 1];
|
|
115
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
116
|
+
return s.slice(1, -1);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return s;
|
|
120
|
+
}
|
|
@@ -1,209 +1,209 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import readline from 'node:readline';
|
|
4
|
-
import { PATHS } from './claude-paths.ts';
|
|
5
|
-
import { MAX_SESSION_MESSAGES } from './constants.ts';
|
|
6
|
-
import { SYSTEM_TAG_RE, pickTitleText } from './system-tags.ts';
|
|
7
|
-
import type { Block, Message, SessionDetail, SessionMeta } from '../types.ts';
|
|
8
|
-
|
|
9
|
-
export async function loadSessionDetail(
|
|
10
|
-
projectId: string,
|
|
11
|
-
sessionId: string,
|
|
12
|
-
): Promise<SessionDetail | null> {
|
|
13
|
-
const jsonlPath = path.join(PATHS.projects, projectId, `${sessionId}.jsonl`);
|
|
14
|
-
if (!fs.existsSync(jsonlPath)) return null;
|
|
15
|
-
|
|
16
|
-
let bytes = 0;
|
|
17
|
-
let mtimeIso: string | null = null;
|
|
18
|
-
try {
|
|
19
|
-
const stat = fs.statSync(jsonlPath);
|
|
20
|
-
bytes = stat.size;
|
|
21
|
-
mtimeIso = stat.mtime.toISOString();
|
|
22
|
-
} catch {
|
|
23
|
-
/* ignore */
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const meta: SessionMeta = {
|
|
27
|
-
sessionId,
|
|
28
|
-
projectId,
|
|
29
|
-
cwd: null,
|
|
30
|
-
gitBranch: null,
|
|
31
|
-
version: null,
|
|
32
|
-
firstAt: null,
|
|
33
|
-
lastAt: null,
|
|
34
|
-
messageCount: 0,
|
|
35
|
-
bytes,
|
|
36
|
-
title: '(untitled)',
|
|
37
|
-
customTitle: null,
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
const messages: Message[] = [];
|
|
41
|
-
let truncated = false;
|
|
42
|
-
// Latest `ai-title` record wins (Claude rewrites it every turn). Kept off
|
|
43
|
-
// the wire shape; just used to seed `meta.title` once parsing finishes.
|
|
44
|
-
let aiTitle: string | null = null;
|
|
45
|
-
|
|
46
|
-
const rl = readline.createInterface({
|
|
47
|
-
input: fs.createReadStream(jsonlPath, { encoding: 'utf8' }),
|
|
48
|
-
crlfDelay: Infinity,
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
for await (const raw of rl) {
|
|
52
|
-
const line = raw.trim();
|
|
53
|
-
if (!line) continue;
|
|
54
|
-
let obj: Record<string, unknown>;
|
|
55
|
-
try {
|
|
56
|
-
obj = JSON.parse(line) as Record<string, unknown>;
|
|
57
|
-
} catch {
|
|
58
|
-
continue;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
captureMeta(obj, meta);
|
|
62
|
-
if (obj.type === 'ai-title' && typeof obj.aiTitle === 'string') {
|
|
63
|
-
aiTitle = obj.aiTitle;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
if (obj.type !== 'user' && obj.type !== 'assistant') continue;
|
|
67
|
-
if (messages.length >= MAX_SESSION_MESSAGES) {
|
|
68
|
-
truncated = true;
|
|
69
|
-
continue;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const msg = buildMessage(obj);
|
|
73
|
-
if (msg) messages.push(msg);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
messages.sort((a, b) => (a.ts ?? '').localeCompare(b.ts ?? ''));
|
|
77
|
-
|
|
78
|
-
// Match `parseJsonlMeta`: file mtime advances on untimestamped meta rewrites
|
|
79
|
-
// (ai-title rotate, rename), so fold it into lastAt to stay in sync with
|
|
80
|
-
// `claude code resume`.
|
|
81
|
-
if (mtimeIso && (!meta.lastAt || mtimeIso > meta.lastAt)) {
|
|
82
|
-
meta.lastAt = mtimeIso;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
meta.messageCount = messages.length;
|
|
86
|
-
meta.title = aiTitle || deriveAutoTitle(messages);
|
|
87
|
-
return { meta, messages, truncated };
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function captureMeta(obj: Record<string, unknown>, meta: SessionMeta): void {
|
|
91
|
-
if (typeof obj.cwd === 'string' && !meta.cwd) meta.cwd = obj.cwd;
|
|
92
|
-
if (typeof obj.gitBranch === 'string' && !meta.gitBranch) meta.gitBranch = obj.gitBranch;
|
|
93
|
-
if (typeof obj.version === 'string' && !meta.version) meta.version = obj.version;
|
|
94
|
-
if (obj.type === 'custom-title' && typeof obj.customTitle === 'string') {
|
|
95
|
-
meta.customTitle = obj.customTitle;
|
|
96
|
-
}
|
|
97
|
-
const ts = typeof obj.timestamp === 'string' ? obj.timestamp : null;
|
|
98
|
-
if (ts) {
|
|
99
|
-
if (!meta.firstAt) meta.firstAt = ts;
|
|
100
|
-
meta.lastAt = ts;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function deriveAutoTitle(messages: Message[]): string {
|
|
105
|
-
for (const m of messages) {
|
|
106
|
-
if (m.type !== 'user' || m.isMeta) continue;
|
|
107
|
-
for (const block of m.blocks) {
|
|
108
|
-
if (block.type !== 'text') continue;
|
|
109
|
-
const usable = pickTitleText(block.text);
|
|
110
|
-
if (!usable) continue;
|
|
111
|
-
const line = usable.trim().split('\n')[0] ?? '';
|
|
112
|
-
if (!line) continue;
|
|
113
|
-
return line.length > 80 ? line.slice(0, 80) + '…' : line;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
return '(untitled)';
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function buildMessage(obj: Record<string, unknown>): Message | null {
|
|
120
|
-
const type = obj.type === 'user' ? 'user' : 'assistant';
|
|
121
|
-
const message = (obj.message ?? {}) as { content?: unknown; model?: unknown };
|
|
122
|
-
const blocks = parseContent(message.content);
|
|
123
|
-
|
|
124
|
-
let isMeta = false;
|
|
125
|
-
if (type === 'user' && blocks.length === 1 && blocks[0]!.type === 'text') {
|
|
126
|
-
if (SYSTEM_TAG_RE.test(blocks[0]!.text)) isMeta = true;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
return {
|
|
130
|
-
uuid: typeof obj.uuid === 'string' ? obj.uuid : '',
|
|
131
|
-
parentUuid: typeof obj.parentUuid === 'string' ? obj.parentUuid : null,
|
|
132
|
-
type,
|
|
133
|
-
ts: typeof obj.timestamp === 'string' ? obj.timestamp : null,
|
|
134
|
-
model: typeof message.model === 'string' ? message.model : null,
|
|
135
|
-
blocks,
|
|
136
|
-
isMeta,
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function parseContent(content: unknown): Block[] {
|
|
141
|
-
if (typeof content === 'string') {
|
|
142
|
-
return [{ type: 'text', text: content }];
|
|
143
|
-
}
|
|
144
|
-
if (!Array.isArray(content)) return [];
|
|
145
|
-
|
|
146
|
-
const out: Block[] = [];
|
|
147
|
-
for (const raw of content) {
|
|
148
|
-
if (!raw || typeof raw !== 'object') continue;
|
|
149
|
-
const b = raw as Record<string, unknown>;
|
|
150
|
-
switch (b.type) {
|
|
151
|
-
case 'text':
|
|
152
|
-
out.push({ type: 'text', text: typeof b.text === 'string' ? b.text : '' });
|
|
153
|
-
break;
|
|
154
|
-
case 'tool_use':
|
|
155
|
-
out.push({
|
|
156
|
-
type: 'tool_use',
|
|
157
|
-
id: typeof b.id === 'string' ? b.id : '',
|
|
158
|
-
name: typeof b.name === 'string' ? b.name : '(unknown)',
|
|
159
|
-
input: b.input ?? null,
|
|
160
|
-
});
|
|
161
|
-
break;
|
|
162
|
-
case 'tool_result':
|
|
163
|
-
out.push({
|
|
164
|
-
type: 'tool_result',
|
|
165
|
-
toolUseId: typeof b.tool_use_id === 'string' ? b.tool_use_id : '',
|
|
166
|
-
content: stringifyToolResult(b.content),
|
|
167
|
-
isError: b.is_error === true,
|
|
168
|
-
});
|
|
169
|
-
break;
|
|
170
|
-
case 'thinking':
|
|
171
|
-
out.push({
|
|
172
|
-
type: 'thinking',
|
|
173
|
-
text: typeof b.thinking === 'string' ? b.thinking : '',
|
|
174
|
-
});
|
|
175
|
-
break;
|
|
176
|
-
case 'image': {
|
|
177
|
-
const src = b.source as { media_type?: unknown } | undefined;
|
|
178
|
-
out.push({
|
|
179
|
-
type: 'image',
|
|
180
|
-
mediaType: typeof src?.media_type === 'string' ? src.media_type : null,
|
|
181
|
-
});
|
|
182
|
-
break;
|
|
183
|
-
}
|
|
184
|
-
default:
|
|
185
|
-
out.push({ type: 'unknown', raw: b });
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
return out;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function stringifyToolResult(content: unknown): string {
|
|
192
|
-
if (typeof content === 'string') return content;
|
|
193
|
-
if (Array.isArray(content)) {
|
|
194
|
-
return content
|
|
195
|
-
.map((b) => {
|
|
196
|
-
if (b && typeof b === 'object' && (b as { type?: unknown }).type === 'text') {
|
|
197
|
-
const t = (b as { text?: unknown }).text;
|
|
198
|
-
return typeof t === 'string' ? t : '';
|
|
199
|
-
}
|
|
200
|
-
if (b && typeof b === 'object' && (b as { type?: unknown }).type === 'image') {
|
|
201
|
-
return '[image]';
|
|
202
|
-
}
|
|
203
|
-
return '';
|
|
204
|
-
})
|
|
205
|
-
.filter(Boolean)
|
|
206
|
-
.join('\n');
|
|
207
|
-
}
|
|
208
|
-
return '';
|
|
209
|
-
}
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import readline from 'node:readline';
|
|
4
|
+
import { PATHS } from './claude-paths.ts';
|
|
5
|
+
import { MAX_SESSION_MESSAGES } from './constants.ts';
|
|
6
|
+
import { SYSTEM_TAG_RE, pickTitleText } from './system-tags.ts';
|
|
7
|
+
import type { Block, Message, SessionDetail, SessionMeta } from '../types.ts';
|
|
8
|
+
|
|
9
|
+
export async function loadSessionDetail(
|
|
10
|
+
projectId: string,
|
|
11
|
+
sessionId: string,
|
|
12
|
+
): Promise<SessionDetail | null> {
|
|
13
|
+
const jsonlPath = path.join(PATHS.projects, projectId, `${sessionId}.jsonl`);
|
|
14
|
+
if (!fs.existsSync(jsonlPath)) return null;
|
|
15
|
+
|
|
16
|
+
let bytes = 0;
|
|
17
|
+
let mtimeIso: string | null = null;
|
|
18
|
+
try {
|
|
19
|
+
const stat = fs.statSync(jsonlPath);
|
|
20
|
+
bytes = stat.size;
|
|
21
|
+
mtimeIso = stat.mtime.toISOString();
|
|
22
|
+
} catch {
|
|
23
|
+
/* ignore */
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const meta: SessionMeta = {
|
|
27
|
+
sessionId,
|
|
28
|
+
projectId,
|
|
29
|
+
cwd: null,
|
|
30
|
+
gitBranch: null,
|
|
31
|
+
version: null,
|
|
32
|
+
firstAt: null,
|
|
33
|
+
lastAt: null,
|
|
34
|
+
messageCount: 0,
|
|
35
|
+
bytes,
|
|
36
|
+
title: '(untitled)',
|
|
37
|
+
customTitle: null,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const messages: Message[] = [];
|
|
41
|
+
let truncated = false;
|
|
42
|
+
// Latest `ai-title` record wins (Claude rewrites it every turn). Kept off
|
|
43
|
+
// the wire shape; just used to seed `meta.title` once parsing finishes.
|
|
44
|
+
let aiTitle: string | null = null;
|
|
45
|
+
|
|
46
|
+
const rl = readline.createInterface({
|
|
47
|
+
input: fs.createReadStream(jsonlPath, { encoding: 'utf8' }),
|
|
48
|
+
crlfDelay: Infinity,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
for await (const raw of rl) {
|
|
52
|
+
const line = raw.trim();
|
|
53
|
+
if (!line) continue;
|
|
54
|
+
let obj: Record<string, unknown>;
|
|
55
|
+
try {
|
|
56
|
+
obj = JSON.parse(line) as Record<string, unknown>;
|
|
57
|
+
} catch {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
captureMeta(obj, meta);
|
|
62
|
+
if (obj.type === 'ai-title' && typeof obj.aiTitle === 'string') {
|
|
63
|
+
aiTitle = obj.aiTitle;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (obj.type !== 'user' && obj.type !== 'assistant') continue;
|
|
67
|
+
if (messages.length >= MAX_SESSION_MESSAGES) {
|
|
68
|
+
truncated = true;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const msg = buildMessage(obj);
|
|
73
|
+
if (msg) messages.push(msg);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
messages.sort((a, b) => (a.ts ?? '').localeCompare(b.ts ?? ''));
|
|
77
|
+
|
|
78
|
+
// Match `parseJsonlMeta`: file mtime advances on untimestamped meta rewrites
|
|
79
|
+
// (ai-title rotate, rename), so fold it into lastAt to stay in sync with
|
|
80
|
+
// `claude code resume`.
|
|
81
|
+
if (mtimeIso && (!meta.lastAt || mtimeIso > meta.lastAt)) {
|
|
82
|
+
meta.lastAt = mtimeIso;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
meta.messageCount = messages.length;
|
|
86
|
+
meta.title = aiTitle || deriveAutoTitle(messages);
|
|
87
|
+
return { meta, messages, truncated };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function captureMeta(obj: Record<string, unknown>, meta: SessionMeta): void {
|
|
91
|
+
if (typeof obj.cwd === 'string' && !meta.cwd) meta.cwd = obj.cwd;
|
|
92
|
+
if (typeof obj.gitBranch === 'string' && !meta.gitBranch) meta.gitBranch = obj.gitBranch;
|
|
93
|
+
if (typeof obj.version === 'string' && !meta.version) meta.version = obj.version;
|
|
94
|
+
if (obj.type === 'custom-title' && typeof obj.customTitle === 'string') {
|
|
95
|
+
meta.customTitle = obj.customTitle;
|
|
96
|
+
}
|
|
97
|
+
const ts = typeof obj.timestamp === 'string' ? obj.timestamp : null;
|
|
98
|
+
if (ts) {
|
|
99
|
+
if (!meta.firstAt) meta.firstAt = ts;
|
|
100
|
+
meta.lastAt = ts;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function deriveAutoTitle(messages: Message[]): string {
|
|
105
|
+
for (const m of messages) {
|
|
106
|
+
if (m.type !== 'user' || m.isMeta) continue;
|
|
107
|
+
for (const block of m.blocks) {
|
|
108
|
+
if (block.type !== 'text') continue;
|
|
109
|
+
const usable = pickTitleText(block.text);
|
|
110
|
+
if (!usable) continue;
|
|
111
|
+
const line = usable.trim().split('\n')[0] ?? '';
|
|
112
|
+
if (!line) continue;
|
|
113
|
+
return line.length > 80 ? line.slice(0, 80) + '…' : line;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return '(untitled)';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function buildMessage(obj: Record<string, unknown>): Message | null {
|
|
120
|
+
const type = obj.type === 'user' ? 'user' : 'assistant';
|
|
121
|
+
const message = (obj.message ?? {}) as { content?: unknown; model?: unknown };
|
|
122
|
+
const blocks = parseContent(message.content);
|
|
123
|
+
|
|
124
|
+
let isMeta = false;
|
|
125
|
+
if (type === 'user' && blocks.length === 1 && blocks[0]!.type === 'text') {
|
|
126
|
+
if (SYSTEM_TAG_RE.test(blocks[0]!.text)) isMeta = true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
uuid: typeof obj.uuid === 'string' ? obj.uuid : '',
|
|
131
|
+
parentUuid: typeof obj.parentUuid === 'string' ? obj.parentUuid : null,
|
|
132
|
+
type,
|
|
133
|
+
ts: typeof obj.timestamp === 'string' ? obj.timestamp : null,
|
|
134
|
+
model: typeof message.model === 'string' ? message.model : null,
|
|
135
|
+
blocks,
|
|
136
|
+
isMeta,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function parseContent(content: unknown): Block[] {
|
|
141
|
+
if (typeof content === 'string') {
|
|
142
|
+
return [{ type: 'text', text: content }];
|
|
143
|
+
}
|
|
144
|
+
if (!Array.isArray(content)) return [];
|
|
145
|
+
|
|
146
|
+
const out: Block[] = [];
|
|
147
|
+
for (const raw of content) {
|
|
148
|
+
if (!raw || typeof raw !== 'object') continue;
|
|
149
|
+
const b = raw as Record<string, unknown>;
|
|
150
|
+
switch (b.type) {
|
|
151
|
+
case 'text':
|
|
152
|
+
out.push({ type: 'text', text: typeof b.text === 'string' ? b.text : '' });
|
|
153
|
+
break;
|
|
154
|
+
case 'tool_use':
|
|
155
|
+
out.push({
|
|
156
|
+
type: 'tool_use',
|
|
157
|
+
id: typeof b.id === 'string' ? b.id : '',
|
|
158
|
+
name: typeof b.name === 'string' ? b.name : '(unknown)',
|
|
159
|
+
input: b.input ?? null,
|
|
160
|
+
});
|
|
161
|
+
break;
|
|
162
|
+
case 'tool_result':
|
|
163
|
+
out.push({
|
|
164
|
+
type: 'tool_result',
|
|
165
|
+
toolUseId: typeof b.tool_use_id === 'string' ? b.tool_use_id : '',
|
|
166
|
+
content: stringifyToolResult(b.content),
|
|
167
|
+
isError: b.is_error === true,
|
|
168
|
+
});
|
|
169
|
+
break;
|
|
170
|
+
case 'thinking':
|
|
171
|
+
out.push({
|
|
172
|
+
type: 'thinking',
|
|
173
|
+
text: typeof b.thinking === 'string' ? b.thinking : '',
|
|
174
|
+
});
|
|
175
|
+
break;
|
|
176
|
+
case 'image': {
|
|
177
|
+
const src = b.source as { media_type?: unknown } | undefined;
|
|
178
|
+
out.push({
|
|
179
|
+
type: 'image',
|
|
180
|
+
mediaType: typeof src?.media_type === 'string' ? src.media_type : null,
|
|
181
|
+
});
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
default:
|
|
185
|
+
out.push({ type: 'unknown', raw: b });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function stringifyToolResult(content: unknown): string {
|
|
192
|
+
if (typeof content === 'string') return content;
|
|
193
|
+
if (Array.isArray(content)) {
|
|
194
|
+
return content
|
|
195
|
+
.map((b) => {
|
|
196
|
+
if (b && typeof b === 'object' && (b as { type?: unknown }).type === 'text') {
|
|
197
|
+
const t = (b as { text?: unknown }).text;
|
|
198
|
+
return typeof t === 'string' ? t : '';
|
|
199
|
+
}
|
|
200
|
+
if (b && typeof b === 'object' && (b as { type?: unknown }).type === 'image') {
|
|
201
|
+
return '[image]';
|
|
202
|
+
}
|
|
203
|
+
return '';
|
|
204
|
+
})
|
|
205
|
+
.filter(Boolean)
|
|
206
|
+
.join('\n');
|
|
207
|
+
}
|
|
208
|
+
return '';
|
|
209
|
+
}
|