coding-tool-x 3.7.13 → 3.8.1

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.
@@ -2,15 +2,9 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
  const crypto = require('crypto');
4
4
  const { getAllSessions, parseSessionInfoFast } = require('../../utils/session');
5
- const { loadAliases } = require('./alias');
6
- const {
7
- getCachedProjects,
8
- setCachedProjects,
9
- invalidateProjectsCache,
10
- checkHasMessagesCache,
11
- rememberHasMessages
12
- } = require('./session-cache');
5
+ const { loadAliases, setAlias } = require('./alias');
13
6
  const { globalCache, CacheKeys } = require('./enhanced-cache');
7
+ const { listProjects: idxListProjects, listSessions: idxListSessions, getRecentSessions: idxGetRecent, searchSessions: idxSearch } = require('./session-history-index');
14
8
  const { PATHS, NATIVE_PATHS } = require('../../config/paths');
15
9
 
16
10
  const CLAUDE_PROJECTS_DIR = NATIVE_PATHS.claude.projects;
@@ -22,78 +16,37 @@ function resolveProjectsDir(config = {}) {
22
16
  }
23
17
 
24
18
  function withResolvedProjectsDir(config = {}) {
25
- return {
26
- ...config,
27
- projectsDir: resolveProjectsDir(config)
28
- };
29
- }
30
-
31
- // Base directory for cc-tool data
32
- function getCcToolDir() {
33
- return PATHS.base;
19
+ return { ...config, projectsDir: resolveProjectsDir(config) };
34
20
  }
35
21
 
36
- // Get path for storing project order
37
- function getOrderFilePath() {
38
- return PATHS.projectOrder;
39
- }
40
-
41
- // Get path for storing fork relations
42
- function getForkRelationsFilePath() {
43
- return PATHS.forkRelations;
44
- }
22
+ function getCcToolDir() { return PATHS.base; }
23
+ function getOrderFilePath() { return PATHS.projectOrder; }
24
+ function getForkRelationsFilePath() { return PATHS.forkRelations; }
25
+ function getSessionOrderFilePath() { return PATHS.sessionOrder; }
45
26
 
46
- // Get path for storing session order
47
- function getSessionOrderFilePath() {
48
- return PATHS.sessionOrder;
49
- }
50
-
51
- // Get saved project order
52
27
  function getProjectOrder(config) {
53
28
  const orderFile = getOrderFilePath();
54
- try {
55
- if (fs.existsSync(orderFile)) {
56
- const data = fs.readFileSync(orderFile, 'utf8');
57
- return JSON.parse(data);
58
- }
59
- } catch (err) {
60
- // Ignore errors
61
- }
29
+ try { if (fs.existsSync(orderFile)) return JSON.parse(fs.readFileSync(orderFile, 'utf8')); } catch (_) {}
62
30
  return [];
63
31
  }
64
32
 
65
- // Save project order
66
33
  function saveProjectOrder(config, order) {
67
34
  const orderFile = getOrderFilePath();
68
35
  const dir = path.dirname(orderFile);
69
- if (!fs.existsSync(dir)) {
70
- fs.mkdirSync(dir, { recursive: true });
71
- }
36
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
72
37
  fs.writeFileSync(orderFile, JSON.stringify(order, null, 2), 'utf8');
73
- invalidateProjectsCache(config);
74
38
  }
75
39
 
76
- // Get fork relations
77
40
  function getForkRelations() {
78
41
  const relationsFile = getForkRelationsFilePath();
79
- try {
80
- if (fs.existsSync(relationsFile)) {
81
- const data = fs.readFileSync(relationsFile, 'utf8');
82
- return JSON.parse(data);
83
- }
84
- } catch (err) {
85
- // Ignore errors
86
- }
42
+ try { if (fs.existsSync(relationsFile)) return JSON.parse(fs.readFileSync(relationsFile, 'utf8')); } catch (_) {}
87
43
  return {};
88
44
  }
89
45
 
90
- // Save fork relations
91
46
  function saveForkRelations(relations) {
92
47
  const relationsFile = getForkRelationsFilePath();
93
48
  const dir = path.dirname(relationsFile);
94
- if (!fs.existsSync(dir)) {
95
- fs.mkdirSync(dir, { recursive: true });
96
- }
49
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
97
50
  fs.writeFileSync(relationsFile, JSON.stringify(relations, null, 2), 'utf8');
98
51
  }
99
52
 
@@ -103,34 +56,20 @@ function invalidateSessionResultCache(projectName) {
103
56
  }
104
57
 
105
58
  function getClaudeForkMessageText(content) {
106
- if (typeof content === 'string') {
107
- return content === 'Warmup' ? '' : content;
108
- }
109
-
110
- if (!Array.isArray(content)) {
111
- return '';
112
- }
113
-
59
+ if (typeof content === 'string') return content === 'Warmup' ? '' : content;
60
+ if (!Array.isArray(content)) return '';
114
61
  const parts = [];
115
62
  for (const item of content) {
116
- if (item?.type === 'text' && item.text) {
117
- parts.push(item.text);
118
- } else if (item?.type === 'image') {
119
- parts.push('[图片]');
120
- }
63
+ if (item?.type === 'text' && item.text) parts.push(item.text);
64
+ else if (item?.type === 'image') parts.push('[图片]');
121
65
  }
122
-
123
66
  return parts.join('\n\n').trim();
124
67
  }
125
68
 
126
69
  function splitTextPreserveEol(content) {
127
70
  const eol = content.includes('\r\n') ? '\r\n' : '\n';
128
71
  const hasTrailingEol = content.endsWith('\r\n') || content.endsWith('\n');
129
- return {
130
- lines: content.split(/\r?\n/),
131
- eol,
132
- hasTrailingEol
133
- };
72
+ return { lines: content.split(/\r?\n/), eol, hasTrailingEol };
134
73
  }
135
74
 
