@mmmbuto/nexuscli 0.5.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 (148) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -0
  3. package/bin/nexuscli.js +117 -0
  4. package/frontend/dist/apple-touch-icon.png +0 -0
  5. package/frontend/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  6. package/frontend/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  7. package/frontend/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  8. package/frontend/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  9. package/frontend/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  10. package/frontend/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  11. package/frontend/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  12. package/frontend/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  13. package/frontend/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  14. package/frontend/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  15. package/frontend/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  16. package/frontend/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  17. package/frontend/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  18. package/frontend/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  19. package/frontend/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  20. package/frontend/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  21. package/frontend/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  22. package/frontend/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  23. package/frontend/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  24. package/frontend/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  25. package/frontend/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  26. package/frontend/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  27. package/frontend/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  28. package/frontend/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  29. package/frontend/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  30. package/frontend/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  31. package/frontend/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  32. package/frontend/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  33. package/frontend/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  34. package/frontend/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  35. package/frontend/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  36. package/frontend/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  37. package/frontend/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  38. package/frontend/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  39. package/frontend/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  40. package/frontend/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  41. package/frontend/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  42. package/frontend/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  43. package/frontend/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  44. package/frontend/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  45. package/frontend/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  46. package/frontend/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  47. package/frontend/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  48. package/frontend/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  49. package/frontend/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  50. package/frontend/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  51. package/frontend/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  52. package/frontend/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  53. package/frontend/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  54. package/frontend/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  55. package/frontend/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  56. package/frontend/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  57. package/frontend/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  58. package/frontend/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  59. package/frontend/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  60. package/frontend/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  61. package/frontend/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  62. package/frontend/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  63. package/frontend/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  64. package/frontend/dist/assets/index-Bn_l1e6e.css +1 -0
  65. package/frontend/dist/assets/index-CikJbUR5.js +8617 -0
  66. package/frontend/dist/browserconfig.xml +12 -0
  67. package/frontend/dist/favicon-16x16.png +0 -0
  68. package/frontend/dist/favicon-32x32.png +0 -0
  69. package/frontend/dist/favicon-48x48.png +0 -0
  70. package/frontend/dist/favicon.ico +0 -0
  71. package/frontend/dist/icon-192.png +0 -0
  72. package/frontend/dist/icon-512.png +0 -0
  73. package/frontend/dist/icon-maskable-192.png +0 -0
  74. package/frontend/dist/icon-maskable-512.png +0 -0
  75. package/frontend/dist/index.html +79 -0
  76. package/frontend/dist/manifest.json +75 -0
  77. package/frontend/dist/sw.js +122 -0
  78. package/frontend/package.json +28 -0
  79. package/lib/cli/api.js +156 -0
  80. package/lib/cli/boot.js +172 -0
  81. package/lib/cli/config.js +185 -0
  82. package/lib/cli/engines.js +257 -0
  83. package/lib/cli/init.js +660 -0
  84. package/lib/cli/logs.js +72 -0
  85. package/lib/cli/start.js +220 -0
  86. package/lib/cli/status.js +187 -0
  87. package/lib/cli/stop.js +64 -0
  88. package/lib/cli/uninstall.js +194 -0
  89. package/lib/cli/users.js +295 -0
  90. package/lib/cli/workspaces.js +337 -0
  91. package/lib/config/manager.js +233 -0
  92. package/lib/server/.env.example +20 -0
  93. package/lib/server/db/adapter.js +314 -0
  94. package/lib/server/db/drivers/better-sqlite3.js +38 -0
  95. package/lib/server/db/drivers/sql-js.js +75 -0
  96. package/lib/server/db/migrate.js +174 -0
  97. package/lib/server/db/migrations/001_ultra_light_schema.sql +96 -0
  98. package/lib/server/db/migrations/002_session_conversation_mapping.sql +19 -0
  99. package/lib/server/db/migrations/003_message_engine_tracking.sql +18 -0
  100. package/lib/server/db/migrations/004_performance_indexes.sql +16 -0
  101. package/lib/server/db.js +2 -0
  102. package/lib/server/lib/cli-wrapper.js +164 -0
  103. package/lib/server/lib/output-parser.js +132 -0
  104. package/lib/server/lib/pty-adapter.js +57 -0
  105. package/lib/server/middleware/auth.js +103 -0
  106. package/lib/server/models/Conversation.js +259 -0
  107. package/lib/server/models/Message.js +228 -0
  108. package/lib/server/models/User.js +115 -0
  109. package/lib/server/package-lock.json +5895 -0
  110. package/lib/server/routes/auth.js +168 -0
  111. package/lib/server/routes/chat.js +206 -0
  112. package/lib/server/routes/codex.js +205 -0
  113. package/lib/server/routes/conversations.js +224 -0
  114. package/lib/server/routes/gemini.js +228 -0
  115. package/lib/server/routes/jobs.js +317 -0
  116. package/lib/server/routes/messages.js +60 -0
  117. package/lib/server/routes/models.js +198 -0
  118. package/lib/server/routes/sessions.js +285 -0
  119. package/lib/server/routes/upload.js +134 -0
  120. package/lib/server/routes/wake-lock.js +95 -0
  121. package/lib/server/routes/workspace.js +80 -0
  122. package/lib/server/routes/workspaces.js +142 -0
  123. package/lib/server/scripts/cleanup-ghost-sessions.js +71 -0
  124. package/lib/server/scripts/seed-users.js +37 -0
  125. package/lib/server/scripts/test-history-access.js +50 -0
  126. package/lib/server/server.js +227 -0
  127. package/lib/server/services/cache.js +85 -0
  128. package/lib/server/services/claude-wrapper.js +312 -0
  129. package/lib/server/services/cli-loader.js +384 -0
  130. package/lib/server/services/codex-output-parser.js +277 -0
  131. package/lib/server/services/codex-wrapper.js +224 -0
  132. package/lib/server/services/context-bridge.js +289 -0
  133. package/lib/server/services/gemini-output-parser.js +398 -0
  134. package/lib/server/services/gemini-wrapper.js +249 -0
  135. package/lib/server/services/history-sync.js +407 -0
  136. package/lib/server/services/output-parser.js +415 -0
  137. package/lib/server/services/session-manager.js +465 -0
  138. package/lib/server/services/summary-generator.js +259 -0
  139. package/lib/server/services/workspace-manager.js +516 -0
  140. package/lib/server/tests/history-sync.test.js +90 -0
  141. package/lib/server/tests/integration-session-sync.test.js +151 -0
  142. package/lib/server/tests/integration.test.js +76 -0
  143. package/lib/server/tests/performance.test.js +118 -0
  144. package/lib/server/tests/services.test.js +160 -0
  145. package/lib/setup/postinstall.js +216 -0
  146. package/lib/utils/paths.js +107 -0
  147. package/lib/utils/termux.js +145 -0
  148. package/package.json +82 -0
