@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.
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/bin/nexuscli.js +117 -0
- package/frontend/dist/apple-touch-icon.png +0 -0
- package/frontend/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/frontend/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/frontend/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/frontend/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/frontend/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/frontend/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/frontend/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/frontend/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/frontend/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/frontend/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/frontend/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/frontend/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/frontend/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/frontend/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/frontend/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/frontend/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/frontend/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/frontend/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/frontend/dist/assets/index-Bn_l1e6e.css +1 -0
- package/frontend/dist/assets/index-CikJbUR5.js +8617 -0
- package/frontend/dist/browserconfig.xml +12 -0
- package/frontend/dist/favicon-16x16.png +0 -0
- package/frontend/dist/favicon-32x32.png +0 -0
- package/frontend/dist/favicon-48x48.png +0 -0
- package/frontend/dist/favicon.ico +0 -0
- package/frontend/dist/icon-192.png +0 -0
- package/frontend/dist/icon-512.png +0 -0
- package/frontend/dist/icon-maskable-192.png +0 -0
- package/frontend/dist/icon-maskable-512.png +0 -0
- package/frontend/dist/index.html +79 -0
- package/frontend/dist/manifest.json +75 -0
- package/frontend/dist/sw.js +122 -0
- package/frontend/package.json +28 -0
- package/lib/cli/api.js +156 -0
- package/lib/cli/boot.js +172 -0
- package/lib/cli/config.js +185 -0
- package/lib/cli/engines.js +257 -0
- package/lib/cli/init.js +660 -0
- package/lib/cli/logs.js +72 -0
- package/lib/cli/start.js +220 -0
- package/lib/cli/status.js +187 -0
- package/lib/cli/stop.js +64 -0
- package/lib/cli/uninstall.js +194 -0
- package/lib/cli/users.js +295 -0
- package/lib/cli/workspaces.js +337 -0
- package/lib/config/manager.js +233 -0
- package/lib/server/.env.example +20 -0
- package/lib/server/db/adapter.js +314 -0
- package/lib/server/db/drivers/better-sqlite3.js +38 -0
- package/lib/server/db/drivers/sql-js.js +75 -0
- package/lib/server/db/migrate.js +174 -0
- package/lib/server/db/migrations/001_ultra_light_schema.sql +96 -0
- package/lib/server/db/migrations/002_session_conversation_mapping.sql +19 -0
- package/lib/server/db/migrations/003_message_engine_tracking.sql +18 -0
- package/lib/server/db/migrations/004_performance_indexes.sql +16 -0
- package/lib/server/db.js +2 -0
- package/lib/server/lib/cli-wrapper.js +164 -0
- package/lib/server/lib/output-parser.js +132 -0
- package/lib/server/lib/pty-adapter.js +57 -0
- package/lib/server/middleware/auth.js +103 -0
- package/lib/server/models/Conversation.js +259 -0
- package/lib/server/models/Message.js +228 -0
- package/lib/server/models/User.js +115 -0
- package/lib/server/package-lock.json +5895 -0
- package/lib/server/routes/auth.js +168 -0
- package/lib/server/routes/chat.js +206 -0
- package/lib/server/routes/codex.js +205 -0
- package/lib/server/routes/conversations.js +224 -0
- package/lib/server/routes/gemini.js +228 -0
- package/lib/server/routes/jobs.js +317 -0
- package/lib/server/routes/messages.js +60 -0
- package/lib/server/routes/models.js +198 -0
- package/lib/server/routes/sessions.js +285 -0
- package/lib/server/routes/upload.js +134 -0
- package/lib/server/routes/wake-lock.js +95 -0
- package/lib/server/routes/workspace.js +80 -0
- package/lib/server/routes/workspaces.js +142 -0
- package/lib/server/scripts/cleanup-ghost-sessions.js +71 -0
- package/lib/server/scripts/seed-users.js +37 -0
- package/lib/server/scripts/test-history-access.js +50 -0
- package/lib/server/server.js +227 -0
- package/lib/server/services/cache.js +85 -0
- package/lib/server/services/claude-wrapper.js +312 -0
- package/lib/server/services/cli-loader.js +384 -0
- package/lib/server/services/codex-output-parser.js +277 -0
- package/lib/server/services/codex-wrapper.js +224 -0
- package/lib/server/services/context-bridge.js +289 -0
- package/lib/server/services/gemini-output-parser.js +398 -0
- package/lib/server/services/gemini-wrapper.js +249 -0
- package/lib/server/services/history-sync.js +407 -0
- package/lib/server/services/output-parser.js +415 -0
- package/lib/server/services/session-manager.js +465 -0
- package/lib/server/services/summary-generator.js +259 -0
- package/lib/server/services/workspace-manager.js +516 -0
- package/lib/server/tests/history-sync.test.js +90 -0
- package/lib/server/tests/integration-session-sync.test.js +151 -0
- package/lib/server/tests/integration.test.js +76 -0
- package/lib/server/tests/performance.test.js +118 -0
- package/lib/server/tests/services.test.js +160 -0
- package/lib/setup/postinstall.js +216 -0
- package/lib/utils/paths.js +107 -0
- package/lib/utils/termux.js +145 -0
- 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;
|