@adversity/coding-tool-x 2.2.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.
Files changed (125) hide show
  1. package/CHANGELOG.md +333 -0
  2. package/LICENSE +21 -0
  3. package/README.md +404 -0
  4. package/bin/ctx.js +8 -0
  5. package/dist/web/assets/index-D1AYlFLZ.js +3220 -0
  6. package/dist/web/assets/index-aL3cKxSK.css +41 -0
  7. package/dist/web/favicon.ico +0 -0
  8. package/dist/web/index.html +14 -0
  9. package/dist/web/logo.png +0 -0
  10. package/docs/CHANGELOG.md +582 -0
  11. package/docs/DIRECTORY_MIGRATION.md +112 -0
  12. package/docs/PROJECT_STRUCTURE.md +396 -0
  13. package/docs/bannel.png +0 -0
  14. package/docs/home.png +0 -0
  15. package/docs/logo.png +0 -0
  16. package/docs/multi-channel-load-balancing.md +249 -0
  17. package/package.json +73 -0
  18. package/src/commands/channels.js +504 -0
  19. package/src/commands/cli-type.js +99 -0
  20. package/src/commands/daemon.js +286 -0
  21. package/src/commands/doctor.js +332 -0
  22. package/src/commands/list.js +222 -0
  23. package/src/commands/logs.js +259 -0
  24. package/src/commands/port-config.js +115 -0
  25. package/src/commands/proxy-control.js +258 -0
  26. package/src/commands/proxy.js +152 -0
  27. package/src/commands/resume.js +137 -0
  28. package/src/commands/search.js +190 -0
  29. package/src/commands/stats.js +224 -0
  30. package/src/commands/switch.js +48 -0
  31. package/src/commands/toggle-proxy.js +222 -0
  32. package/src/commands/ui.js +92 -0
  33. package/src/commands/workspace.js +454 -0
  34. package/src/config/default.js +40 -0
  35. package/src/config/loader.js +75 -0
  36. package/src/config/paths.js +121 -0
  37. package/src/index.js +373 -0
  38. package/src/reset-config.js +92 -0
  39. package/src/server/api/agents.js +248 -0
  40. package/src/server/api/aliases.js +36 -0
  41. package/src/server/api/channels.js +258 -0
  42. package/src/server/api/claude-hooks.js +480 -0
  43. package/src/server/api/codex-channels.js +312 -0
  44. package/src/server/api/codex-projects.js +91 -0
  45. package/src/server/api/codex-proxy.js +182 -0
  46. package/src/server/api/codex-sessions.js +491 -0
  47. package/src/server/api/codex-statistics.js +57 -0
  48. package/src/server/api/commands.js +245 -0
  49. package/src/server/api/config-templates.js +182 -0
  50. package/src/server/api/config.js +147 -0
  51. package/src/server/api/convert.js +127 -0
  52. package/src/server/api/dashboard.js +125 -0
  53. package/src/server/api/env.js +144 -0
  54. package/src/server/api/favorites.js +77 -0
  55. package/src/server/api/gemini-channels.js +261 -0
  56. package/src/server/api/gemini-projects.js +91 -0
  57. package/src/server/api/gemini-proxy.js +160 -0
  58. package/src/server/api/gemini-sessions.js +397 -0
  59. package/src/server/api/gemini-statistics.js +57 -0
  60. package/src/server/api/health-check.js +118 -0
  61. package/src/server/api/mcp.js +336 -0
  62. package/src/server/api/pm2-autostart.js +269 -0
  63. package/src/server/api/projects.js +124 -0
  64. package/src/server/api/prompts.js +279 -0
  65. package/src/server/api/proxy.js +235 -0
  66. package/src/server/api/rules.js +271 -0
  67. package/src/server/api/sessions.js +595 -0
  68. package/src/server/api/settings.js +61 -0
  69. package/src/server/api/skills.js +305 -0
  70. package/src/server/api/statistics.js +91 -0
  71. package/src/server/api/terminal.js +202 -0
  72. package/src/server/api/ui-config.js +64 -0
  73. package/src/server/api/workspaces.js +407 -0
  74. package/src/server/codex-proxy-server.js +538 -0
  75. package/src/server/dev-server.js +26 -0
  76. package/src/server/gemini-proxy-server.js +518 -0
  77. package/src/server/index.js +305 -0
  78. package/src/server/proxy-server.js +469 -0
  79. package/src/server/services/agents-service.js +354 -0
  80. package/src/server/services/alias.js +71 -0
  81. package/src/server/services/channel-health.js +234 -0
  82. package/src/server/services/channel-scheduler.js +234 -0
  83. package/src/server/services/channels.js +347 -0
  84. package/src/server/services/codex-channels.js +625 -0
  85. package/src/server/services/codex-config.js +90 -0
  86. package/src/server/services/codex-parser.js +322 -0
  87. package/src/server/services/codex-sessions.js +665 -0
  88. package/src/server/services/codex-settings-manager.js +397 -0
  89. package/src/server/services/codex-speed-test-template.json +24 -0
  90. package/src/server/services/codex-statistics-service.js +255 -0
  91. package/src/server/services/commands-service.js +360 -0
  92. package/src/server/services/config-templates-service.js +732 -0
  93. package/src/server/services/env-checker.js +307 -0
  94. package/src/server/services/env-manager.js +300 -0
  95. package/src/server/services/favorites.js +163 -0
  96. package/src/server/services/gemini-channels.js +333 -0
  97. package/src/server/services/gemini-config.js +73 -0
  98. package/src/server/services/gemini-sessions.js +689 -0
  99. package/src/server/services/gemini-settings-manager.js +263 -0
  100. package/src/server/services/gemini-statistics-service.js +253 -0
  101. package/src/server/services/health-check.js +399 -0
  102. package/src/server/services/mcp-service.js +1188 -0
  103. package/src/server/services/prompts-service.js +492 -0
  104. package/src/server/services/proxy-runtime.js +79 -0
  105. package/src/server/services/pty-manager.js +435 -0
  106. package/src/server/services/rules-service.js +401 -0
  107. package/src/server/services/session-cache.js +127 -0
  108. package/src/server/services/session-converter.js +577 -0
  109. package/src/server/services/sessions.js +757 -0
  110. package/src/server/services/settings-manager.js +163 -0
  111. package/src/server/services/skill-service.js +965 -0
  112. package/src/server/services/speed-test.js +545 -0
  113. package/src/server/services/statistics-service.js +386 -0
  114. package/src/server/services/terminal-commands.js +155 -0
  115. package/src/server/services/terminal-config.js +140 -0
  116. package/src/server/services/terminal-detector.js +306 -0
  117. package/src/server/services/ui-config.js +130 -0
  118. package/src/server/services/workspace-service.js +662 -0
  119. package/src/server/utils/pricing.js +41 -0
  120. package/src/server/websocket-server.js +557 -0
  121. package/src/ui/menu.js +129 -0
  122. package/src/ui/prompts.js +100 -0
  123. package/src/utils/format.js +43 -0
  124. package/src/utils/port-helper.js +94 -0
  125. package/src/utils/session.js +239 -0
