@zzusp/ccsm 1.0.0
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 -0
- package/README.md +232 -0
- package/bin/cli.mjs +52 -0
- package/dist/assets/DiskUsage-Bq4VaoUA.js +2 -0
- package/dist/assets/DiskUsage-Bq4VaoUA.js.map +1 -0
- package/dist/assets/ImportPage-b8NORa8b.js +2 -0
- package/dist/assets/ImportPage-b8NORa8b.js.map +1 -0
- package/dist/assets/ProjectMemory-aSV8UzQ9.js +2 -0
- package/dist/assets/ProjectMemory-aSV8UzQ9.js.map +1 -0
- package/dist/assets/charts-A5eNHLjX.js +56 -0
- package/dist/assets/charts-A5eNHLjX.js.map +1 -0
- package/dist/assets/geist-mono-cyrillic-wght-normal-BZdD_g9V.woff2 +0 -0
- package/dist/assets/geist-mono-latin-ext-wght-normal-b6lpi8_2.woff2 +0 -0
- package/dist/assets/geist-mono-latin-wght-normal-Cjtb1TV-.woff2 +0 -0
- package/dist/assets/index-DLATR3tZ.js +5 -0
- package/dist/assets/index-DLATR3tZ.js.map +1 -0
- package/dist/assets/index-DLDtbkux.css +1 -0
- package/dist/assets/plus-jakarta-sans-latin-ext-wght-italic-DJWiFoht.woff2 +0 -0
- package/dist/assets/plus-jakarta-sans-latin-ext-wght-normal-DmpS2jIq.woff2 +0 -0
- package/dist/assets/plus-jakarta-sans-latin-wght-italic-DnD1KgkH.woff2 +0 -0
- package/dist/assets/plus-jakarta-sans-latin-wght-normal-eXO_dkmS.woff2 +0 -0
- package/dist/assets/plus-jakarta-sans-vietnamese-wght-italic-CPBsCcxN.woff2 +0 -0
- package/dist/assets/plus-jakarta-sans-vietnamese-wght-normal-qRpaaN48.woff2 +0 -0
- package/dist/assets/query-C1K1uQRu.js +2 -0
- package/dist/assets/query-C1K1uQRu.js.map +1 -0
- package/dist/assets/react-W0jzChlo.js +50 -0
- package/dist/assets/react-W0jzChlo.js.map +1 -0
- package/dist/assets/router-DfbutHY3.js +13 -0
- package/dist/assets/router-DfbutHY3.js.map +1 -0
- package/dist/assets/vendor-CH80ylbS.js +19 -0
- package/dist/assets/vendor-CH80ylbS.js.map +1 -0
- package/dist/favicon.svg +7 -0
- package/dist/index.html +30 -0
- package/package.json +72 -0
- package/server/index.ts +126 -0
- package/server/lib/active-sessions.ts +95 -0
- package/server/lib/bundle.ts +86 -0
- package/server/lib/claude-paths.ts +36 -0
- package/server/lib/constants.ts +7 -0
- package/server/lib/delete-project.ts +100 -0
- package/server/lib/delete.ts +203 -0
- package/server/lib/disk-usage.ts +83 -0
- package/server/lib/encode-cwd.ts +24 -0
- package/server/lib/export-bundle.ts +236 -0
- package/server/lib/fs-size.ts +38 -0
- package/server/lib/import-bundle.ts +488 -0
- package/server/lib/load-memory.ts +120 -0
- package/server/lib/load-session.ts +209 -0
- package/server/lib/open-folder.ts +40 -0
- package/server/lib/parse-jsonl.ts +107 -0
- package/server/lib/port.ts +23 -0
- package/server/lib/rename-session.ts +0 -0
- package/server/lib/safe-id.ts +6 -0
- package/server/lib/scan.ts +183 -0
- package/server/lib/search-all.ts +130 -0
- package/server/lib/search-session.ts +203 -0
- package/server/lib/system-tags.ts +20 -0
- package/server/routes/disk.ts +9 -0
- package/server/routes/import.ts +87 -0
- package/server/routes/projects.ts +104 -0
- package/server/routes/search.ts +79 -0
- package/server/routes/sessions.ts +81 -0
- package/server/types.ts +1 -0
- package/shared/constants.ts +2 -0
- package/shared/types.ts +359 -0
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
|
|
4
|
+
export interface OpenFolderResult {
|
|
5
|
+
ok: boolean;
|
|
6
|
+
error?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function openFolder(folderPath: string): OpenFolderResult {
|
|
10
|
+
try {
|
|
11
|
+
const st = fs.statSync(folderPath);
|
|
12
|
+
if (!st.isDirectory()) return { ok: false, error: 'not a directory' };
|
|
13
|
+
} catch {
|
|
14
|
+
return { ok: false, error: 'path not found' };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let cmd: string;
|
|
18
|
+
let args: string[];
|
|
19
|
+
if (process.platform === 'win32') {
|
|
20
|
+
cmd = 'explorer.exe';
|
|
21
|
+
args = [folderPath];
|
|
22
|
+
} else if (process.platform === 'darwin') {
|
|
23
|
+
cmd = 'open';
|
|
24
|
+
args = [folderPath];
|
|
25
|
+
} else {
|
|
26
|
+
cmd = 'xdg-open';
|
|
27
|
+
args = [folderPath];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
|
|
32
|
+
child.on('error', (err) => {
|
|
33
|
+
console.error(`[open-folder] spawn ${cmd} failed:`, err);
|
|
34
|
+
});
|
|
35
|
+
child.unref();
|
|
36
|
+
return { ok: true };
|
|
37
|
+
} catch (err) {
|
|
38
|
+
return { ok: false, error: (err as Error).message };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import readline from 'node:readline';
|
|
3
|
+
import { SYSTEM_TAG_RE, pickTitleText } from './system-tags.ts';
|
|
4
|
+
|
|
5
|
+
export interface JsonlMeta {
|
|
6
|
+
title: string;
|
|
7
|
+
/** Latest `custom-title` record value, or null if never renamed. */
|
|
8
|
+
customTitle: string | null;
|
|
9
|
+
firstAt: string | null;
|
|
10
|
+
lastAt: string | null;
|
|
11
|
+
messageCount: number;
|
|
12
|
+
cwdFromMessages: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function parseJsonlMeta(filePath: string): Promise<JsonlMeta> {
|
|
16
|
+
let firstUserTitle = '';
|
|
17
|
+
let aiTitle: string | null = null;
|
|
18
|
+
let customTitle: string | null = null;
|
|
19
|
+
let firstAt: string | null = null;
|
|
20
|
+
let lastAt: string | null = null;
|
|
21
|
+
let messageCount = 0;
|
|
22
|
+
let cwdFromMessages: string | null = null;
|
|
23
|
+
|
|
24
|
+
const rl = readline.createInterface({
|
|
25
|
+
input: fs.createReadStream(filePath, { encoding: 'utf8' }),
|
|
26
|
+
crlfDelay: Infinity,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
for await (const raw of rl) {
|
|
30
|
+
const line = raw.trim();
|
|
31
|
+
if (!line) continue;
|
|
32
|
+
let obj: Record<string, unknown>;
|
|
33
|
+
try {
|
|
34
|
+
obj = JSON.parse(line) as Record<string, unknown>;
|
|
35
|
+
} catch {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ts = typeof obj.timestamp === 'string' ? obj.timestamp : null;
|
|
40
|
+
if (ts) {
|
|
41
|
+
if (!firstAt) firstAt = ts;
|
|
42
|
+
lastAt = ts;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (obj.cwd && typeof obj.cwd === 'string' && !cwdFromMessages) {
|
|
46
|
+
cwdFromMessages = obj.cwd;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (obj.type === 'custom-title' && typeof obj.customTitle === 'string') {
|
|
50
|
+
customTitle = obj.customTitle;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Claude Code rewrites this record every turn; the latest copy is canonical.
|
|
54
|
+
if (obj.type === 'ai-title' && typeof obj.aiTitle === 'string') {
|
|
55
|
+
aiTitle = obj.aiTitle;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (obj.type === 'user' || obj.type === 'assistant') {
|
|
59
|
+
messageCount += 1;
|
|
60
|
+
|
|
61
|
+
if (!firstUserTitle && obj.type === 'user') {
|
|
62
|
+
const msg = obj.message as { content?: unknown } | undefined;
|
|
63
|
+
const candidate = extractUserText(msg?.content);
|
|
64
|
+
if (candidate && !SYSTEM_TAG_RE.test(candidate)) {
|
|
65
|
+
const usable = pickTitleText(candidate);
|
|
66
|
+
if (usable) {
|
|
67
|
+
firstUserTitle = usable.slice(0, 80).replace(/\s+/g, ' ').trim();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// `claude code resume` keys off file mtime, which advances even when Claude Code
|
|
75
|
+
// rewrites untimestamped meta records (ai-title rotate, custom-title/agent-name on
|
|
76
|
+
// rename, last-prompt, permission-mode). Reconcile so the UI agrees with resume.
|
|
77
|
+
const mtimeIso = fs.statSync(filePath).mtime.toISOString();
|
|
78
|
+
const reconciledLastAt = !lastAt || mtimeIso > lastAt ? mtimeIso : lastAt;
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
title: aiTitle || firstUserTitle || '(untitled)',
|
|
82
|
+
customTitle,
|
|
83
|
+
firstAt,
|
|
84
|
+
lastAt: reconciledLastAt,
|
|
85
|
+
messageCount,
|
|
86
|
+
cwdFromMessages,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function extractUserText(content: unknown): string {
|
|
91
|
+
if (typeof content === 'string') return content;
|
|
92
|
+
if (Array.isArray(content)) {
|
|
93
|
+
for (const block of content) {
|
|
94
|
+
if (
|
|
95
|
+
block &&
|
|
96
|
+
typeof block === 'object' &&
|
|
97
|
+
'type' in block &&
|
|
98
|
+
block.type === 'text' &&
|
|
99
|
+
'text' in block &&
|
|
100
|
+
typeof (block as { text: unknown }).text === 'string'
|
|
101
|
+
) {
|
|
102
|
+
return (block as { text: string }).text;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return '';
|
|
107
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createServer } from 'node:net';
|
|
2
|
+
|
|
3
|
+
export async function findAvailablePort(
|
|
4
|
+
start: number,
|
|
5
|
+
end: number,
|
|
6
|
+
host = '127.0.0.1',
|
|
7
|
+
): Promise<number> {
|
|
8
|
+
for (let port = start; port <= end; port++) {
|
|
9
|
+
if (await isPortFree(port, host)) return port;
|
|
10
|
+
}
|
|
11
|
+
throw new Error(`No free port in range ${start}..${end} on ${host}`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isPortFree(port: number, host: string): Promise<boolean> {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
const server = createServer();
|
|
17
|
+
server.once('error', () => resolve(false));
|
|
18
|
+
server.once('listening', () => {
|
|
19
|
+
server.close(() => resolve(true));
|
|
20
|
+
});
|
|
21
|
+
server.listen(port, host);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { PATHS } from './claude-paths.ts';
|
|
4
|
+
import { RECENT_ACTIVITY_WINDOW_MS } from './constants.ts';
|
|
5
|
+
import { decodeCwd } from './encode-cwd.ts';
|
|
6
|
+
import { dirSize, fileSize } from './fs-size.ts';
|
|
7
|
+
import { parseJsonlMeta } from './parse-jsonl.ts';
|
|
8
|
+
import { buildActiveSessionMap } from './active-sessions.ts';
|
|
9
|
+
import type { ProjectSummary, RelatedBytes, SessionSummary } from '../types.ts';
|
|
10
|
+
|
|
11
|
+
const JSONL_EXT = '.jsonl';
|
|
12
|
+
|
|
13
|
+
function listSessionIdsInProject(projectDir: string): string[] {
|
|
14
|
+
if (!fs.existsSync(projectDir)) return [];
|
|
15
|
+
const ids: string[] = [];
|
|
16
|
+
for (const ent of fs.readdirSync(projectDir, { withFileTypes: true })) {
|
|
17
|
+
if (ent.isFile() && ent.name.endsWith(JSONL_EXT)) {
|
|
18
|
+
ids.push(ent.name.slice(0, -JSONL_EXT.length));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return ids;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function decodeProjectId(encoded: string, sampleCwd: string | null): {
|
|
25
|
+
decoded: string;
|
|
26
|
+
resolved: boolean;
|
|
27
|
+
} {
|
|
28
|
+
if (sampleCwd) return { decoded: sampleCwd, resolved: true };
|
|
29
|
+
const decoded = decodeCwd(encoded);
|
|
30
|
+
let resolved = false;
|
|
31
|
+
try {
|
|
32
|
+
resolved = fs.statSync(decoded).isDirectory();
|
|
33
|
+
} catch {
|
|
34
|
+
resolved = false;
|
|
35
|
+
}
|
|
36
|
+
return { decoded, resolved };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function resolveProjectCwd(
|
|
40
|
+
projectId: string,
|
|
41
|
+
): Promise<{ decoded: string; resolved: boolean } | null> {
|
|
42
|
+
const projectDir = path.join(PATHS.projects, projectId);
|
|
43
|
+
if (!fs.existsSync(projectDir)) return null;
|
|
44
|
+
|
|
45
|
+
const sessionIds = listSessionIdsInProject(projectDir);
|
|
46
|
+
let sampleCwd: string | null = null;
|
|
47
|
+
for (const id of sessionIds) {
|
|
48
|
+
const jsonlPath = path.join(projectDir, `${id}${JSONL_EXT}`);
|
|
49
|
+
if (!fs.existsSync(jsonlPath)) continue;
|
|
50
|
+
const meta = await parseJsonlMeta(jsonlPath);
|
|
51
|
+
if (meta.cwdFromMessages) {
|
|
52
|
+
sampleCwd = meta.cwdFromMessages;
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return decodeProjectId(projectId, sampleCwd);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function listProjects(): Promise<ProjectSummary[]> {
|
|
60
|
+
if (!fs.existsSync(PATHS.projects)) return [];
|
|
61
|
+
const result: ProjectSummary[] = [];
|
|
62
|
+
|
|
63
|
+
for (const ent of fs.readdirSync(PATHS.projects, { withFileTypes: true })) {
|
|
64
|
+
if (!ent.isDirectory()) continue;
|
|
65
|
+
const projectId = ent.name;
|
|
66
|
+
const projectDir = path.join(PATHS.projects, projectId);
|
|
67
|
+
|
|
68
|
+
const sessionIds = listSessionIdsInProject(projectDir);
|
|
69
|
+
let sampleCwd: string | null = null;
|
|
70
|
+
let totalBytes = 0;
|
|
71
|
+
let lastActiveAt: string | null = null;
|
|
72
|
+
|
|
73
|
+
for (const id of sessionIds) {
|
|
74
|
+
const jsonlPath = path.join(projectDir, `${id}${JSONL_EXT}`);
|
|
75
|
+
const subdirPath = path.join(projectDir, id);
|
|
76
|
+
totalBytes += fileSize(jsonlPath);
|
|
77
|
+
totalBytes += dirSize(subdirPath);
|
|
78
|
+
totalBytes += dirSize(path.join(PATHS.fileHistory, id));
|
|
79
|
+
totalBytes += dirSize(path.join(PATHS.sessionEnv, id));
|
|
80
|
+
|
|
81
|
+
if (!sampleCwd && fs.existsSync(jsonlPath)) {
|
|
82
|
+
const meta = await parseJsonlMeta(jsonlPath);
|
|
83
|
+
sampleCwd = meta.cwdFromMessages;
|
|
84
|
+
if (meta.lastAt && (!lastActiveAt || meta.lastAt > lastActiveAt)) {
|
|
85
|
+
lastActiveAt = meta.lastAt;
|
|
86
|
+
}
|
|
87
|
+
} else if (fs.existsSync(jsonlPath)) {
|
|
88
|
+
try {
|
|
89
|
+
const mtime = fs.statSync(jsonlPath).mtime.toISOString();
|
|
90
|
+
if (!lastActiveAt || mtime > lastActiveAt) lastActiveAt = mtime;
|
|
91
|
+
} catch {
|
|
92
|
+
// ignore
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const { decoded, resolved } = decodeProjectId(projectId, sampleCwd);
|
|
98
|
+
|
|
99
|
+
result.push({
|
|
100
|
+
id: projectId,
|
|
101
|
+
encodedCwd: projectId,
|
|
102
|
+
decodedCwd: decoded,
|
|
103
|
+
cwdResolved: resolved,
|
|
104
|
+
sessionCount: sessionIds.length,
|
|
105
|
+
totalBytes,
|
|
106
|
+
lastActiveAt,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
result.sort((a, b) => {
|
|
111
|
+
const at = a.lastActiveAt ?? '';
|
|
112
|
+
const bt = b.lastActiveAt ?? '';
|
|
113
|
+
return bt.localeCompare(at);
|
|
114
|
+
});
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function listSessionsForProject(projectId: string): Promise<SessionSummary[]> {
|
|
119
|
+
const projectDir = path.join(PATHS.projects, projectId);
|
|
120
|
+
if (!fs.existsSync(projectDir)) return [];
|
|
121
|
+
|
|
122
|
+
const activeMap = buildActiveSessionMap();
|
|
123
|
+
const ids = listSessionIdsInProject(projectDir);
|
|
124
|
+
const out: SessionSummary[] = [];
|
|
125
|
+
|
|
126
|
+
for (const id of ids) {
|
|
127
|
+
const jsonlPath = path.join(projectDir, `${id}${JSONL_EXT}`);
|
|
128
|
+
const subdirPath = path.join(projectDir, id);
|
|
129
|
+
const fhPath = path.join(PATHS.fileHistory, id);
|
|
130
|
+
const sePath = path.join(PATHS.sessionEnv, id);
|
|
131
|
+
|
|
132
|
+
const related: RelatedBytes = {
|
|
133
|
+
jsonl: fileSize(jsonlPath),
|
|
134
|
+
subdir: dirSize(subdirPath),
|
|
135
|
+
fileHistory: dirSize(fhPath),
|
|
136
|
+
sessionEnv: dirSize(sePath),
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
let title = '(no jsonl)';
|
|
140
|
+
let customTitle: string | null = null;
|
|
141
|
+
let firstAt: string | null = null;
|
|
142
|
+
let lastAt: string | null = null;
|
|
143
|
+
let messageCount = 0;
|
|
144
|
+
|
|
145
|
+
if (fs.existsSync(jsonlPath)) {
|
|
146
|
+
const meta = await parseJsonlMeta(jsonlPath);
|
|
147
|
+
title = meta.title;
|
|
148
|
+
customTitle = meta.customTitle;
|
|
149
|
+
firstAt = meta.firstAt;
|
|
150
|
+
lastAt = meta.lastAt;
|
|
151
|
+
messageCount = meta.messageCount;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const livePid = activeMap.get(id) ?? null;
|
|
155
|
+
let isRecentlyActive = false;
|
|
156
|
+
if (fs.existsSync(jsonlPath)) {
|
|
157
|
+
try {
|
|
158
|
+
const mtimeMs = fs.statSync(jsonlPath).mtimeMs;
|
|
159
|
+
isRecentlyActive = Date.now() - mtimeMs < RECENT_ACTIVITY_WINDOW_MS;
|
|
160
|
+
} catch {
|
|
161
|
+
// ignore
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
out.push({
|
|
166
|
+
id,
|
|
167
|
+
projectId,
|
|
168
|
+
title,
|
|
169
|
+
customTitle,
|
|
170
|
+
firstAt,
|
|
171
|
+
lastAt,
|
|
172
|
+
messageCount,
|
|
173
|
+
bytes: related.jsonl,
|
|
174
|
+
relatedBytes: related,
|
|
175
|
+
isLivePid: livePid !== null,
|
|
176
|
+
isRecentlyActive,
|
|
177
|
+
livePid,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
out.sort((a, b) => (b.lastAt ?? '').localeCompare(a.lastAt ?? ''));
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { PATHS, isUnderClaudeRoot } from './claude-paths.ts';
|
|
4
|
+
import { decodeCwd } from './encode-cwd.ts';
|
|
5
|
+
import { isSafeId } from './safe-id.ts';
|
|
6
|
+
import { parseJsonlMeta } from './parse-jsonl.ts';
|
|
7
|
+
import { searchSessionFile, type SearchSessionOpts } from './search-session.ts';
|
|
8
|
+
import type { SearchBlockKind, SearchEvent, SearchSessionHit } from '../types.ts';
|
|
9
|
+
|
|
10
|
+
const JSONL_EXT = '.jsonl';
|
|
11
|
+
|
|
12
|
+
export interface SearchAllOpts {
|
|
13
|
+
query: string;
|
|
14
|
+
include: ReadonlySet<SearchBlockKind>;
|
|
15
|
+
perSession: number;
|
|
16
|
+
maxSessions: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface SessionFileEntry {
|
|
20
|
+
projectId: string;
|
|
21
|
+
sessionId: string;
|
|
22
|
+
filePath: string;
|
|
23
|
+
mtimeMs: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function* searchAll(opts: SearchAllOpts): AsyncGenerator<SearchEvent> {
|
|
27
|
+
const startedAt = Date.now();
|
|
28
|
+
const files = enumerateSessionFiles();
|
|
29
|
+
files.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
30
|
+
|
|
31
|
+
const sessionOpts: SearchSessionOpts = {
|
|
32
|
+
include: opts.include,
|
|
33
|
+
perSession: opts.perSession,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
let scanned = 0;
|
|
37
|
+
let matched = 0;
|
|
38
|
+
let truncated = false;
|
|
39
|
+
|
|
40
|
+
for (const entry of files) {
|
|
41
|
+
if (matched >= opts.maxSessions) {
|
|
42
|
+
truncated = true;
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
scanned++;
|
|
46
|
+
|
|
47
|
+
let result;
|
|
48
|
+
try {
|
|
49
|
+
result = await searchSessionFile(entry.filePath, opts.query, sessionOpts);
|
|
50
|
+
} catch {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (result.snippets.length === 0) continue;
|
|
54
|
+
|
|
55
|
+
let meta;
|
|
56
|
+
try {
|
|
57
|
+
meta = await parseJsonlMeta(entry.filePath);
|
|
58
|
+
} catch {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
matched++;
|
|
63
|
+
const decodedCwd = meta.cwdFromMessages ?? decodeCwd(entry.projectId);
|
|
64
|
+
const hit: SearchSessionHit = {
|
|
65
|
+
type: 'session',
|
|
66
|
+
projectId: entry.projectId,
|
|
67
|
+
sessionId: entry.sessionId,
|
|
68
|
+
projectDecodedCwd: decodedCwd,
|
|
69
|
+
title: meta.title,
|
|
70
|
+
customTitle: meta.customTitle,
|
|
71
|
+
lastAt: meta.lastAt,
|
|
72
|
+
hasMore: result.hasMore,
|
|
73
|
+
snippets: result.snippets,
|
|
74
|
+
};
|
|
75
|
+
yield hit;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
yield {
|
|
79
|
+
type: 'done',
|
|
80
|
+
scanned,
|
|
81
|
+
matched,
|
|
82
|
+
durationMs: Date.now() - startedAt,
|
|
83
|
+
truncated,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function enumerateSessionFiles(): SessionFileEntry[] {
|
|
88
|
+
if (!fs.existsSync(PATHS.projects)) return [];
|
|
89
|
+
const out: SessionFileEntry[] = [];
|
|
90
|
+
|
|
91
|
+
let projects: fs.Dirent[];
|
|
92
|
+
try {
|
|
93
|
+
projects = fs.readdirSync(PATHS.projects, { withFileTypes: true });
|
|
94
|
+
} catch {
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const projEnt of projects) {
|
|
99
|
+
if (!projEnt.isDirectory()) continue;
|
|
100
|
+
if (!isSafeId(projEnt.name)) continue;
|
|
101
|
+
const projectId = projEnt.name;
|
|
102
|
+
const projectDir = path.join(PATHS.projects, projectId);
|
|
103
|
+
if (!isUnderClaudeRoot(projectDir)) continue;
|
|
104
|
+
|
|
105
|
+
let files: fs.Dirent[];
|
|
106
|
+
try {
|
|
107
|
+
files = fs.readdirSync(projectDir, { withFileTypes: true });
|
|
108
|
+
} catch {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const fileEnt of files) {
|
|
113
|
+
if (!fileEnt.isFile() || !fileEnt.name.endsWith(JSONL_EXT)) continue;
|
|
114
|
+
const sessionId = fileEnt.name.slice(0, -JSONL_EXT.length);
|
|
115
|
+
if (!isSafeId(sessionId)) continue;
|
|
116
|
+
const filePath = path.join(projectDir, fileEnt.name);
|
|
117
|
+
if (!isUnderClaudeRoot(filePath)) continue;
|
|
118
|
+
|
|
119
|
+
let mtimeMs = 0;
|
|
120
|
+
try {
|
|
121
|
+
mtimeMs = fs.statSync(filePath).mtimeMs;
|
|
122
|
+
} catch {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
out.push({ projectId, sessionId, filePath, mtimeMs });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return out;
|
|
130
|
+
}
|