@@ -0,0 +1,465 @@
1
+ /**
2
+ * SessionManager - Session Sync Pattern Implementation (TRI CLI v0.4.0)
3
+ *
4
+ * Simplified session management for Claude/Codex/Gemini engines.
5
+ * Principle: FILESYSTEM = SOURCE OF TRUTH
6
+ *
7
+ * Flow:
8
+ * 1. Check if session file exists on filesystem
9
+ * 2. If exists → reuse session
10
+ * 3. If not → create new session, CLI will create file on first message
11
+ *
12
+ * @see docs/PLAN_TRI_CLI_ARCHITECTURE.md
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const { v4: uuidv4 } = require('uuid');
18
+ const { prepare, saveDb } = require('../db');
19
+
20
+ // Engine-specific session directories
21
+ const SESSION_DIRS = {
22
+ claude: path.join(process.env.HOME || '', '.claude', 'projects'),
23
+ codex: path.join(process.env.HOME || '', '.codex', 'sessions'),
24
+ gemini: path.join(process.env.HOME || '', '.gemini', 'sessions'),
25
+ };
26
+
27
+ class SessionManager {
28
+ constructor() {
29
+ // RAM cache: Map<`${conversationId}:${engine}`, sessionId>
30
+ this.sessionMap = new Map();
31
+
32
+ // Track last access for cache cleanup
33
+ this.lastAccess = new Map();
34
+
35
+ // Cache TTL (30 minutes)
36
+ this.cacheTTL = 30 * 60 * 1000;
37
+ }
38
+
39
+ /**
40
+ * Get cache key for conversation + engine
41
+ */
42
+ _getCacheKey(conversationId, engine) {
43
+ return `${conversationId}:${engine}`;
44
+ }
45
+
46
+ /**
47
+ * Check if session file exists on disk
48
+ * Claude: ~/.claude/projects/<workspace-slug>/<sessionId>.jsonl
49
+ * Codex: ~/.codex/sessions/<sessionId>.jsonl (if available)
50
+ * Gemini: ~/.gemini/sessions/<sessionId>.jsonl (if available)
51
+ */
52
+ sessionFileExists(sessionId, engine, workspacePath) {
53
+ try {
54
+ const sessionPath = this.getSessionFilePath(sessionId, engine, workspacePath);
55
+ if (!sessionPath) return false;
56
+ return fs.existsSync(sessionPath);
57
+ } catch (error) {
58
+ console.warn(`[SessionManager] Error checking session file:`, error.message);
59
+ return false;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Get the full path to a session file
65
+ * @returns {string|null} Full path or null if engine not supported
66
+ */
67
+ getSessionFilePath(sessionId, engine, workspacePath) {
68
+ const normalizedEngine = this._normalizeEngine(engine);
69
+
70
+ switch (normalizedEngine) {
71
+ case 'claude':
72
+ // Claude stores sessions per workspace: ~/.claude/projects/<slug>/<sessionId>.jsonl
73
+ const slug = this._pathToSlug(workspacePath);
74
+ return path.join(SESSION_DIRS.claude, slug, `${sessionId}.jsonl`);
75
+
76
+ case 'codex':
77
+ // Codex may store sessions globally: ~/.codex/sessions/<sessionId>.jsonl
78
+ return path.join(SESSION_DIRS.codex, `${sessionId}.jsonl`);
79
+
80
+ case 'gemini':
81
+ // Gemini sessions: ~/.gemini/sessions/<sessionId>.jsonl
82
+ return path.join(SESSION_DIRS.gemini, `${sessionId}.jsonl`);
83
+
84
+ default:
85
+ console.warn(`[SessionManager] Unknown engine: ${engine}`);
86
+ return null;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Normalize engine name (handle variants like 'claude-code')
92
+ */
93
+ _normalizeEngine(engine) {
94
+ if (!engine) return 'claude';
95
+ const lower = engine.toLowerCase();
96
+ if (lower.includes('claude')) return 'claude';
97
+ if (lower.includes('codex') || lower.includes('openai')) return 'codex';
98
+ if (lower.includes('gemini') || lower.includes('google')) return 'gemini';
99
+ return lower;
100
+ }
101
+
102
+ /**
103
+ * Convert workspace path to slug (for .claude/projects/ directory)
104
+ * Same as Claude Code: /path/to/dir → -path-to-dir
105
+ * Fixed: dots are preserved to avoid collisions
106
+ */
107
+ _pathToSlug(workspacePath) {
108
+ if (!workspacePath) return '-default';
109
+ // Replace only slashes with dashes, preserve dots
110
+ return workspacePath.replace(/\//g, '-');
111
+ }
112
+
113
+ /**
114
+ * Generate workspace hash (legacy method, kept for compatibility)
115
+ * @deprecated Use _pathToSlug instead (matches Claude Code behavior)
116
+ */
117
+ _getWorkspaceHash(workspacePath) {
118
+ const crypto = require('crypto');
119
+ return crypto.createHash('md5').update(workspacePath).digest('hex').substring(0, 8);
120
+ }
121
+
122
+ /**
123
+ * Get or create session for conversation + engine
124
+ *
125
+ * SIMPLIFIED FLOW (TRI CLI v0.4.0):
126
+ * 1. Check RAM cache (fastest)
127
+ * 2. Check DB for existing mapping
128
+ * 3. Verify session file exists on filesystem (source of truth)
129
+ * 4. If invalid/missing → create new session
130
+ * 5. Save to DB + RAM cache
131
+ *
132
+ * @param {string} conversationId - Frontend conversation ID
133
+ * @param {string} engine - 'claude' | 'codex' | 'gemini'
134
+ * @param {string} workspacePath - Workspace directory path
135
+ * @returns {{ sessionId: string, isNew: boolean }}
136
+ */
137
+ async getOrCreateSession(conversationId, engine, workspacePath) {
138
+ const normalizedEngine = this._normalizeEngine(engine);
139
+ const cacheKey = this._getCacheKey(conversationId, normalizedEngine);
140
+
141
+ console.log(`[SessionManager] getOrCreateSession(${conversationId}, ${normalizedEngine}, ${workspacePath})`);
142
+
143
+ // 1. Check RAM cache first (fastest path)
144
+ if (this.sessionMap.has(cacheKey)) {
145
+ const cachedId = this.sessionMap.get(cacheKey);
146
+
147
+ // Verify it still exists on filesystem
148
+ if (this.sessionFileExists(cachedId, normalizedEngine, workspacePath)) {
149
+ this.lastAccess.set(cacheKey, Date.now());
150
+ console.log(`[SessionManager] Cache hit: ${cachedId}`);
151
+ return { sessionId: cachedId, isNew: false };
152
+ }
153
+
154
+ // Invalid cache entry - remove it
155
+ console.log(`[SessionManager] Cache entry invalid, removing: ${cachedId}`);
156
+ this.sessionMap.delete(cacheKey);
157
+ }
158
+
159
+ // 2. Check DB for existing session mapping
160
+ try {
161
+ const stmt = prepare(`
162
+ SELECT id, workspace_path FROM sessions
163
+ WHERE conversation_id = ? AND engine = ?
164
+ `);
165
+ const row = stmt.get(conversationId, normalizedEngine);
166
+
167
+ if (row) {
168
+ // 3. Verify session file exists on filesystem
169
+ if (this.sessionFileExists(row.id, normalizedEngine, row.workspace_path || workspacePath)) {
170
+ // Valid session - update cache and return
171
+ this.sessionMap.set(cacheKey, row.id);
172
+ this.lastAccess.set(cacheKey, Date.now());
173
+ console.log(`[SessionManager] DB hit, verified: ${row.id}`);
174
+ return { sessionId: row.id, isNew: false };
175
+ }
176
+
177
+ // Session file deleted - clean up DB entry
178
+ console.log(`[SessionManager] Session ${row.id} file missing, cleaning up`);
179
+ this._deleteSession(row.id);
180
+ }
181
+ } catch (dbErr) {
182
+ console.warn(`[SessionManager] DB lookup failed:`, dbErr.message);
183
+ }
184
+
185
+ // 4. Create new session (file will be created by CLI on first message)
186
+ const sessionId = uuidv4();
187
+ const now = Date.now();
188
+ console.log(`[SessionManager] Creating new session: ${sessionId} (${normalizedEngine})`);
189
+
190
+ // 5. Save to DB (metadata only - file created by CLI)
191
+ try {
192
+ const insertStmt = prepare(`
193
+ INSERT INTO sessions (id, workspace_path, engine, conversation_id, title, created_at, last_used_at)
194
+ VALUES (?, ?, ?, ?, ?, ?, ?)
195
+ `);
196
+ const title = 'New Chat'; // Will be updated after first response
197
+ insertStmt.run(sessionId, workspacePath, normalizedEngine, conversationId, title, now, now);
198
+ saveDb();
199
+ console.log(`[SessionManager] Saved to DB: ${sessionId}`);
200
+ } catch (dbErr) {
201
+ console.error(`[SessionManager] DB save failed (continuing):`, dbErr.message);
202
+ // Continue - session will work, just not persisted in DB
203
+ }
204
+
205
+ // 6. Save to RAM cache
206
+ this.sessionMap.set(cacheKey, sessionId);
207
+ this.lastAccess.set(cacheKey, Date.now());
208
+
209
+ return { sessionId, isNew: true };
210
+ }
211
+
212
+ /**
213
+ * Get existing session without creating new one
214
+ */
215
+ getSession(conversationId, engine) {
216
+ const cacheKey = this._getCacheKey(conversationId, engine);
217
+
218
+ // Check cache first
219
+ if (this.sessionMap.has(cacheKey)) {
220
+ this.lastAccess.set(cacheKey, Date.now());
221
+ return this.sessionMap.get(cacheKey);
222
+ }
223
+
224
+ // Check DB
225
+ try {
226
+ const stmt = prepare(`
227
+ SELECT id FROM sessions
228
+ WHERE conversation_id = ? AND engine = ?
229
+ `);
230
+ const row = stmt.get(conversationId, engine);
231
+
232
+ if (row) {
233
+ this.sessionMap.set(cacheKey, row.id);
234
+ this.lastAccess.set(cacheKey, Date.now());
235
+ return row.id;
236
+ }
237
+ } catch (dbErr) {
238
+ console.warn(`[SessionManager] DB lookup failed:`, dbErr.message);
239
+ }
240
+
241
+ return null;
242
+ }
243
+
244
+ /**
245
+ * Delete session from DB
246
+ */
247
+ _deleteSession(sessionId) {
248
+ try {
249
+ const stmt = prepare('DELETE FROM sessions WHERE id = ?');
250
+ stmt.run(sessionId);
251
+ saveDb();
252
+ console.log(`[SessionManager] Deleted session: ${sessionId}`);
253
+ } catch (error) {
254
+ console.error(`[SessionManager] Failed to delete session:`, error.message);
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Convert workspace path to slug (matches Claude Code behavior)
260
+ * /path/to/dir → -path-to-dir (also converts dots to dashes)
261
+ */
262
+ _pathToSlug(workspacePath) {
263
+ if (!workspacePath) return '-default';
264
+ return workspacePath.replace(/[\/\.]/g, '-');
265
+ }
266
+
267
+ /**
268
+ * Delete all sessions for a conversation (cleanup)
269
+ * Called when conversation is deleted
270
+ * SYNC DELETE: Also removes the original .jsonl session files
271
+ */
272
+ deleteConversationSessions(conversationId) {
273
+ console.log(`[SessionManager] Deleting all sessions for conversation: ${conversationId}`);
274
+
275
+ try {
276
+ // Get all sessions with workspace_path for file deletion
277
+ const selectStmt = prepare('SELECT id, engine, workspace_path FROM sessions WHERE conversation_id = ?');
278
+ const sessions = selectStmt.all(conversationId);
279
+
280
+ let filesDeleted = 0;
281
+
282
+ // Remove from cache AND delete session files
283
+ for (const session of sessions) {
284
+ const cacheKey = this._getCacheKey(conversationId, session.engine);
285
+ this.sessionMap.delete(cacheKey);
286
+ this.lastAccess.delete(cacheKey);
287
+
288
+ // Delete the original .jsonl file (SYNC DELETE)
289
+ const sessionFile = this._getSessionFilePath(session.id, session.engine, session.workspace_path);
290
+ if (sessionFile && fs.existsSync(sessionFile)) {
291
+ try {
292
+ fs.unlinkSync(sessionFile);
293
+ filesDeleted++;
294
+ console.log(`[SessionManager] Deleted session file: ${sessionFile}`);
295
+ } catch (e) {
296
+ console.warn(`[SessionManager] Failed to delete file ${sessionFile}: ${e.message}`);
297
+ }
298
+ }
299
+ }
300
+
301
+ // Delete from DB
302
+ const deleteStmt = prepare('DELETE FROM sessions WHERE conversation_id = ?');
303
+ deleteStmt.run(conversationId);
304
+ saveDb();
305
+
306
+ console.log(`[SessionManager] Deleted ${sessions.length} sessions (${filesDeleted} files)`);
307
+ return sessions.length;
308
+ } catch (error) {
309
+ console.error(`[SessionManager] Failed to delete conversation sessions:`, error.message);
310
+ return 0;
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Get the filesystem path for a session file
316
+ */
317
+ _getSessionFilePath(sessionId, engine, workspacePath) {
318
+ const normalizedEngine = engine?.toLowerCase().includes('claude') ? 'claude'
319
+ : engine?.toLowerCase().includes('codex') ? 'codex'
320
+ : engine?.toLowerCase().includes('gemini') ? 'gemini'
321
+ : 'claude';
322
+
323
+ switch (normalizedEngine) {
324
+ case 'claude':
325
+ const slug = this._pathToSlug(workspacePath);
326
+ return path.join(SESSION_DIRS.claude, slug, `${sessionId}.jsonl`);
327
+ case 'codex':
328
+ return path.join(SESSION_DIRS.codex, `${sessionId}.jsonl`);
329
+ case 'gemini':
330
+ return path.join(SESSION_DIRS.gemini, `${sessionId}.jsonl`);
331
+ default:
332
+ return null;
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Update session title (called after first user message)
338
+ * @param {string} sessionId
339
+ * @param {string} title
340
+ * @returns {boolean}
341
+ */
342
+ updateSessionTitle(sessionId, title) {
343
+ try {
344
+ const stmt = prepare(`UPDATE sessions SET title = ? WHERE id = ?`);
345
+ stmt.run(title, sessionId);
346
+ saveDb();
347
+ console.log(`[SessionManager] Updated session ${sessionId} title: ${title}`);
348
+ return true;
349
+ } catch (error) {
350
+ console.error(`[SessionManager] Failed to update title:`, error.message);
351
+ return false;
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Extract title from message (50 chars, word boundary)
357
+ * @param {string} message
358
+ * @returns {string}
359
+ */
360
+ extractTitle(message) {
361
+ if (!message || message.trim() === '') return 'New Chat';
362
+
363
+ // Clean up: normalize whitespace, remove newlines
364
+ const cleaned = message.replace(/\s+/g, ' ').trim();
365
+
366
+ if (cleaned.length <= 50) {
367
+ return cleaned;
368
+ }
369
+
370
+ const truncated = cleaned.substring(0, 50);
371
+ const lastSpace = truncated.lastIndexOf(' ');
372
+
373
+ // Cut at word boundary if space found after first 20 chars
374
+ if (lastSpace > 20) {
375
+ return truncated.substring(0, lastSpace) + '...';
376
+ }
377
+
378
+ return truncated + '...';
379
+ }
380
+
381
+ /**
382
+ * Update session's conversation_id mapping
383
+ * Used when a new conversation needs to use an existing session
384
+ */
385
+ updateSessionConversation(sessionId, conversationId, engine) {
386
+ try {
387
+ const stmt = prepare(`
388
+ UPDATE sessions SET conversation_id = ?, last_used_at = ? WHERE id = ?
389
+ `);
390
+ stmt.run(conversationId, Date.now(), sessionId);
391
+ saveDb();
392
+
393
+ // Update cache
394
+ const cacheKey = this._getCacheKey(conversationId, engine);
395
+ this.sessionMap.set(cacheKey, sessionId);
396
+ this.lastAccess.set(cacheKey, Date.now());
397
+
398
+ console.log(`[SessionManager] Updated session ${sessionId} → conversation ${conversationId}`);
399
+ return true;
400
+ } catch (error) {
401
+ console.error(`[SessionManager] Failed to update session:`, error.message);
402
+ return false;
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Get all sessions for a conversation
408
+ */
409
+ getConversationSessions(conversationId) {
410
+ try {
411
+ const stmt = prepare(`
412
+ SELECT id, engine, workspace_path, created_at, updated_at
413
+ FROM sessions
414
+ WHERE conversation_id = ?
415
+ `);
416
+ return stmt.all(conversationId);
417
+ } catch (error) {
418
+ console.error(`[SessionManager] Failed to get conversation sessions:`, error.message);
419
+ return [];
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Clean expired cache entries
425
+ */
426
+ cleanCache() {
427
+ const now = Date.now();
428
+ let cleaned = 0;
429
+
430
+ for (const [key, lastAccess] of this.lastAccess.entries()) {
431
+ if (now - lastAccess > this.cacheTTL) {
432
+ this.sessionMap.delete(key);
433
+ this.lastAccess.delete(key);
434
+ cleaned++;
435
+ }
436
+ }
437
+
438
+ if (cleaned > 0) {
439
+ console.log(`[SessionManager] Cleaned ${cleaned} expired cache entries`);
440
+ }
441
+
442
+ return cleaned;
443
+ }
444
+
445
+ /**
446
+ * Get cache statistics
447
+ */
448
+ getStats() {
449
+ return {
450
+ cacheSize: this.sessionMap.size,
451
+ cacheTTL: this.cacheTTL,
452
+ timestamp: new Date().toISOString()
453
+ };
454
+ }
455
+ }
456
+
457
+ // Singleton instance
458
+ const sessionManager = new SessionManager();
459
+
460
+ // Periodic cache cleanup (every 10 minutes)
461
+ setInterval(() => {
462
+ sessionManager.cleanCache();
463
+ }, 10 * 60 * 1000);
464
+
465
+ module.exports = sessionManager;