@@ -0,0 +1,757 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const crypto = require('crypto');
5
+ const { getAllSessions, parseSessionInfoFast } = require('../../utils/session');
6
+ const { loadAliases } = require('./alias');
7
+ const {
8
+ getCachedProjects,
9
+ setCachedProjects,
10
+ invalidateProjectsCache,
11
+ checkHasMessagesCache,
12
+ rememberHasMessages
13
+ } = require('./session-cache');
14
+ const { PATHS } = require('../../config/paths');
15
+
16
+ // Base directory for cc-tool data
17
+ function getCcToolDir() {
18
+ return PATHS.base;
19
+ }
20
+
21
+ // Get path for storing project order
22
+ function getOrderFilePath() {
23
+ return PATHS.projectOrder;
24
+ }
25
+
26
+ // Get path for storing fork relations
27
+ function getForkRelationsFilePath() {
28
+ return path.join(PATHS.base, 'fork-relations.json');
29
+ }
30
+
31
+ // Get path for storing session order
32
+ function getSessionOrderFilePath() {
33
+ return path.join(getCcToolDir(), 'session-order.json');
34
+ }
35
+
36
+ // Get saved project order
37
+ function getProjectOrder(config) {
38
+ const orderFile = getOrderFilePath();
39
+ try {
40
+ if (fs.existsSync(orderFile)) {
41
+ const data = fs.readFileSync(orderFile, 'utf8');
42
+ return JSON.parse(data);
43
+ }
44
+ } catch (err) {
45
+ // Ignore errors
46
+ }
47
+ return [];
48
+ }
49
+
50
+ // Save project order
51
+ function saveProjectOrder(config, order) {
52
+ const orderFile = getOrderFilePath();
53
+ const dir = path.dirname(orderFile);
54
+ if (!fs.existsSync(dir)) {
55
+ fs.mkdirSync(dir, { recursive: true });
56
+ }
57
+ fs.writeFileSync(orderFile, JSON.stringify(order, null, 2), 'utf8');
58
+ invalidateProjectsCache(config);
59
+ }
60
+
61
+ // Get fork relations
62
+ function getForkRelations() {
63
+ const relationsFile = getForkRelationsFilePath();
64
+ try {
65
+ if (fs.existsSync(relationsFile)) {
66
+ const data = fs.readFileSync(relationsFile, 'utf8');
67
+ return JSON.parse(data);
68
+ }
69
+ } catch (err) {
70
+ // Ignore errors
71
+ }
72
+ return {};
73
+ }
74
+
75
+ // Save fork relations
76
+ function saveForkRelations(relations) {
77
+ const relationsFile = getForkRelationsFilePath();
78
+ const dir = path.dirname(relationsFile);
79
+ if (!fs.existsSync(dir)) {
80
+ fs.mkdirSync(dir, { recursive: true });
81
+ }
82
+ fs.writeFileSync(relationsFile, JSON.stringify(relations, null, 2), 'utf8');
83
+ }
84
+
85
+ // Get all projects with stats
86
+ function getProjects(config) {
87
+ const projectsDir = config.projectsDir;
88
+
89
+ if (!fs.existsSync(projectsDir)) {
90
+ return [];
91
+ }
92
+
93
+ const entries = fs.readdirSync(projectsDir, { withFileTypes: true });
94
+ return entries
95
+ .filter(entry => entry.isDirectory())
96
+ .map(entry => entry.name);
97
+ }
98
+
99
+ // Parse real project path from encoded name
100
+ // macOS/Linux: "-Users-lilithgames-work-project" -> "/Users/lilithgames/work/project"
101
+ // Windows: "C--Users-admin-Desktop-project" -> "C:\Users\admin\Desktop\project"
102
+ function parseRealProjectPath(encodedName) {
103
+ const isWindows = process.platform === 'win32';
104
+ const fallbackFromSessions = tryResolvePathFromSessions(encodedName);
105
+
106
+ // Detect Windows drive letter (e.g., "C--Users-admin")
107
+ const windowsDriveMatch = encodedName.match(/^([A-Z])--(.+)$/);
108
+
109
+ if (isWindows && windowsDriveMatch) {
110
+ // Windows path with drive letter
111
+ const driveLetter = windowsDriveMatch[1];
112
+ const restPath = windowsDriveMatch[2];
113
+
114
+ // Split by '-' to get segments
115
+ const segments = restPath.split('-').filter(s => s);
116
+
117
+ // Build path from left to right, checking existence
118
+ let realSegments = [];
119
+ let accumulated = '';
120
+ let currentPath = '';
121
+
122
+ for (let i = 0; i < segments.length; i++) {
123
+ if (accumulated) {
124
+ accumulated += '-' + segments[i];
125
+ } else {
126
+ accumulated = segments[i];
127
+ }
128
+
129
+ const testPath = driveLetter + ':\\' + realSegments.concat(accumulated).join('\\');
130
+
131
+ // Check if this path exists
132
+ let found = fs.existsSync(testPath);
133
+ let finalAccumulated = accumulated;
134
+
135
+ // If not found with dash, try with underscore
136
+ if (!found && accumulated.includes('-')) {
137
+ const withUnderscore = accumulated.replace(/-/g, '_');
138
+ const testPathUnderscore = driveLetter + ':\\' + realSegments.concat(withUnderscore).join('\\');
139
+ if (fs.existsSync(testPathUnderscore)) {
140
+ finalAccumulated = withUnderscore;
141
+ found = true;
142
+ }
143
+ }
144
+
145
+ if (found) {
146
+ realSegments.push(finalAccumulated);
147
+ accumulated = '';
148
+ currentPath = driveLetter + ':\\' + realSegments.join('\\');
149
+ }
150
+ }
151
+
152
+ // If there's remaining accumulated segment, try underscore variant
153
+ if (accumulated) {
154
+ let finalAccumulated = accumulated;
155
+ if (accumulated.includes('-')) {
156
+ const withUnderscore = accumulated.replace(/-/g, '_');
157
+ const testPath = driveLetter + ':\\' + realSegments.concat(withUnderscore).join('\\');
158
+ if (fs.existsSync(testPath)) {
159
+ finalAccumulated = withUnderscore;
160
+ }
161
+ }
162
+ realSegments.push(finalAccumulated);
163
+ currentPath = driveLetter + ':\\' + realSegments.join('\\');
164
+ }
165
+
166
+ return {
167
+ fullPath: validateProjectPath(currentPath) || fallbackFromSessions?.fullPath || (driveLetter + ':\\' + restPath.replace(/-/g, '\\')),
168
+ projectName: fallbackFromSessions?.projectName || realSegments[realSegments.length - 1] || encodedName
169
+ };
170
+ } else {
171
+ // Unix-like path (macOS/Linux) or fallback
172
+ const pathStr = encodedName.replace(/^-/, '/').replace(/-/g, '/');
173
+ const segments = pathStr.split('/').filter(s => s);
174
+
175
+ // Build path from left to right, checking existence
176
+ let currentPath = '';
177
+ const realSegments = [];
178
+ let accumulated = '';
179
+
180
+ for (let i = 0; i < segments.length; i++) {
181
+ if (accumulated) {
182
+ accumulated += '-' + segments[i];
183
+ } else {
184
+ accumulated = segments[i];
185
+ }
186
+
187
+ const testPath = '/' + realSegments.concat(accumulated).join('/');
188
+
189
+ // Check if this path exists
190
+ let found = fs.existsSync(testPath);
191
+ let finalAccumulated = accumulated;
192
+
193
+ // If not found with dash, try with underscore
194
+ if (!found && accumulated.includes('-')) {
195
+ const withUnderscore = accumulated.replace(/-/g, '_');
196
+ const testPathUnderscore = '/' + realSegments.concat(withUnderscore).join('/');
197
+ if (fs.existsSync(testPathUnderscore)) {
198
+ finalAccumulated = withUnderscore;
199
+ found = true;
200
+ }
201
+ }
202
+
203
+ if (found) {
204
+ realSegments.push(finalAccumulated);
205
+ accumulated = '';
206
+ currentPath = '/' + realSegments.join('/');
207
+ }
208
+ }
209
+
210
+ // If there's remaining accumulated segment, try underscore variant
211
+ if (accumulated) {
212
+ let finalAccumulated = accumulated;
213
+ if (accumulated.includes('-')) {
214
+ const withUnderscore = accumulated.replace(/-/g, '_');
215
+ const testPath = '/' + realSegments.concat(withUnderscore).join('/');
216
+ if (fs.existsSync(testPath)) {
217
+ finalAccumulated = withUnderscore;
218
+ }
219
+ }
220
+ realSegments.push(finalAccumulated);
221
+ currentPath = '/' + realSegments.join('/');
222
+ }
223
+
224
+ return {
225
+ fullPath: validateProjectPath(currentPath) || fallbackFromSessions?.fullPath || pathStr,
226
+ projectName: fallbackFromSessions?.projectName || realSegments[realSegments.length - 1] || encodedName
227
+ };
228
+ }
229
+ }
230
+
231
+ function validateProjectPath(candidatePath) {
232
+ if (candidatePath && fs.existsSync(candidatePath)) {
233
+ return candidatePath;
234
+ }
235
+ return null;
236
+ }
237
+
238
+ function tryResolvePathFromSessions(encodedName) {
239
+ try {
240
+ const projectDir = path.join(os.homedir(), '.claude', 'projects', encodedName);
241
+ if (!fs.existsSync(projectDir)) {
242
+ return null;
243
+ }
244
+ const files = fs.readdirSync(projectDir).filter(f => f.endsWith('.jsonl'));
245
+ for (const file of files) {
246
+ const sessionFile = path.join(projectDir, file);
247
+ const cwd = extractCwdFromSessionHeader(sessionFile);
248
+ if (cwd && fs.existsSync(cwd)) {
249
+ return {
250
+ fullPath: cwd,
251
+ projectName: path.basename(cwd)
252
+ };
253
+ }
254
+ }
255
+ } catch (err) {
256
+ // ignore fallback errors
257
+ }
258
+ return null;
259
+ }
260
+
261
+ function extractCwdFromSessionHeader(sessionFile) {
262
+ try {
263
+ const fd = fs.openSync(sessionFile, 'r');
264
+ const buffer = Buffer.alloc(4096);
265
+ const bytesRead = fs.readSync(fd, buffer, 0, 4096, 0);
266
+ fs.closeSync(fd);
267
+ const content = buffer.slice(0, bytesRead).toString('utf8');
268
+ const lines = content.split('\n');
269
+ for (const line of lines) {
270
+ if (!line.trim()) continue;
271
+ try {
272
+ const json = JSON.parse(line);
273
+ if (json.cwd && typeof json.cwd === 'string') {
274
+ return json.cwd;
275
+ }
276
+ } catch (e) {
277
+ // ignore
278
+ }
279
+ }
280
+ } catch (err) {
281
+ // ignore
282
+ }
283
+ return null;
284
+ }
285
+
286
+ // Get projects with detailed stats (with caching)
287
+ function getProjectsWithStats(config, options = {}) {
288
+ if (!options.force) {
289
+ const cached = getCachedProjects(config);
290
+ if (cached) {
291
+ return cached;
292
+ }
293
+ }
294
+
295
+ const data = buildProjectsWithStats(config);
296
+ setCachedProjects(config, data);
297
+ return data;
298
+ }
299
+
300
+ function buildProjectsWithStats(config) {
301
+ const projectsDir = config.projectsDir;
302
+
303
+ if (!fs.existsSync(projectsDir)) {
304
+ return [];
305
+ }
306
+
307
+ const entries = fs.readdirSync(projectsDir, { withFileTypes: true });
308
+
309
+ return entries
310
+ .filter(entry => entry.isDirectory())
311
+ .map(entry => {
312
+ const projectName = entry.name;
313
+ const projectPath = path.join(projectsDir, projectName);
314
+
315
+ // Parse real project path
316
+ const { fullPath, projectName: displayName } = parseRealProjectPath(projectName);
317
+
318
+ // Get session files (only count sessions with actual messages)
319
+ let sessionCount = 0;
320
+ let lastUsed = null;
321
+
322
+ try {
323
+ const files = fs.readdirSync(projectPath);
324
+ const jsonlFiles = files.filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-'));
325
+
326
+ // Filter: only count sessions that have actual messages (not just file-history-snapshots)
327
+ const sessionFilesWithMessages = jsonlFiles.filter(f => {
328
+ const filePath = path.join(projectPath, f);
329
+ return hasActualMessages(filePath);
330
+ });
331
+
332
+ sessionCount = sessionFilesWithMessages.length;
333
+
334
+ // Find most recent session (only from sessions with messages)
335
+ if (sessionFilesWithMessages.length > 0) {
336
+ const stats = sessionFilesWithMessages.map(f => {
337
+ const filePath = path.join(projectPath, f);
338
+ const stat = fs.statSync(filePath);
339
+ return stat.mtime.getTime();
340
+ });
341
+ lastUsed = Math.max(...stats);
342
+ }
343
+ } catch (err) {
344
+ // Ignore errors
345
+ }
346
+
347
+ return {
348
+ name: projectName, // Keep encoded name for API operations
349
+ displayName, // Project name for display
350
+ fullPath, // Real full path for display
351
+ sessionCount,
352
+ lastUsed
353
+ };
354
+ })
355
+ .sort((a, b) => (b.lastUsed || 0) - (a.lastUsed || 0)); // Sort by last used
356
+ }
357
+
358
+ // 获取 Claude 项目/会话数量(轻量统计)
359
+ function getProjectAndSessionCounts(config) {
360
+ const projectsDir = config.projectsDir;
361
+ if (!fs.existsSync(projectsDir)) {
362
+ return { projectCount: 0, sessionCount: 0 };
363
+ }
364
+
365
+ let projectCount = 0;
366
+ let sessionCount = 0;
367
+
368
+ const entries = fs.readdirSync(projectsDir, { withFileTypes: true });
369
+ entries.forEach((entry) => {
370
+ if (!entry.isDirectory()) {
371
+ return;
372
+ }
373
+ projectCount += 1;
374
+ const projectPath = path.join(projectsDir, entry.name);
375
+ try {
376
+ const files = fs.readdirSync(projectPath);
377
+ sessionCount += files.filter(file => file.endsWith('.jsonl') && !file.startsWith('agent-')).length;
378
+ } catch (err) {
379
+ // 忽略单个项目的读取错误
380
+ }
381
+ });
382
+
383
+ return { projectCount, sessionCount };
384
+ }
385
+
386
+ // Check if a session file has actual messages (not just file-history-snapshots)
387
+ function hasActualMessages(filePath) {
388
+ try {
389
+ const stats = fs.statSync(filePath);
390
+ const cached = checkHasMessagesCache(filePath, stats);
391
+ if (typeof cached === 'boolean') {
392
+ return cached;
393
+ }
394
+
395
+ const result = scanSessionFileForMessages(filePath);
396
+ rememberHasMessages(filePath, stats, result);
397
+ return result;
398
+ } catch (err) {
399
+ return false;
400
+ }
401
+ }
402
+
403
+ function scanSessionFileForMessages(filePath) {
404
+ let fd = null;
405
+ try {
406
+ fd = fs.openSync(filePath, 'r');
407
+ const bufferSize = 64 * 1024;
408
+ const buffer = Buffer.alloc(bufferSize);
409
+ const pattern = /"type"\s*:\s*"(user|assistant|summary)"/;
410
+ let leftover = '';
411
+ let bytesRead;
412
+
413
+ while ((bytesRead = fs.readSync(fd, buffer, 0, bufferSize, null)) > 0) {
414
+ const chunk = buffer.toString('utf8', 0, bytesRead);
415
+ const combined = leftover + chunk;
416
+ if (pattern.test(combined)) {
417
+ fs.closeSync(fd);
418
+ return true;
419
+ }
420
+ leftover = combined.slice(-64);
421
+ }
422
+
423
+ fs.closeSync(fd);
424
+ return false;
425
+ } catch (err) {
426
+ if (fd) {
427
+ try {
428
+ fs.closeSync(fd);
429
+ } catch (e) {
430
+ // ignore
431
+ }
432
+ }
433
+ return false;
434
+ }
435
+ }
436
+
437
+ // Get sessions for a project
438
+ function getSessionsForProject(config, projectName) {
439
+ const projectConfig = { ...config, currentProject: projectName };
440
+ const sessions = getAllSessions(projectConfig);
441
+ const forkRelations = getForkRelations();
442
+ const savedOrder = getSessionOrder(projectName);
443
+
444
+ // Parse session info and calculate total size, filter out sessions with no messages
445
+ let totalSize = 0;
446
+ const sessionsWithInfo = sessions
447
+ .filter(session => hasActualMessages(session.filePath))
448
+ .map(session => {
449
+ const info = parseSessionInfoFast(session.filePath);
450
+ totalSize += session.size || 0;
451
+ return {
452
+ sessionId: session.sessionId,
453
+ mtime: session.mtime,
454
+ size: session.size,
455
+ filePath: session.filePath,
456
+ gitBranch: info.gitBranch || null,
457
+ firstMessage: info.firstMessage || null,
458
+ forkedFrom: forkRelations[session.sessionId] || null
459
+ };
460
+ });
461
+
462
+ // Apply saved order if exists
463
+ let orderedSessions = sessionsWithInfo;
464
+ if (savedOrder.length > 0) {
465
+ const ordered = [];
466
+ const sessionMap = new Map(sessionsWithInfo.map(s => [s.sessionId, s]));
467
+
468
+ // Add sessions in saved order
469
+ for (const sessionId of savedOrder) {
470
+ if (sessionMap.has(sessionId)) {
471
+ ordered.push(sessionMap.get(sessionId));
472
+ sessionMap.delete(sessionId);
473
+ }
474
+ }
475
+
476
+ // Add remaining sessions (new ones not in saved order)
477
+ ordered.push(...sessionMap.values());
478
+ orderedSessions = ordered;
479
+ }
480
+
481
+ return {
482
+ sessions: orderedSessions,
483
+ totalSize
484
+ };
485
+ }
486
+
487
+ // Delete a session
488
+ function deleteSession(config, projectName, sessionId) {
489
+ const projectDir = path.join(config.projectsDir, projectName);
490
+ const sessionFile = path.join(projectDir, sessionId + '.jsonl');
491
+
492
+ if (!fs.existsSync(sessionFile)) {
493
+ throw new Error('Session not found');
494
+ }
495
+
496
+ fs.unlinkSync(sessionFile);
497
+ invalidateProjectsCache(config);
498
+ return { success: true };
499
+ }
500
+
501
+ // Fork a session
502
+ function forkSession(config, projectName, sessionId) {
503
+ const projectDir = path.join(config.projectsDir, projectName);
504
+ const sessionFile = path.join(projectDir, sessionId + '.jsonl');
505
+
506
+ if (!fs.existsSync(sessionFile)) {
507
+ throw new Error('Session not found');
508
+ }
509
+
510
+ // Read the original session
511
+ const content = fs.readFileSync(sessionFile, 'utf8');
512
+
513
+ // Generate new session ID (UUID v4)
514
+ const newSessionId = crypto.randomUUID();
515
+ const newSessionFile = path.join(projectDir, newSessionId + '.jsonl');
516
+
517
+ // Write to new file
518
+ fs.writeFileSync(newSessionFile, content, 'utf8');
519
+
520
+ // Save fork relation
521
+ const forkRelations = getForkRelations();
522
+ forkRelations[newSessionId] = sessionId;
523
+ saveForkRelations(forkRelations);
524
+ invalidateProjectsCache(config);
525
+
526
+ return { newSessionId, forkedFrom: sessionId };
527
+ }
528
+
529
+ // Get session order for a project
530
+ function getSessionOrder(projectName) {
531
+ const orderFile = getSessionOrderFilePath();
532
+ try {
533
+ if (fs.existsSync(orderFile)) {
534
+ const data = fs.readFileSync(orderFile, 'utf8');
535
+ const allOrders = JSON.parse(data);
536
+ return allOrders[projectName] || [];
537
+ }
538
+ } catch (err) {
539
+ // Ignore errors
540
+ }
541
+ return [];
542
+ }
543
+
544
+ // Save session order for a project
545
+ function saveSessionOrder(projectName, order) {
546
+ const orderFile = getSessionOrderFilePath();
547
+ const dir = path.dirname(orderFile);
548
+ if (!fs.existsSync(dir)) {
549
+ fs.mkdirSync(dir, { recursive: true });
550
+ }
551
+
552
+ // Read existing orders
553
+ let allOrders = {};
554
+ try {
555
+ if (fs.existsSync(orderFile)) {
556
+ const data = fs.readFileSync(orderFile, 'utf8');
557
+ allOrders = JSON.parse(data);
558
+ }
559
+ } catch (err) {
560
+ // Ignore errors
561
+ }
562
+
563
+ // Update order for this project
564
+ allOrders[projectName] = order;
565
+ fs.writeFileSync(orderFile, JSON.stringify(allOrders, null, 2), 'utf8');
566
+ }
567
+
568
+ // Delete a project (remove the entire project directory)
569
+ function deleteProject(config, projectName) {
570
+ const projectDir = path.join(config.projectsDir, projectName);
571
+
572
+ if (!fs.existsSync(projectDir)) {
573
+ throw new Error('Project not found');
574
+ }
575
+
576
+ // Recursively delete the directory
577
+ fs.rmSync(projectDir, { recursive: true, force: true });
578
+
579
+ // Remove from order file if exists
580
+ const order = getProjectOrder(config);
581
+ const newOrder = order.filter(name => name !== projectName);
582
+ if (newOrder.length !== order.length) {
583
+ saveProjectOrder(config, newOrder);
584
+ }
585
+
586
+ invalidateProjectsCache(config);
587
+ return { success: true };
588
+ }
589
+
590
+ // Search sessions for keyword
591
+ function searchSessions(config, projectName, keyword, contextLength = 15) {
592
+ const projectDir = path.join(config.projectsDir, projectName);
593
+
594
+ if (!fs.existsSync(projectDir)) {
595
+ return [];
596
+ }
597
+
598
+ const results = [];
599
+ const files = fs.readdirSync(projectDir);
600
+ const jsonlFiles = files.filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-'));
601
+ const aliases = loadAliases();
602
+
603
+ for (const file of jsonlFiles) {
604
+ const sessionId = file.replace('.jsonl', '');
605
+ const filePath = path.join(projectDir, file);
606
+
607
+ // Skip sessions with no actual messages
608
+ if (!hasActualMessages(filePath)) {
609
+ continue;
610
+ }
611
+
612
+ try {
613
+ const content = fs.readFileSync(filePath, 'utf8');
614
+ const lines = content.split('\n');
615
+ const matches = [];
616
+
617
+ for (const line of lines) {
618
+ if (!line.trim()) continue;
619
+
620
+ try {
621
+ const json = JSON.parse(line);
622
+
623
+ // Search in message content
624
+ if (json.message && json.message.content) {
625
+ const text = json.message.content;
626
+ const lowerText = text.toLowerCase();
627
+ const lowerKeyword = keyword.toLowerCase();
628
+ let index = 0;
629
+
630
+ while ((index = lowerText.indexOf(lowerKeyword, index)) !== -1) {
631
+ // Extract context
632
+ const start = Math.max(0, index - contextLength);
633
+ const end = Math.min(text.length, index + keyword.length + contextLength);
634
+ const context = text.substring(start, end);
635
+
636
+ matches.push({
637
+ role: json.message.role || 'unknown',
638
+ context: (start > 0 ? '...' : '') + context + (end < text.length ? '...' : ''),
639
+ position: index
640
+ });
641
+
642
+ index += keyword.length;
643
+ }
644
+ }
645
+ } catch (e) {
646
+ // Skip invalid JSON lines
647
+ }
648
+ }
649
+
650
+ if (matches.length > 0) {
651
+ results.push({
652
+ sessionId,
653
+ alias: aliases[sessionId] || null,
654
+ matchCount: matches.length,
655
+ matches: matches.slice(0, 5) // Limit to 5 matches per session
656
+ });
657
+ }
658
+ } catch (e) {
659
+ // Skip files that can't be read
660
+ }
661
+ }
662
+
663
+ // Sort by match count
664
+ results.sort((a, b) => b.matchCount - a.matchCount);
665
+
666
+ return results;
667
+ }
668
+
669
+ // Get recent sessions across all projects
670
+ function getRecentSessions(config, limit = 5) {
671
+ const projects = getProjects(config);
672
+ const allSessions = [];
673
+ const forkRelations = getForkRelations();
674
+ const aliases = loadAliases();
675
+
676
+ // Collect all sessions from all projects
677
+ projects.forEach(projectName => {
678
+ const projectConfig = { ...config, currentProject: projectName };
679
+ const sessions = getAllSessions(projectConfig);
680
+ const { projectName: displayName, fullPath } = parseRealProjectPath(projectName);
681
+
682
+ sessions.forEach(session => {
683
+ // Skip sessions with no actual messages
684
+ if (!hasActualMessages(session.filePath)) {
685
+ return;
686
+ }
687
+
688
+ const info = parseSessionInfoFast(session.filePath);
689
+ allSessions.push({
690
+ sessionId: session.sessionId,
691
+ projectName: projectName,
692
+ projectDisplayName: displayName,
693
+ projectFullPath: fullPath,
694
+ mtime: session.mtime,
695
+ size: session.size,
696
+ filePath: session.filePath,
697
+ gitBranch: info.gitBranch || null,
698
+ firstMessage: info.firstMessage || null,
699
+ forkedFrom: forkRelations[session.sessionId] || null,
700
+ alias: aliases[session.sessionId] || null
701
+ });
702
+ });
703
+ });
704
+
705
+ // Sort by mtime descending (most recent first)
706
+ allSessions.sort((a, b) => b.mtime - a.mtime);
707
+
708
+ // Return top N sessions
709
+ return allSessions.slice(0, limit);
710
+ }
711
+
712
+ // Search sessions across all projects
713
+ function searchSessionsAcrossProjects(config, keyword, contextLength = 35) {
714
+ const projects = getProjects(config);
715
+ const allResults = [];
716
+
717
+ projects.forEach(projectName => {
718
+ const projectResults = searchSessions(config, projectName, keyword, contextLength);
719
+ const { projectName: displayName, fullPath } = parseRealProjectPath(projectName);
720
+
721
+ // Add project info to each result
722
+ projectResults.forEach(result => {
723
+ allResults.push({
724
+ ...result,
725
+ projectName: projectName,
726
+ projectDisplayName: displayName,
727
+ projectFullPath: fullPath
728
+ });
729
+ });
730
+ });
731
+
732
+ // Sort by match count
733
+ allResults.sort((a, b) => b.matchCount - a.matchCount);
734
+
735
+ return allResults;
736
+ }
737
+
738
+ module.exports = {
739
+ getProjects,
740
+ getProjectsWithStats,
741
+ getSessionsForProject,
742
+ deleteSession,
743
+ forkSession,
744
+ getRecentSessions,
745
+ getProjectOrder,
746
+ saveProjectOrder,
747
+ getSessionOrder,
748
+ saveSessionOrder,
749
+ deleteProject,
750
+ parseRealProjectPath,
751
+ searchSessions,
752
+ searchSessionsAcrossProjects,
753
+ getForkRelations,
754
+ saveForkRelations,
755
+ hasActualMessages,
756
+ getProjectAndSessionCounts
757
+ };