136
75
  function joinTextPreserveEol(lines, eol, hasTrailingEol) {
@@ -139,134 +78,73 @@ function joinTextPreserveEol(lines, eol, hasTrailingEol) {
139
78
  }
140
79
 
141
80
  function sliceClaudeSessionContentByUserMessage(content, afterUserMessageNumber) {
142
- if (!Number.isInteger(afterUserMessageNumber) || afterUserMessageNumber <= 0) {
143
- return content;
144
- }
145
-
81
+ if (!Number.isInteger(afterUserMessageNumber) || afterUserMessageNumber <= 0) return content;
146
82
  const { lines, eol, hasTrailingEol } = splitTextPreserveEol(content);
147
83
  const keptLines = [];
148
84
  let matchedUserMessages = 0;
149
85
  let targetUserReached = false;
150
-
151
86
  for (const line of lines) {
152
- if (!line.trim()) {
153
- keptLines.push(line);
154
- continue;
155
- }
156
-
87
+ if (!line.trim()) { keptLines.push(line); continue; }
157
88
  let json;
158
- try {
159
- json = JSON.parse(line);
160
- } catch (err) {
161
- keptLines.push(line);
162
- continue;
163
- }
164
-
165
- if (json.type !== 'user') {
166
- keptLines.push(line);
167
- continue;
168
- }
169
-
89
+ try { json = JSON.parse(line); } catch (_) { keptLines.push(line); continue; }
90
+ if (json.type !== 'user') { keptLines.push(line); continue; }
170
91
  const userText = getClaudeForkMessageText(json.message?.content);
171
- if (!userText) {
172
- keptLines.push(line);
173
- continue;
174
- }
175
-
176
- if (targetUserReached) {
177
- return joinTextPreserveEol(keptLines, eol, hasTrailingEol);
178
- }
179
-
92
+ if (!userText) { keptLines.push(line); continue; }
93
+ if (targetUserReached) return joinTextPreserveEol(keptLines, eol, hasTrailingEol);
180
94
  matchedUserMessages += 1;
181
95
  keptLines.push(line);
182
- if (matchedUserMessages >= afterUserMessageNumber) {
183
- targetUserReached = true;
184
- }
96
+ if (matchedUserMessages >= afterUserMessageNumber) targetUserReached = true;
185
97
  }
186
-
187
- if (targetUserReached) {
188
- return joinTextPreserveEol(keptLines, eol, hasTrailingEol);
189
- }
190
-
98
+ if (targetUserReached) return joinTextPreserveEol(keptLines, eol, hasTrailingEol);
191
99
  throw new Error(`afterUserMessageNumber ${afterUserMessageNumber} exceeds available user messages (${matchedUserMessages})`);
192
100
  }
193
101
 
194
- // Get all projects with stats (async version)
195
- async function getProjects(config) {
196
- const projectsDir = resolveProjectsDir(config);
102
+ // ===== Read-side: all project/list/session/recent/search delegate to session-history-index =====
197
103
 
198
- if (!fs.existsSync(projectsDir)) {
199
- return [];
200
- }
104
+ async function getProjects(config) {
105
+ const indexed = await idxListProjects('claude');
106
+ return indexed.map(p => p.name);
107
+ }
201
108
 
202
- const entries = await fs.promises.readdir(projectsDir, { withFileTypes: true });
203
- return entries
204
- .filter(entry => entry.isDirectory())
205
- .map(entry => entry.name);
109
+ async function getProjectsWithStats(config, options = {}) {
110
+ return idxListProjects('claude');
206
111
  }
207
112
 
208
- // Parse real project path from encoded name
209
- // macOS/Linux: "-Users-lilithgames-work-project" -> "/Users/lilithgames/work/project"
210
- // Windows: "C--Users-admin-Desktop-project" -> "C:\Users\admin\Desktop\project"
113
+ // ===== Path resolution helpers (kept for mutation/conversion paths) =====
114
+
211
115
  function parseRealProjectPath(encodedName) {
212
116
  const isWindows = process.platform === 'win32';
213
117
  const fallbackFromSessions = tryResolvePathFromSessions(encodedName);
214
-
215
- // Detect Windows drive letter (e.g., "C--Users-admin")
216
118
  const windowsDriveMatch = encodedName.match(/^([A-Z])--(.+)$/);
217
119
 
218
120
  if (isWindows && windowsDriveMatch) {
219
- // Windows path with drive letter
220
121
  const driveLetter = windowsDriveMatch[1];
221
122
  const restPath = windowsDriveMatch[2];
222
-
223
- // Split by '-' to get segments
224
123
  const segments = restPath.split('-').filter(s => s);
225
-
226
- // Build path from left to right, checking existence
227
124
  let realSegments = [];
228
125
  let accumulated = '';
229
126
  let currentPath = '';
230
127
 
231
128
  for (let i = 0; i < segments.length; i++) {
232
- if (accumulated) {
233
- accumulated += '-' + segments[i];
234
- } else {
235
- accumulated = segments[i];
236
- }
237
-
129
+ if (accumulated) accumulated += '-' + segments[i];
130
+ else accumulated = segments[i];
238
131
  const testPath = driveLetter + ':\\' + realSegments.concat(accumulated).join('\\');
239
-
240
- // Check if this path exists
241
132
  let found = fs.existsSync(testPath);
242
133
  let finalAccumulated = accumulated;
243
-
244
- // If not found with dash, try with underscore
245
134
  if (!found && accumulated.includes('-')) {
246
135
  const withUnderscore = accumulated.replace(/-/g, '_');
247
136
  const testPathUnderscore = driveLetter + ':\\' + realSegments.concat(withUnderscore).join('\\');
248
- if (fs.existsSync(testPathUnderscore)) {
249
- finalAccumulated = withUnderscore;
250
- found = true;
251
- }
252
- }
253
-
254
- if (found) {
255
- realSegments.push(finalAccumulated);
256
- accumulated = '';
257
- currentPath = driveLetter + ':\\' + realSegments.join('\\');
137
+ if (fs.existsSync(testPathUnderscore)) { finalAccumulated = withUnderscore; found = true; }
258
138
  }
139
+ if (found) { realSegments.push(finalAccumulated); accumulated = ''; currentPath = driveLetter + ':\\' + realSegments.join('\\'); }
259
140
  }
260
141
 
261
- // If there's remaining accumulated segment, try underscore variant
262
142
  if (accumulated) {
263
143
  let finalAccumulated = accumulated;
264
144
  if (accumulated.includes('-')) {
265
145
  const withUnderscore = accumulated.replace(/-/g, '_');
266
146
  const testPath = driveLetter + ':\\' + realSegments.concat(withUnderscore).join('\\');
267
- if (fs.existsSync(testPath)) {
268
- finalAccumulated = withUnderscore;
269
- }
147
+ if (fs.existsSync(testPath)) finalAccumulated = withUnderscore;
270
148
  }
271
149
  realSegments.push(finalAccumulated);
272
150
  currentPath = driveLetter + ':\\' + realSegments.join('\\');
@@ -276,94 +154,60 @@ function parseRealProjectPath(encodedName) {
276
154
  fullPath: validateProjectPath(currentPath) || fallbackFromSessions?.fullPath || (driveLetter + ':\\' + restPath.replace(/-/g, '\\')),
277
155
  projectName: fallbackFromSessions?.projectName || realSegments[realSegments.length - 1] || encodedName
278
156
  };
279
- } else {
280
- // Unix-like path (macOS/Linux) or fallback
281
- const pathStr = encodedName.replace(/^-/, '/').replace(/-/g, '/');
282
- const segments = pathStr.split('/').filter(s => s);
283
-
284
- // Build path from left to right, checking existence
285
- let currentPath = '';
286
- const realSegments = [];
287
- let accumulated = '';
288
-
289
- for (let i = 0; i < segments.length; i++) {
290
- if (accumulated) {
291
- accumulated += '-' + segments[i];
292
- } else {
293
- accumulated = segments[i];
294
- }
295
-
296
- const testPath = '/' + realSegments.concat(accumulated).join('/');
297
-
298
- // Check if this path exists
299
- let found = fs.existsSync(testPath);
300
- let finalAccumulated = accumulated;
157
+ }
301
158
 
302
- // If not found with dash, try with underscore
303
- if (!found && accumulated.includes('-')) {
304
- const withUnderscore = accumulated.replace(/-/g, '_');
305
- const testPathUnderscore = '/' + realSegments.concat(withUnderscore).join('/');
306
- if (fs.existsSync(testPathUnderscore)) {
307
- finalAccumulated = withUnderscore;
308
- found = true;
309
- }
310
- }
159
+ const pathStr = encodedName.replace(/^-/, '/').replace(/-/g, '/');
160
+ const segments = pathStr.split('/').filter(s => s);
161
+ let currentPath = '';
162
+ const realSegments = [];
163
+ let accumulated = '';
311
164
 
312
- if (found) {
313
- realSegments.push(finalAccumulated);
314
- accumulated = '';
315
- currentPath = '/' + realSegments.join('/');
316
- }
165
+ for (let i = 0; i < segments.length; i++) {
166
+ if (accumulated) accumulated += '-' + segments[i];
167
+ else accumulated = segments[i];
168
+ const testPath = '/' + realSegments.concat(accumulated).join('/');
169
+ let found = fs.existsSync(testPath);
170
+ let finalAccumulated = accumulated;
171
+ if (!found && accumulated.includes('-')) {
172
+ const withUnderscore = accumulated.replace(/-/g, '_');
173
+ const testPathUnderscore = '/' + realSegments.concat(withUnderscore).join('/');
174
+ if (fs.existsSync(testPathUnderscore)) { finalAccumulated = withUnderscore; found = true; }
317
175
  }
176
+ if (found) { realSegments.push(finalAccumulated); accumulated = ''; currentPath = '/' + realSegments.join('/'); }
177
+ }
318
178
 
319
- // If there's remaining accumulated segment, try underscore variant
320
- if (accumulated) {
321
- let finalAccumulated = accumulated;
322
- if (accumulated.includes('-')) {
323
- const withUnderscore = accumulated.replace(/-/g, '_');
324
- const testPath = '/' + realSegments.concat(withUnderscore).join('/');
325
- if (fs.existsSync(testPath)) {
326
- finalAccumulated = withUnderscore;
327
- }
328
- }
329
- realSegments.push(finalAccumulated);
330
- currentPath = '/' + realSegments.join('/');
179
+ if (accumulated) {
180
+ let finalAccumulated = accumulated;
181
+ if (accumulated.includes('-')) {
182
+ const withUnderscore = accumulated.replace(/-/g, '_');
183
+ const testPath = '/' + realSegments.concat(withUnderscore).join('/');
184
+ if (fs.existsSync(testPath)) finalAccumulated = withUnderscore;
331
185
  }
332
-
333
- return {
334
- fullPath: validateProjectPath(currentPath) || fallbackFromSessions?.fullPath || pathStr,
335
- projectName: fallbackFromSessions?.projectName || realSegments[realSegments.length - 1] || encodedName
336
- };
186
+ realSegments.push(finalAccumulated);
187
+ currentPath = '/' + realSegments.join('/');
337
188
  }
189
+
190
+ return {
191
+ fullPath: validateProjectPath(currentPath) || fallbackFromSessions?.fullPath || pathStr,
192
+ projectName: fallbackFromSessions?.projectName || realSegments[realSegments.length - 1] || encodedName
193
+ };
338
194
  }
339
195
 
340
196
  function validateProjectPath(candidatePath) {
341
- if (candidatePath && fs.existsSync(candidatePath)) {
342
- return candidatePath;
343
- }
344
- return null;
197
+ return (candidatePath && fs.existsSync(candidatePath)) ? candidatePath : null;
345
198
  }
346
199
 
347
200
  function tryResolvePathFromSessions(encodedName) {
348
201
  try {
349
202
  const projectDir = path.join(CLAUDE_PROJECTS_DIR, encodedName);
350
- if (!fs.existsSync(projectDir)) {
351
- return null;
352
- }
203
+ if (!fs.existsSync(projectDir)) return null;
353
204
  const files = fs.readdirSync(projectDir).filter(f => f.endsWith('.jsonl'));
354
205
  for (const file of files) {
355
206
  const sessionFile = path.join(projectDir, file);
356
207
  const cwd = extractCwdFromSessionHeader(sessionFile);
357
- if (cwd && fs.existsSync(cwd)) {
358
- return {
359
- fullPath: cwd,
360
- projectName: path.basename(cwd)
361
- };
362
- }
208
+ if (cwd && fs.existsSync(cwd)) return { fullPath: cwd, projectName: path.basename(cwd) };
363
209
  }
364
- } catch (err) {
365
- // ignore fallback errors
366
- }
210
+ } catch (_) {}
367
211
  return null;
368
212
  }
369
213
 
@@ -374,637 +218,186 @@ function extractCwdFromSessionHeader(sessionFile) {
374
218
  const bytesRead = fs.readSync(fd, buffer, 0, 4096, 0);
375
219
  fs.closeSync(fd);
376
220
  const content = buffer.slice(0, bytesRead).toString('utf8');
377
- const lines = content.split('\n');
378
- for (const line of lines) {
221
+ for (const line of content.split('\n')) {
379
222
  if (!line.trim()) continue;
380
- try {
381
- const json = JSON.parse(line);
382
- if (json.cwd && typeof json.cwd === 'string') {
383
- return json.cwd;
384
- }
385
- } catch (e) {
386
- // ignore
387
- }
223
+ try { const json = JSON.parse(line); if (json.cwd && typeof json.cwd === 'string') return json.cwd; } catch (_) {}
388
224
  }
389
- } catch (err) {
390
- // ignore
391
- }
225
+ } catch (_) {}
392
226
  return null;
393
227
  }
394
228
 
395
- // Get projects with detailed stats (with caching) - async version
396
- async function getProjectsWithStats(config, options = {}) {
397
- const resolvedConfig = withResolvedProjectsDir(config);
398
- if (!options.force) {
399
- // Check enhanced cache first
400
- const cacheKey = `${CacheKeys.PROJECTS}${resolvedConfig.projectsDir}`;
401
- const enhancedCached = globalCache.get(cacheKey);
402
- if (enhancedCached) {
403
- return enhancedCached;
404
- }
405
-
406
- // Check old cache
407
- const cached = getCachedProjects(resolvedConfig);
408
- if (cached) {
409
- globalCache.set(cacheKey, cached, 300000); // 5分钟
410
- return cached;
411
- }
412
- }
229
+ // ===== Counts: delegates to index =====
413
230
 
231
+ async function getProjectAndSessionCounts(config) {
414
232
  try {
415
- const data = await buildProjectsWithStats(resolvedConfig);
416
- if (!Array.isArray(data)) {
417
- console.warn(`[getProjectsWithStats] Unexpected non-array result for ${resolvedConfig.projectsDir}, returning empty array.`);
418
- return [];
419
- }
420
- setCachedProjects(resolvedConfig, data);
421
- globalCache.set(`${CacheKeys.PROJECTS}${resolvedConfig.projectsDir}`, data, 300000);
422
- return data;
423
- } catch (err) {
424
- console.error(`[getProjectsWithStats] Failed to build projects for ${resolvedConfig.projectsDir}:`, err);
425
- return [];
426
- }
427
- }
428
-
429
- async function buildProjectsWithStats(config) {
430
- const projectsDir = resolveProjectsDir(config);
431
-
432
- if (!fs.existsSync(projectsDir)) {
433
- return [];
434
- }
435
-
436
- const entries = await fs.promises.readdir(projectsDir, { withFileTypes: true });
437
-
438
- // Process all projects concurrently
439
- const projectPromises = entries
440
- .filter(entry => entry.isDirectory())
441
- .map(async (entry) => {
442
- const projectName = entry.name;
443
- const projectPath = path.join(projectsDir, projectName);
444
-
445
- // Parse real project path
446
- const { fullPath, projectName: displayName } = parseRealProjectPath(projectName);
447
-
448
- // Get session files (only count sessions with actual messages)
449
- let sessionCount = 0;
450
- let lastUsed = null;
451
-
452
- try {
453
- const files = await fs.promises.readdir(projectPath);
454
- const jsonlFiles = files.filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-'));
455
-
456
- // Filter: only count sessions that have actual messages (in parallel)
457
- const sessionChecks = await Promise.all(
458
- jsonlFiles.map(async (f) => {
459
- const filePath = path.join(projectPath, f);
460
- const hasMessages = await hasActualMessages(filePath);
461
- return hasMessages ? f : null;
462
- })
463
- );
464
-
465
- const sessionFilesWithMessages = sessionChecks.filter(f => f !== null);
466
- sessionCount = sessionFilesWithMessages.length;
467
-
468
- // Find most recent session (only from sessions with messages)
469
- if (sessionFilesWithMessages.length > 0) {
470
- const statPromises = sessionFilesWithMessages.map(async (f) => {
471
- const filePath = path.join(projectPath, f);
472
- const stat = await fs.promises.stat(filePath);
473
- return stat.mtime.getTime();
474
- });
475
- const stats = await Promise.all(statPromises);
476
- lastUsed = Math.max(...stats);
477
- }
478
- } catch (err) {
479
- // Ignore errors
480
- }
481
-
482
- return {
483
- name: projectName, // Keep encoded name for API operations
484
- displayName, // Project name for display
485
- fullPath, // Real full path for display
486
- sessionCount,
487
- lastUsed
488
- };
489
- });
490
-
491
- const projects = await Promise.all(projectPromises);
492
- return projects.sort((a, b) => (b.lastUsed || 0) - (a.lastUsed || 0)); // Sort by last used
493
- }
494
-
495
- // 获取 Claude 项目/会话数量(轻量统计)
496
- function getProjectAndSessionCounts(config) {
497
- const projectsDir = resolveProjectsDir(config);
498
- if (!fs.existsSync(projectsDir)) {
233
+ const projects = await idxListProjects('claude');
234
+ let sessionCount = 0;
235
+ for (const p of projects) sessionCount += p.sessionCount || 0;
236
+ return { projectCount: projects.length, sessionCount };
237
+ } catch (_) {
499
238
  return { projectCount: 0, sessionCount: 0 };
500
239
  }
501
-
502
- let projectCount = 0;
503
- let sessionCount = 0;
504
-
505
- const entries = fs.readdirSync(projectsDir, { withFileTypes: true });
506
- entries.forEach((entry) => {
507
- if (!entry.isDirectory()) {
508
- return;
509
- }
510
- projectCount += 1;
511
- const projectPath = path.join(projectsDir, entry.name);
512
- try {
513
- const files = fs.readdirSync(projectPath);
514
- sessionCount += files.filter(file => file.endsWith('.jsonl') && !file.startsWith('agent-')).length;
515
- } catch (err) {
516
- // 忽略单个项目的读取错误
517
- }
518
- });
519
-
520
- return { projectCount, sessionCount };
521
- }
522
-
523
- // Check if a session file has actual messages (async with enhanced caching)
524
- async function hasActualMessages(filePath) {
525
- try {
526
- const stats = await fs.promises.stat(filePath);
527
-
528
- // Check enhanced cache first
529
- const cacheKey = `${CacheKeys.HAS_MESSAGES}${filePath}:${stats.mtime.getTime()}`;
530
- const cached = globalCache.get(cacheKey);
531
- if (typeof cached === 'boolean') {
532
- return cached;
533
- }
534
-
535
- // Check old cache mechanism
536
- const oldCached = checkHasMessagesCache(filePath, stats);
537
- if (typeof oldCached === 'boolean') {
538
- globalCache.set(cacheKey, oldCached, 600000); // 10分钟
539
- return oldCached;
540
- }
541
-
542
- const result = await scanSessionFileForMessagesAsync(filePath);
543
- globalCache.set(cacheKey, result, 600000);
544
- rememberHasMessages(filePath, stats, result);
545
- return result;
546
- } catch (err) {
547
- return false;
548
- }
549
- }
550
-
551
- function scanSessionFileForMessages(filePath) {
552
- let fd = null;
553
- try {
554
- fd = fs.openSync(filePath, 'r');
555
- const bufferSize = 64 * 1024;
556
- const buffer = Buffer.alloc(bufferSize);
557
- const pattern = /"type"\s*:\s*"(user|assistant|summary)"/;
558
- let leftover = '';
559
- let bytesRead;
560
-
561
- while ((bytesRead = fs.readSync(fd, buffer, 0, bufferSize, null)) > 0) {
562
- const chunk = buffer.toString('utf8', 0, bytesRead);
563
- const combined = leftover + chunk;
564
- if (pattern.test(combined)) {
565
- fs.closeSync(fd);
566
- return true;
567
- }
568
- leftover = combined.slice(-64);
569
- }
570
-
571
- fs.closeSync(fd);
572
- return false;
573
- } catch (err) {
574
- if (fd) {
575
- try {
576
- fs.closeSync(fd);
577
- } catch (e) {
578
- // ignore
579
- }
580
- }
581
- return false;
582
- }
583
240
  }
584
241
 
585
- // Async version using streams for better performance
586
- function scanSessionFileForMessagesAsync(filePath) {
587
- return new Promise((resolve) => {
588
- const stream = fs.createReadStream(filePath, { encoding: 'utf8', highWaterMark: 64 * 1024 });
589
- const pattern = /"type"\s*:\s*"(user|assistant|summary)"/;
590
- let found = false;
591
- let leftover = '';
592
-
593
- stream.on('data', (chunk) => {
594
- if (found) return;
595
- const combined = leftover + chunk;
596
- if (pattern.test(combined)) {
597
- found = true;
598
- stream.destroy();
599
- resolve(true);
600
- }
601
- leftover = combined.slice(-64);
602
- });
603
-
604
- stream.on('end', () => {
605
- if (!found) resolve(false);
606
- });
242
+ // ===== Sessions for project: index + saved order + fork relations =====
607
243
 
608
- stream.on('error', () => {
609
- resolve(false);
610
- });
611
- });
612
- }
613
-
614
- // Get sessions for a project - async version
615
244
  async function getSessionsForProject(config, projectName, options = {}) {
616
- // Check cache first
617
- const cacheKey = `${CacheKeys.SESSIONS}${projectName}`;
618
- if (!options.force) {
619
- const cached = globalCache.get(cacheKey);
620
- if (cached) {
621
- return cached;
622
- }
623
- }
624
-
625
- const projectConfig = { ...config, currentProject: projectName };
626
- const sessions = getAllSessions(projectConfig);
245
+ const indexed = await idxListSessions('claude', projectName);
627
246
  const forkRelations = getForkRelations();
628
247
  const savedOrder = getSessionOrder(projectName);
629
248
 
630
- // Parse session info and calculate total size, filter out sessions with no messages (in parallel)
631
- let totalSize = 0;
632
-
633
- const sessionChecks = await Promise.all(
634
- sessions.map(async (session) => {
635
- const hasMessages = await hasActualMessages(session.filePath);
636
- return hasMessages ? session : null;
637
- })
638
- );
639
-
640
- const validSessions = sessionChecks.filter(s => s !== null);
249
+ let sessions = indexed.map(s => ({
250
+ sessionId: s.sessionId,
251
+ mtime: s.mtime,
252
+ size: s.size,
253
+ filePath: s.filePath,
254
+ gitBranch: s.gitBranch || null,
255
+ firstMessage: s.firstMessage || null,
256
+ forkedFrom: forkRelations[s.sessionId] || null
257
+ }));
641
258
 
642
- const sessionsWithInfo = validSessions.map(session => {
643
- const info = parseSessionInfoFast(session.filePath);
644
- totalSize += session.size || 0;
645
- return {
646
- sessionId: session.sessionId,
647
- mtime: session.mtime,
648
- size: session.size,
649
- filePath: session.filePath,
650
- gitBranch: info.gitBranch || null,
651
- firstMessage: info.firstMessage || null,
652
- forkedFrom: forkRelations[session.sessionId] || null
653
- };
654
- });
655
-
656
- // Apply saved order if exists
657
- let orderedSessions = sessionsWithInfo;
658
259
  if (savedOrder.length > 0) {
260
+ const sm = new Map(sessions.map(s => [s.sessionId, s]));
659
261
  const ordered = [];
660
- const sessionMap = new Map(sessionsWithInfo.map(s => [s.sessionId, s]));
661
-
662
- // Add sessions in saved order
663
- for (const sessionId of savedOrder) {
664
- if (sessionMap.has(sessionId)) {
665
- ordered.push(sessionMap.get(sessionId));
666
- sessionMap.delete(sessionId);
667
- }
262
+ for (const sid of savedOrder) {
263
+ if (sm.has(sid)) { ordered.push(sm.get(sid)); sm.delete(sid); }
668
264
  }
669
-
670
- // Add remaining sessions (new ones not in saved order)
671
- ordered.push(...sessionMap.values());
672
- orderedSessions = ordered;
265
+ ordered.push(...sm.values());
266
+ sessions = ordered;
673
267
  }
674
268
 
675
- const result = {
676
- sessions: orderedSessions,
677
- totalSize
678
- };
679
-
680
- // Cache for 2 minutes
681
- globalCache.set(cacheKey, result, 120000);
682
- return result;
269
+ const totalSize = sessions.reduce((sum, s) => sum + (s.size || 0), 0);
270
+ return { sessions, totalSize };
683
271
  }
684
272
 
685
- // Delete a session
273
+ // ===== Mutations (sync) =====
274
+
686
275
  function deleteSession(config, projectName, sessionId) {
687
276
  const resolvedConfig = withResolvedProjectsDir(config);
688
277
  const projectDir = path.join(resolvedConfig.projectsDir, projectName);
689
- const sessionFile = path.join(projectDir, sessionId + '.jsonl');
690
-
691
- if (!fs.existsSync(sessionFile)) {
692
- throw new Error('Session not found');
693
- }
694
-
695
- fs.unlinkSync(sessionFile);
696
- invalidateProjectsCache(resolvedConfig);
278
+ const filePath = path.join(projectDir, sessionId + '.jsonl');
279
+ if (!fs.existsSync(filePath)) throw new Error('Session not found');
280
+ fs.unlinkSync(filePath);
697
281
  invalidateSessionResultCache(projectName);
698
282
  return { success: true };
699
283
  }
700
284
 
701
- // Fork a session
702
285
  function forkSession(config, projectName, sessionId, options = {}) {
703
286
  const resolvedConfig = withResolvedProjectsDir(config);
704
287
  const projectDir = path.join(resolvedConfig.projectsDir, projectName);
705
- const sessionFile = path.join(projectDir, sessionId + '.jsonl');
706
-
707
- if (!fs.existsSync(sessionFile)) {
708
- throw new Error('Session not found');
288
+ const sourcePath = path.join(projectDir, sessionId + '.jsonl');
289
+ if (!fs.existsSync(sourcePath)) throw new Error('Session not found');
290
+ const forkId = crypto.randomUUID();
291
+ const targetPath = path.join(projectDir, forkId + '.jsonl');
292
+ let content;
293
+ if (options.afterUserMessageNumber && Number.isInteger(options.afterUserMessageNumber) && options.afterUserMessageNumber > 0) {
294
+ content = sliceClaudeSessionContentByUserMessage(fs.readFileSync(sourcePath, 'utf8'), options.afterUserMessageNumber);
295
+ } else {
296
+ content = fs.readFileSync(sourcePath, 'utf8');
709
297
  }
710
-
711
- // Read the original session
712
- const originalContent = fs.readFileSync(sessionFile, 'utf8');
713
- const content = sliceClaudeSessionContentByUserMessage(
714
- originalContent,
715
- options.afterUserMessageNumber
716
- );
717
-
718
- // Generate new session ID (UUID v4)
719
- const newSessionId = crypto.randomUUID();
720
- const newSessionFile = path.join(projectDir, newSessionId + '.jsonl');
721
-
722
- // Write to new file
723
- fs.writeFileSync(newSessionFile, content, 'utf8');
724
-
725
- // Save fork relation
298
+ fs.writeFileSync(targetPath, content, 'utf8');
726
299
  const forkRelations = getForkRelations();
727
- forkRelations[newSessionId] = sessionId;
300
+ forkRelations[forkId] = sessionId;
728
301
  saveForkRelations(forkRelations);
729
- if (options.alias) {
730
- const { setAlias } = require('./alias');
731
- setAlias(newSessionId, options.alias);
732
- }
733
- invalidateProjectsCache(resolvedConfig);
302
+ if (options.alias) setAlias(forkId, options.alias);
734
303
  invalidateSessionResultCache(projectName);
735
-
736
304
  return {
737
- newSessionId,
305
+ newSessionId: forkId,
738
306
  forkedFrom: sessionId,
739
307
  alias: options.alias || null,
740
308
  afterUserMessageNumber: options.afterUserMessageNumber || null
741
309
  };
742
310
  }
743
311
 
744
- // Get session order for a project
745
312
  function getSessionOrder(projectName) {
746
313
  const orderFile = getSessionOrderFilePath();
747
314
  try {
748
315
  if (fs.existsSync(orderFile)) {
749
- const data = fs.readFileSync(orderFile, 'utf8');
750
- const allOrders = JSON.parse(data);
751
- return allOrders[projectName] || [];
316
+ const data = JSON.parse(fs.readFileSync(orderFile, 'utf8'));
317
+ return data[projectName] || [];
752
318
  }
753
- } catch (err) {
754
- // Ignore errors
755
- }
319
+ } catch (_) {}
756
320
  return [];
757
321
  }
758
322
 
759
- // Save session order for a project
760
323
  function saveSessionOrder(projectName, order) {
761
324
  const orderFile = getSessionOrderFilePath();
762
325
  const dir = path.dirname(orderFile);
763
- if (!fs.existsSync(dir)) {
764
- fs.mkdirSync(dir, { recursive: true });
765
- }
766
-
767
- // Read existing orders
768
- let allOrders = {};
769
- try {
770
- if (fs.existsSync(orderFile)) {
771
- const data = fs.readFileSync(orderFile, 'utf8');
772
- allOrders = JSON.parse(data);
773
- }
774
- } catch (err) {
775
- // Ignore errors
776
- }
777
-
778
- // Update order for this project
779
- allOrders[projectName] = order;
780
- fs.writeFileSync(orderFile, JSON.stringify(allOrders, null, 2), 'utf8');
326
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
327
+ let data = {};
328
+ try { if (fs.existsSync(orderFile)) data = JSON.parse(fs.readFileSync(orderFile, 'utf8')); } catch (_) {}
329
+ data[projectName] = order;
330
+ fs.writeFileSync(orderFile, JSON.stringify(data, null, 2), 'utf8');
781
331
  }
782
332
 
783
- // Delete a project (remove the entire project directory)
784
333
  function deleteProject(config, projectName) {
785
334
  const resolvedConfig = withResolvedProjectsDir(config);
786
335
  const projectDir = path.join(resolvedConfig.projectsDir, projectName);
787
-
788
- if (!fs.existsSync(projectDir)) {
789
- throw new Error('Project not found');
790
- }
791
-
792
- // Recursively delete the directory
336
+ if (!fs.existsSync(projectDir)) throw new Error('Project not found');
793
337
  fs.rmSync(projectDir, { recursive: true, force: true });
794
-
795
- // Remove from order file if exists
796
338
  const order = getProjectOrder(resolvedConfig);
797
339
  const newOrder = order.filter(name => name !== projectName);
798
- if (newOrder.length !== order.length) {
799
- saveProjectOrder(resolvedConfig, newOrder);
800
- }
801
-
802
- invalidateProjectsCache(resolvedConfig);
340
+ if (newOrder.length !== order.length) saveProjectOrder(resolvedConfig, newOrder);
803
341
  return { success: true };
804
342
  }
805
343
 
806
- // Search sessions for keyword
807
- function searchSessions(config, projectName, keyword, contextLength = 15) {
808
- const resolvedConfig = withResolvedProjectsDir(config);
809
- const projectDir = path.join(resolvedConfig.projectsDir, projectName);
810
-
811
- if (!fs.existsSync(projectDir)) {
812
- return [];
813
- }
344
+ // ===== Search: delegates to index =====
814
345
 
815
- const results = [];
816
- const files = fs.readdirSync(projectDir);
817
- const jsonlFiles = files.filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-'));
346
+ async function searchSessions(config, projectName, keyword, contextLength = 15) {
347
+ const results = await idxSearch('claude', keyword, { projectName, contextLength });
818
348
  const aliases = loadAliases();
819
-
820
- for (const file of jsonlFiles) {
821
- const sessionId = file.replace('.jsonl', '');
822
- const filePath = path.join(projectDir, file);
823
-
824
- // Skip sessions with no actual messages
825
- if (!hasActualMessages(filePath)) {
826
- continue;
827
- }
828
-
829
- try {
830
- const content = fs.readFileSync(filePath, 'utf8');
831
- const lines = content.split('\n');
832
- const matches = [];
833
-
834
- for (const line of lines) {
835
- if (!line.trim()) continue;
836
-
837
- try {
838
- const json = JSON.parse(line);
839
-
840
- // Search in message content
841
- if (json.message && json.message.content) {
842
- const text = json.message.content;
843
- const lowerText = text.toLowerCase();
844
- const lowerKeyword = keyword.toLowerCase();
845
- let index = 0;
846
-
847
- while ((index = lowerText.indexOf(lowerKeyword, index)) !== -1) {
848
- // Extract context
849
- const start = Math.max(0, index - contextLength);
850
- const end = Math.min(text.length, index + keyword.length + contextLength);
851
- const context = text.substring(start, end);
852
-
853
- matches.push({
854
- role: json.message.role || 'unknown',
855
- context: (start > 0 ? '...' : '') + context + (end < text.length ? '...' : ''),
856
- position: index
857
- });
858
-
859
- index += keyword.length;
860
- }
861
- }
862
- } catch (e) {
863
- // Skip invalid JSON lines
864
- }
865
- }
866
-
867
- if (matches.length > 0) {
868
- results.push({
869
- sessionId,
870
- alias: aliases[sessionId] || null,
871
- matchCount: matches.length,
872
- matches: matches.slice(0, 5) // Limit to 5 matches per session
873
- });
874
- }
875
- } catch (e) {
876
- // Skip files that can't be read
877
- }
878
- }
879
-
880
- // Sort by match count
881
- results.sort((a, b) => b.matchCount - a.matchCount);
882
-
883
- return results;
349
+ return results.map(r => ({
350
+ sessionId: r.sessionId,
351
+ alias: aliases[r.sessionId] || null,
352
+ matchCount: r.matchCount,
353
+ matches: r.matches
354
+ }));
884
355
  }
885
356
 
886
- // Get recent sessions across all projects
887
357
  async function getRecentSessions(config, limit = 5) {
888
- const projects = await getProjects(config);
889
- const allSessions = [];
890
- const forkRelations = getForkRelations();
358
+ const indexed = await idxGetRecent('claude', limit);
891
359
  const aliases = loadAliases();
892
-
893
- // Collect all sessions from all projects
894
- projects.forEach(projectName => {
895
- const projectConfig = { ...config, currentProject: projectName };
896
- const sessions = getAllSessions(projectConfig);
897
- const { projectName: displayName, fullPath } = parseRealProjectPath(projectName);
898
-
899
- sessions.forEach(session => {
900
- // Skip sessions with no actual messages
901
- if (!hasActualMessages(session.filePath)) {
902
- return;
903
- }
904
-
905
- const info = parseSessionInfoFast(session.filePath);
906
- allSessions.push({
907
- sessionId: session.sessionId,
908
- projectName: projectName,
909
- projectDisplayName: displayName,
910
- projectFullPath: fullPath,
911
- mtime: session.mtime,
912
- size: session.size,
913
- filePath: session.filePath,
914
- gitBranch: info.gitBranch || null,
915
- firstMessage: info.firstMessage || null,
916
- forkedFrom: forkRelations[session.sessionId] || null,
917
- alias: aliases[session.sessionId] || null
918
- });
919
- });
920
- });
921
-
922
- // Sort by mtime descending (most recent first)
923
- allSessions.sort((a, b) => b.mtime - a.mtime);
924
-
925
- // Return top N sessions
926
- return allSessions.slice(0, limit);
360
+ const forkRelations = getForkRelations();
361
+ return indexed.map(s => ({
362
+ sessionId: s.sessionId,
363
+ projectName: s.projectName,
364
+ projectDisplayName: s.projectDisplayName,
365
+ projectFullPath: s.projectFullPath,
366
+ mtime: s.mtime,
367
+ size: s.size,
368
+ filePath: s.filePath,
369
+ gitBranch: s.gitBranch || null,
370
+ firstMessage: s.firstMessage || null,
371
+ forkedFrom: forkRelations[s.sessionId] || null,
372
+ alias: aliases[s.sessionId] || null
373
+ }));
927
374
  }
928
375
 
929
- // Search sessions across all projects
930
376
  async function searchSessionsAcrossProjects(config, keyword, contextLength = 35) {
931
377
  const allResults = [];
932
378
 
933
379
  try {
934
- // Search in Claude projects
935
- const claudeProjects = await getProjects(config);
936
- claudeProjects.forEach(projectName => {
937
- const projectResults = searchSessions(config, projectName, keyword, contextLength);
938
- const { projectName: displayName, fullPath } = parseRealProjectPath(projectName);
939
-
940
- // Add project info to each result
941
- projectResults.forEach(result => {
942
- allResults.push({
943
- ...result,
944
- projectName: projectName,
945
- projectDisplayName: displayName,
946
- projectFullPath: fullPath,
947
- channel: 'claude'
948
- });
949
- });
950
- });
951
- } catch (error) {
952
- console.error('Error searching Claude projects:', error);
953
- }
380
+ const claudeResults = await idxSearch('claude', keyword, { contextLength });
381
+ for (const r of claudeResults) allResults.push({ ...r, channel: 'claude' });
382
+ } catch (_) {}
954
383
 
955
384
  try {
956
- // Search in Codex projects
957
- const codexProjectsDir = CODEX_PROJECTS_DIR;
958
- if (fs.existsSync(codexProjectsDir)) {
959
- const codexConfig = { ...config, projectsDir: codexProjectsDir };
960
- const codexProjects = await getProjects(codexConfig);
961
- codexProjects.forEach(projectName => {
962
- const projectResults = searchSessions(codexConfig, projectName, keyword, contextLength);
963
- const { projectName: displayName, fullPath } = parseRealProjectPath(projectName);
964
-
965
- projectResults.forEach(result => {
966
- allResults.push({
967
- ...result,
968
- projectName: projectName,
969
- projectDisplayName: displayName,
970
- projectFullPath: fullPath,
971
- channel: 'codex'
972
- });
973
- });
974
- });
385
+ if (fs.existsSync(CODEX_PROJECTS_DIR)) {
386
+ const { searchSessions: codexSearch } = require('./codex-sessions');
387
+ const codexResults = await codexSearch(keyword);
388
+ for (const r of codexResults) allResults.push({ ...r, channel: 'codex' });
975
389
  }
976
- } catch (error) {
977
- console.error('Error searching Codex projects:', error);
978
- }
390
+ } catch (_) {}
979
391
 
980
392
  try {
981
- // Search in Gemini projects
982
- const geminiProjectsDir = GEMINI_PROJECTS_DIR;
983
- if (fs.existsSync(geminiProjectsDir)) {
984
- const geminiConfig = { ...config, projectsDir: geminiProjectsDir };
985
- const geminiProjects = await getProjects(geminiConfig);
986
- geminiProjects.forEach(projectName => {
987
- const projectResults = searchSessions(geminiConfig, projectName, keyword, contextLength);
988
- const { projectName: displayName, fullPath } = parseRealProjectPath(projectName);
989
-
990
- projectResults.forEach(result => {
991
- allResults.push({
992
- ...result,
993
- projectName: projectName,
994
- projectDisplayName: displayName,
995
- projectFullPath: fullPath,
996
- channel: 'gemini'
997
- });
998
- });
999
- });
393
+ if (fs.existsSync(GEMINI_PROJECTS_DIR)) {
394
+ const { searchSessions: geminiSearch } = require('./gemini-sessions');
395
+ const geminiResults = await geminiSearch(keyword, contextLength);
396
+ for (const r of geminiResults) allResults.push({ ...r, channel: 'gemini' });
1000
397
  }
1001
- } catch (error) {
1002
- console.error('Error searching Gemini projects:', error);
1003
- }
1004
-
1005
- // Sort by match count
1006
- allResults.sort((a, b) => b.matchCount - a.matchCount);
398
+ } catch (_) {}
1007
399
 
400
+ allResults.sort((a, b) => (b.matchCount || 0) - (a.matchCount || 0));
1008
401
  return allResults;
1009
402
  }
1010
403
 
@@ -1025,6 +418,5 @@ module.exports = {
1025
418
  searchSessionsAcrossProjects,
1026
419
  getForkRelations,
1027
420
  saveForkRelations,
1028
- hasActualMessages,
1029
421
  getProjectAndSessionCounts
1030
422
  };