@mmmbuto/nexuscli 0.7.6 → 0.7.8
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/README.md +23 -27
- package/frontend/dist/assets/{index-CikJbUR5.js → index-BbBoc8w4.js} +1704 -1704
- package/frontend/dist/assets/{index-Bn_l1e6e.css → index-WfmfixF4.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/lib/server/routes/chat.js +70 -8
- package/lib/server/routes/codex.js +70 -9
- package/lib/server/routes/gemini.js +75 -15
- package/lib/server/routes/sessions.js +22 -2
- package/lib/server/server.js +11 -0
- package/lib/server/services/codex-wrapper.js +14 -5
- package/lib/server/services/context-bridge.js +165 -42
- package/lib/server/services/gemini-wrapper.js +28 -9
- package/lib/server/services/session-importer.js +155 -0
- package/lib/server/services/session-manager.js +20 -0
- package/lib/server/services/workspace-manager.js +3 -8
- package/package.json +1 -1
|
@@ -53,59 +53,80 @@ class ContextBridge {
|
|
|
53
53
|
/**
|
|
54
54
|
* Build optimized context for engine switch
|
|
55
55
|
* @param {Object} params
|
|
56
|
-
* @param {string} params.
|
|
56
|
+
* @param {string} params.conversationId - Stable conversation ID (cross-engine)
|
|
57
|
+
* @param {string} params.sessionId - Legacy session ID (fallback)
|
|
57
58
|
* @param {string} params.fromEngine - Previous engine
|
|
58
59
|
* @param {string} params.toEngine - Target engine
|
|
59
60
|
* @param {string} params.userMessage - Current user message
|
|
60
61
|
* @returns {Object} { prompt, isEngineBridge, contextTokens }
|
|
61
62
|
*/
|
|
62
|
-
async buildContext({ sessionId, fromEngine, toEngine, userMessage }) {
|
|
63
|
+
async buildContext({ conversationId, sessionId, fromEngine, toEngine, userMessage }) {
|
|
63
64
|
const config = this.getEngineConfig(toEngine);
|
|
64
65
|
const isEngineBridge = fromEngine && fromEngine !== toEngine;
|
|
66
|
+
const convoId = conversationId || sessionId; // backward compat
|
|
65
67
|
|
|
66
68
|
// Reserve tokens for user message
|
|
67
69
|
const userTokens = this.estimateTokens(userMessage);
|
|
68
|
-
const availableTokens = config.maxTokens - userTokens - 200; // 200 token buffer
|
|
70
|
+
const availableTokens = Math.max(0, config.maxTokens - userTokens - 200); // 200 token buffer, never negative
|
|
69
71
|
|
|
70
72
|
let contextText = '';
|
|
71
73
|
let contextTokens = 0;
|
|
72
74
|
let contextSource = 'none';
|
|
73
75
|
|
|
74
|
-
//
|
|
75
|
-
if (
|
|
76
|
-
const
|
|
77
|
-
if (
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
76
|
+
// For engine bridge, use structured handoff template
|
|
77
|
+
if (isEngineBridge) {
|
|
78
|
+
const handoffContext = this.buildEngineHandoffContext(convoId, fromEngine, toEngine, availableTokens, config);
|
|
79
|
+
if (handoffContext.text) {
|
|
80
|
+
contextText = handoffContext.text;
|
|
81
|
+
contextTokens = handoffContext.tokens;
|
|
82
|
+
contextSource = handoffContext.source;
|
|
83
|
+
}
|
|
84
|
+
// If handoff couldn't fit, fall back to history-only context
|
|
85
|
+
if (!contextText && availableTokens > 200) {
|
|
86
|
+
const historyContext = this.buildTokenAwareHistory(convoId, availableTokens, config);
|
|
87
|
+
if (historyContext.text) {
|
|
88
|
+
contextText = historyContext.text;
|
|
89
|
+
contextTokens = historyContext.tokens;
|
|
90
|
+
contextSource = 'history_fallback';
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
// Try summary first (most efficient)
|
|
95
|
+
if (config.preferSummary) {
|
|
96
|
+
const summaryContext = this.summaryGenerator.getBridgeContext(convoId);
|
|
97
|
+
if (summaryContext) {
|
|
98
|
+
const summaryTokens = this.estimateTokens(summaryContext);
|
|
99
|
+
if (summaryTokens <= availableTokens) {
|
|
100
|
+
contextText = summaryContext;
|
|
101
|
+
contextTokens = summaryTokens;
|
|
102
|
+
contextSource = 'summary';
|
|
103
|
+
}
|
|
83
104
|
}
|
|
84
105
|
}
|
|
85
|
-
}
|
|
86
106
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
107
|
+
// Fallback to token-aware message history
|
|
108
|
+
if (!contextText && availableTokens > 200) {
|
|
109
|
+
const historyContext = this.buildTokenAwareHistory(convoId, availableTokens, config);
|
|
110
|
+
if (historyContext.text) {
|
|
111
|
+
contextText = historyContext.text;
|
|
112
|
+
contextTokens = historyContext.tokens;
|
|
113
|
+
contextSource = 'history';
|
|
114
|
+
}
|
|
94
115
|
}
|
|
95
116
|
}
|
|
96
117
|
|
|
97
118
|
// Build final prompt
|
|
98
119
|
let prompt = userMessage;
|
|
99
120
|
|
|
121
|
+
if (availableTokens === 0) {
|
|
122
|
+
console.log(`[ContextBridge] Budget exhausted before context; sending raw message (engine=${toEngine})`);
|
|
123
|
+
}
|
|
124
|
+
|
|
100
125
|
if (contextText) {
|
|
101
|
-
|
|
102
|
-
prompt = `${contextText}\n\n[Switching from ${fromEngine} to ${toEngine}]\n\nUser message:\n${userMessage}`;
|
|
103
|
-
} else {
|
|
104
|
-
prompt = `${contextText}\n\nUser message:\n${userMessage}`;
|
|
105
|
-
}
|
|
126
|
+
prompt = `${contextText}\n\n${userMessage}`;
|
|
106
127
|
}
|
|
107
128
|
|
|
108
|
-
console.log(`[ContextBridge] Built context: ${contextTokens} tokens from ${contextSource}, bridge: ${isEngineBridge}`);
|
|
129
|
+
console.log(`[ContextBridge] Built context: ${contextTokens} tokens from ${contextSource}, bridge: ${isEngineBridge}, avail=${availableTokens}, total=${contextTokens + userTokens}`);
|
|
109
130
|
|
|
110
131
|
return {
|
|
111
132
|
prompt,
|
|
@@ -116,6 +137,106 @@ class ContextBridge {
|
|
|
116
137
|
};
|
|
117
138
|
}
|
|
118
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Build structured context for engine handoff
|
|
142
|
+
* Uses a clear template that helps the new engine understand previous context
|
|
143
|
+
* @param {string} conversationId
|
|
144
|
+
* @param {string} fromEngine
|
|
145
|
+
* @param {string} toEngine
|
|
146
|
+
* @param {number} maxTokens
|
|
147
|
+
* @param {Object} config
|
|
148
|
+
* @returns {Object} { text, tokens, source }
|
|
149
|
+
*/
|
|
150
|
+
buildEngineHandoffContext(conversationId, fromEngine, toEngine, maxTokens, config = {}) {
|
|
151
|
+
const engineNames = {
|
|
152
|
+
'claude': 'Claude Code (Anthropic)',
|
|
153
|
+
'codex': 'Codex (OpenAI)',
|
|
154
|
+
'gemini': 'Gemini (Google)',
|
|
155
|
+
'deepseek': 'DeepSeek'
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const fromName = engineNames[fromEngine] || fromEngine;
|
|
159
|
+
const toName = engineNames[toEngine] || toEngine;
|
|
160
|
+
|
|
161
|
+
// Get summary if available
|
|
162
|
+
const summary = this.summaryGenerator.getSummary(conversationId);
|
|
163
|
+
|
|
164
|
+
// Get recent messages (last 5)
|
|
165
|
+
const messages = Message.getContextMessages(conversationId, 5);
|
|
166
|
+
const messageCount = Message.countByConversation(conversationId);
|
|
167
|
+
|
|
168
|
+
// Build structured template
|
|
169
|
+
const sections = [];
|
|
170
|
+
|
|
171
|
+
// Header
|
|
172
|
+
sections.push(`<previous_session_context engine="${fromEngine}" total_messages="${messageCount}">`);
|
|
173
|
+
sections.push(`This conversation was previously handled by ${fromName}.`);
|
|
174
|
+
sections.push(`You are now continuing as ${toName}.`);
|
|
175
|
+
sections.push('');
|
|
176
|
+
|
|
177
|
+
// Summary section (if available)
|
|
178
|
+
if (summary && summary.summary_short) {
|
|
179
|
+
sections.push('## Summary');
|
|
180
|
+
sections.push(summary.summary_short);
|
|
181
|
+
sections.push('');
|
|
182
|
+
|
|
183
|
+
// Key decisions
|
|
184
|
+
if (summary.key_decisions && summary.key_decisions.length > 0) {
|
|
185
|
+
sections.push('## Key Decisions');
|
|
186
|
+
summary.key_decisions.slice(0, 5).forEach(d => sections.push(`- ${d}`));
|
|
187
|
+
sections.push('');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Files modified
|
|
191
|
+
if (summary.files_modified && summary.files_modified.length > 0) {
|
|
192
|
+
sections.push('## Files Modified');
|
|
193
|
+
summary.files_modified.slice(0, 10).forEach(f => sections.push(`- ${f}`));
|
|
194
|
+
sections.push('');
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Recent messages (always include for continuity)
|
|
199
|
+
if (messages.length > 0) {
|
|
200
|
+
sections.push('## Recent Messages');
|
|
201
|
+
for (const msg of messages) {
|
|
202
|
+
const role = msg.role === 'user' ? 'User' : 'Assistant';
|
|
203
|
+
const engine = msg.engine ? ` [${msg.engine}]` : '';
|
|
204
|
+
// Truncate long messages
|
|
205
|
+
let content = msg.content || '';
|
|
206
|
+
if (content.length > 500) {
|
|
207
|
+
content = content.substring(0, 500) + '...';
|
|
208
|
+
}
|
|
209
|
+
sections.push(`${role}${engine}: ${content}`);
|
|
210
|
+
sections.push('');
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
sections.push('</previous_session_context>');
|
|
215
|
+
sections.push('');
|
|
216
|
+
sections.push('Continue assisting with the following request:');
|
|
217
|
+
|
|
218
|
+
const text = sections.join('\n');
|
|
219
|
+
const tokens = this.estimateTokens(text);
|
|
220
|
+
|
|
221
|
+
// Check token budget
|
|
222
|
+
if (tokens > maxTokens) {
|
|
223
|
+
// Fallback to simpler context if too long
|
|
224
|
+
console.log(`[ContextBridge] Handoff template too long (${tokens} > ${maxTokens}), using fallback`);
|
|
225
|
+
const fallback = this.buildTokenAwareHistory(conversationId, maxTokens, config);
|
|
226
|
+
return {
|
|
227
|
+
text: fallback.text,
|
|
228
|
+
tokens: fallback.tokens,
|
|
229
|
+
source: 'handoff_fallback_history'
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
text,
|
|
235
|
+
tokens,
|
|
236
|
+
source: summary ? 'handoff+summary' : 'handoff+history'
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
119
240
|
/**
|
|
120
241
|
* Build token-aware history context
|
|
121
242
|
* @param {string} sessionId - Session ID
|
|
@@ -123,9 +244,9 @@ class ContextBridge {
|
|
|
123
244
|
* @param {Object} config - Engine config
|
|
124
245
|
* @returns {Object} { text, tokens, messageCount }
|
|
125
246
|
*/
|
|
126
|
-
buildTokenAwareHistory(
|
|
247
|
+
buildTokenAwareHistory(conversationId, maxTokens, config = {}) {
|
|
127
248
|
// Get more messages than we need, we'll trim
|
|
128
|
-
const messages = Message.getContextMessages(
|
|
249
|
+
const messages = Message.getContextMessages(conversationId, 20);
|
|
129
250
|
|
|
130
251
|
if (messages.length === 0) {
|
|
131
252
|
return { text: '', tokens: 0, messageCount: 0 };
|
|
@@ -139,11 +260,13 @@ class ContextBridge {
|
|
|
139
260
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
140
261
|
const msg = messages[i];
|
|
141
262
|
|
|
142
|
-
// For code-focused engines,
|
|
263
|
+
// For code-focused engines, compress assistant responses to code only
|
|
264
|
+
// BUT always keep user messages for context continuity
|
|
143
265
|
let content = msg.content;
|
|
144
|
-
if (config.codeOnly) {
|
|
145
|
-
|
|
146
|
-
if
|
|
266
|
+
if (config.codeOnly && msg.role === 'assistant') {
|
|
267
|
+
const codeContent = this.extractCodeContent(content);
|
|
268
|
+
// Only use code-only if there's actual code, otherwise keep truncated original
|
|
269
|
+
content = codeContent || (content.length > 500 ? content.substring(0, 500) + '...' : content);
|
|
147
270
|
}
|
|
148
271
|
|
|
149
272
|
// Truncate long messages
|
|
@@ -228,11 +351,11 @@ class ContextBridge {
|
|
|
228
351
|
* @param {boolean} isEngineBridge - Was this an engine switch
|
|
229
352
|
* @returns {boolean} Should generate summary
|
|
230
353
|
*/
|
|
231
|
-
shouldTriggerSummary(
|
|
354
|
+
shouldTriggerSummary(conversationId, isEngineBridge = false) {
|
|
232
355
|
// Always trigger on engine bridge
|
|
233
356
|
if (isEngineBridge) return true;
|
|
234
357
|
|
|
235
|
-
const messageCount = Message.countByConversation(
|
|
358
|
+
const messageCount = Message.countByConversation(conversationId);
|
|
236
359
|
|
|
237
360
|
// Trigger every 10 messages after threshold
|
|
238
361
|
if (messageCount >= SUMMARY_TRIGGER_THRESHOLD && messageCount % 10 === 0) {
|
|
@@ -240,7 +363,7 @@ class ContextBridge {
|
|
|
240
363
|
}
|
|
241
364
|
|
|
242
365
|
// Check if we have a stale summary (older than 20 messages)
|
|
243
|
-
const existingSummary = this.summaryGenerator.getSummary(
|
|
366
|
+
const existingSummary = this.summaryGenerator.getSummary(conversationId);
|
|
244
367
|
if (!existingSummary && messageCount > SUMMARY_TRIGGER_THRESHOLD) {
|
|
245
368
|
return true;
|
|
246
369
|
}
|
|
@@ -253,10 +376,10 @@ class ContextBridge {
|
|
|
253
376
|
* @param {string} sessionId - Session ID
|
|
254
377
|
* @param {string} logPrefix - Log prefix for debugging
|
|
255
378
|
*/
|
|
256
|
-
triggerSummaryGeneration(
|
|
257
|
-
const messages = Message.getByConversation(
|
|
379
|
+
triggerSummaryGeneration(conversationId, logPrefix = '[ContextBridge]') {
|
|
380
|
+
const messages = Message.getByConversation(conversationId, 40);
|
|
258
381
|
|
|
259
|
-
this.summaryGenerator.generateAndSave(
|
|
382
|
+
this.summaryGenerator.generateAndSave(conversationId, messages)
|
|
260
383
|
.then(summary => {
|
|
261
384
|
if (summary) {
|
|
262
385
|
console.log(`${logPrefix} Summary updated: ${summary.summary_short?.substring(0, 50)}...`);
|
|
@@ -272,10 +395,10 @@ class ContextBridge {
|
|
|
272
395
|
* @param {string} sessionId - Session ID
|
|
273
396
|
* @returns {Object} Stats
|
|
274
397
|
*/
|
|
275
|
-
getContextStats(
|
|
276
|
-
const messageCount = Message.countByConversation(
|
|
277
|
-
const lastEngine = Message.getLastEngine(
|
|
278
|
-
const hasSummary = !!this.summaryGenerator.getSummary(
|
|
398
|
+
getContextStats(conversationId) {
|
|
399
|
+
const messageCount = Message.countByConversation(conversationId);
|
|
400
|
+
const lastEngine = Message.getLastEngine(conversationId);
|
|
401
|
+
const hasSummary = !!this.summaryGenerator.getSummary(conversationId);
|
|
279
402
|
|
|
280
403
|
return {
|
|
281
404
|
messageCount,
|
|
@@ -10,13 +10,14 @@
|
|
|
10
10
|
* - -o stream-json: JSON streaming output
|
|
11
11
|
* - --include-directories: Workspace access
|
|
12
12
|
*
|
|
13
|
-
* @version 0.
|
|
13
|
+
* @version 0.5.0 - Extended BaseCliWrapper for interrupt support
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
const fs = require('fs');
|
|
17
17
|
const path = require('path');
|
|
18
18
|
const pty = require('../lib/pty-adapter');
|
|
19
19
|
const GeminiOutputParser = require('./gemini-output-parser');
|
|
20
|
+
const BaseCliWrapper = require('./base-cli-wrapper');
|
|
20
21
|
|
|
21
22
|
// Default model - Gemini 3 Pro Preview
|
|
22
23
|
const DEFAULT_MODEL = 'gemini-3-pro-preview';
|
|
@@ -24,8 +25,9 @@ const DEFAULT_MODEL = 'gemini-3-pro-preview';
|
|
|
24
25
|
// CLI timeout (10 minutes)
|
|
25
26
|
const CLI_TIMEOUT_MS = 600000;
|
|
26
27
|
|
|
27
|
-
class GeminiWrapper {
|
|
28
|
+
class GeminiWrapper extends BaseCliWrapper {
|
|
28
29
|
constructor(options = {}) {
|
|
30
|
+
super(); // Initialize activeProcesses from BaseCliWrapper
|
|
29
31
|
this.geminiPath = this._resolveGeminiPath(options.geminiPath);
|
|
30
32
|
this.workspaceDir = options.workspaceDir || process.cwd();
|
|
31
33
|
|
|
@@ -67,18 +69,19 @@ class GeminiWrapper {
|
|
|
67
69
|
*
|
|
68
70
|
* @param {Object} params
|
|
69
71
|
* @param {string} params.prompt - User message/prompt
|
|
70
|
-
* @param {string} params.
|
|
72
|
+
* @param {string} params.threadId - Native Gemini session ID for resume
|
|
71
73
|
* @param {string} [params.model='gemini-3-pro-preview'] - Model name
|
|
72
74
|
* @param {string} [params.workspacePath] - Workspace directory
|
|
73
75
|
* @param {Function} [params.onStatus] - Callback for status events (SSE streaming)
|
|
74
|
-
* @returns {Promise<{text: string, usage: Object}>}
|
|
76
|
+
* @returns {Promise<{text: string, usage: Object, sessionId: string}>}
|
|
75
77
|
*/
|
|
76
78
|
async sendMessage({
|
|
77
79
|
prompt,
|
|
78
|
-
|
|
80
|
+
threadId,
|
|
79
81
|
model = DEFAULT_MODEL,
|
|
80
82
|
workspacePath,
|
|
81
|
-
onStatus
|
|
83
|
+
onStatus,
|
|
84
|
+
processId: processIdOverride
|
|
82
85
|
}) {
|
|
83
86
|
return new Promise((resolve, reject) => {
|
|
84
87
|
const parser = new GeminiOutputParser();
|
|
@@ -87,16 +90,23 @@ class GeminiWrapper {
|
|
|
87
90
|
const cwd = workspacePath || this.workspaceDir;
|
|
88
91
|
|
|
89
92
|
// Build CLI arguments
|
|
90
|
-
//
|
|
93
|
+
// If threadId exists, use --resume to continue native session
|
|
91
94
|
const args = [
|
|
92
95
|
'-y', // YOLO mode - auto-approve all actions
|
|
93
96
|
'-m', model, // Model selection
|
|
94
97
|
'-o', 'stream-json', // JSON streaming for structured events
|
|
95
|
-
prompt // Prompt as positional argument
|
|
96
98
|
];
|
|
97
99
|
|
|
100
|
+
// Add resume flag if continuing existing session
|
|
101
|
+
if (threadId) {
|
|
102
|
+
args.push('--resume', threadId);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Add prompt as positional argument
|
|
106
|
+
args.push(prompt);
|
|
107
|
+
|
|
98
108
|
console.log(`[GeminiWrapper] Model: ${model}`);
|
|
99
|
-
console.log(`[GeminiWrapper]
|
|
109
|
+
console.log(`[GeminiWrapper] ThreadId: ${threadId || '(new session)'}`);
|
|
100
110
|
console.log(`[GeminiWrapper] CWD: ${cwd}`);
|
|
101
111
|
console.log(`[GeminiWrapper] Prompt length: ${prompt.length}`);
|
|
102
112
|
|
|
@@ -117,6 +127,11 @@ class GeminiWrapper {
|
|
|
117
127
|
return reject(new Error(`Failed to spawn Gemini CLI: ${spawnError.message}`));
|
|
118
128
|
}
|
|
119
129
|
|
|
130
|
+
// Register process for interrupt capability
|
|
131
|
+
// Prefer external processId (Nexus sessionId), else threadId, else temp
|
|
132
|
+
const processId = processIdOverride || threadId || `gemini-${Date.now()}`;
|
|
133
|
+
this.registerProcess(processId, ptyProcess, 'pty');
|
|
134
|
+
|
|
120
135
|
let stdout = '';
|
|
121
136
|
|
|
122
137
|
// Handle PTY data
|
|
@@ -145,6 +160,9 @@ class GeminiWrapper {
|
|
|
145
160
|
|
|
146
161
|
// Handle PTY exit
|
|
147
162
|
ptyProcess.onExit(({ exitCode }) => {
|
|
163
|
+
// Unregister process on exit
|
|
164
|
+
this.unregisterProcess(processId);
|
|
165
|
+
|
|
148
166
|
if (promiseSettled) return;
|
|
149
167
|
promiseSettled = true;
|
|
150
168
|
|
|
@@ -170,6 +188,7 @@ class GeminiWrapper {
|
|
|
170
188
|
|
|
171
189
|
resolve({
|
|
172
190
|
text: finalResponse,
|
|
191
|
+
sessionId: parser.getSessionId(), // Native Gemini session ID for resume
|
|
173
192
|
usage: {
|
|
174
193
|
prompt_tokens: promptTokens,
|
|
175
194
|
completion_tokens: completionTokens,
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionImporter - Importa sessioni CLI native in tabella sessions (indice NexusCLI)
|
|
3
|
+
*
|
|
4
|
+
* Scansione:
|
|
5
|
+
* - Claude: ~/.claude/projects/<slug>/<sessionId>.jsonl
|
|
6
|
+
* - Codex : ~/.codex/sessions/<sessionId>.jsonl
|
|
7
|
+
* - Gemini: ~/.gemini/sessions/<sessionId>.jsonl
|
|
8
|
+
*
|
|
9
|
+
* Note:
|
|
10
|
+
* - Usa FILESYSTEM come source of truth: non legge contenuti, solo metadati.
|
|
11
|
+
* - workspace_path stimato da slug (best effort, non reversibile al 100%).
|
|
12
|
+
* - Non sovrascrive entry esistenti in DB.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const { prepare, saveDb } = require('../db');
|
|
18
|
+
|
|
19
|
+
const HOME = process.env.HOME || '';
|
|
20
|
+
|
|
21
|
+
const CLAUDE_PROJECTS = path.join(HOME, '.claude', 'projects');
|
|
22
|
+
const CODEX_SESSIONS = path.join(HOME, '.codex', 'sessions');
|
|
23
|
+
const GEMINI_SESSIONS = path.join(HOME, '.gemini', 'sessions');
|
|
24
|
+
|
|
25
|
+
class SessionImporter {
|
|
26
|
+
constructor() {}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Importa tutte le sessioni per tutti gli engine
|
|
30
|
+
* @returns {{claude:number, codex:number, gemini:number}}
|
|
31
|
+
*/
|
|
32
|
+
importAll() {
|
|
33
|
+
const claude = this.importClaudeSessions();
|
|
34
|
+
const codex = this.importCodexSessions();
|
|
35
|
+
const gemini = this.importGeminiSessions();
|
|
36
|
+
return { claude, codex, gemini };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Claude: ~/.claude/projects/<slug>/<sessionId>.jsonl
|
|
41
|
+
*/
|
|
42
|
+
importClaudeSessions() {
|
|
43
|
+
let imported = 0;
|
|
44
|
+
if (!fs.existsSync(CLAUDE_PROJECTS)) return imported;
|
|
45
|
+
|
|
46
|
+
const projectSlugs = fs.readdirSync(CLAUDE_PROJECTS);
|
|
47
|
+
for (const slug of projectSlugs) {
|
|
48
|
+
const projectDir = path.join(CLAUDE_PROJECTS, slug);
|
|
49
|
+
if (!fs.statSync(projectDir).isDirectory()) continue;
|
|
50
|
+
|
|
51
|
+
const files = fs.readdirSync(projectDir).filter(f => f.endsWith('.jsonl'));
|
|
52
|
+
for (const file of files) {
|
|
53
|
+
const sessionId = file.replace('.jsonl', '');
|
|
54
|
+
if (this.sessionExists(sessionId)) continue;
|
|
55
|
+
|
|
56
|
+
const workspacePath = this.slugToPath(slug);
|
|
57
|
+
this.insertSession(sessionId, 'claude', workspacePath, null);
|
|
58
|
+
imported++;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (imported > 0) saveDb();
|
|
63
|
+
console.log(`[SessionImporter] Claude imported: ${imported}`);
|
|
64
|
+
return imported;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Codex: ~/.codex/sessions/<sessionId>.jsonl
|
|
69
|
+
*/
|
|
70
|
+
importCodexSessions() {
|
|
71
|
+
let imported = 0;
|
|
72
|
+
if (!fs.existsSync(CODEX_SESSIONS)) return imported;
|
|
73
|
+
|
|
74
|
+
const files = fs.readdirSync(CODEX_SESSIONS)
|
|
75
|
+
.filter(f => f.endsWith('.jsonl') || f.endsWith('.json'));
|
|
76
|
+
for (const file of files) {
|
|
77
|
+
const sessionId = file.replace('.jsonl', '');
|
|
78
|
+
if (this.sessionExists(sessionId)) continue;
|
|
79
|
+
|
|
80
|
+
this.insertSession(sessionId, 'codex', '', null);
|
|
81
|
+
imported++;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (imported > 0) saveDb();
|
|
85
|
+
console.log(`[SessionImporter] Codex imported: ${imported}`);
|
|
86
|
+
return imported;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Gemini: ~/.gemini/sessions/<sessionId>.jsonl
|
|
91
|
+
*/
|
|
92
|
+
importGeminiSessions() {
|
|
93
|
+
let imported = 0;
|
|
94
|
+
if (!fs.existsSync(GEMINI_SESSIONS)) return imported;
|
|
95
|
+
|
|
96
|
+
const files = fs.readdirSync(GEMINI_SESSIONS)
|
|
97
|
+
.filter(f => f.endsWith('.jsonl') || f.endsWith('.json'));
|
|
98
|
+
for (const file of files) {
|
|
99
|
+
const sessionId = file.replace('.jsonl', '');
|
|
100
|
+
if (this.sessionExists(sessionId)) continue;
|
|
101
|
+
|
|
102
|
+
this.insertSession(sessionId, 'gemini', '', null);
|
|
103
|
+
imported++;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (imported > 0) saveDb();
|
|
107
|
+
console.log(`[SessionImporter] Gemini imported: ${imported}`);
|
|
108
|
+
return imported;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Inserisce riga minima in sessions
|
|
113
|
+
*/
|
|
114
|
+
insertSession(id, engine, workspacePath, conversationId) {
|
|
115
|
+
try {
|
|
116
|
+
const now = Date.now();
|
|
117
|
+
const stmt = prepare(`
|
|
118
|
+
INSERT INTO sessions (id, engine, workspace_path, conversation_id, title, created_at, last_used_at)
|
|
119
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
120
|
+
`);
|
|
121
|
+
stmt.run(id, engine, workspacePath || '', conversationId || null, 'Imported Chat', now, now);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
console.warn(`[SessionImporter] insert failed for ${id}: ${err.message}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Controlla se la sessione esiste già
|
|
129
|
+
*/
|
|
130
|
+
sessionExists(sessionId) {
|
|
131
|
+
try {
|
|
132
|
+
const stmt = prepare('SELECT 1 FROM sessions WHERE id = ?');
|
|
133
|
+
return !!stmt.get(sessionId);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
console.warn(`[SessionImporter] exists check failed: ${err.message}`);
|
|
136
|
+
return true; // default to skip to avoid duplicates
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Best-effort reverse di slug Claude in path
|
|
142
|
+
* -path-to-dir → /path/to/dir
|
|
143
|
+
* Conserva i punti.
|
|
144
|
+
*/
|
|
145
|
+
slugToPath(slug) {
|
|
146
|
+
if (!slug) return '';
|
|
147
|
+
// Claude slug era workspacePath.replace(/\//g, '-'); mantiene i punti
|
|
148
|
+
// Invertiamo: leading '-' → '/', i restanti '-' → '/'
|
|
149
|
+
let tmp = slug;
|
|
150
|
+
if (tmp.startsWith('-')) tmp = '/' + tmp.slice(1);
|
|
151
|
+
return tmp.replace(/-/g, '/');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = new SessionImporter();
|
|
@@ -442,6 +442,26 @@ class SessionManager {
|
|
|
442
442
|
}
|
|
443
443
|
}
|
|
444
444
|
|
|
445
|
+
/**
|
|
446
|
+
* Bump session activity counters (message_count, last_used_at)
|
|
447
|
+
* @param {string} sessionId
|
|
448
|
+
* @param {number} increment
|
|
449
|
+
*/
|
|
450
|
+
bumpSessionActivity(sessionId, increment = 1) {
|
|
451
|
+
if (!sessionId) return;
|
|
452
|
+
try {
|
|
453
|
+
const stmt = prepare(`
|
|
454
|
+
UPDATE sessions
|
|
455
|
+
SET message_count = COALESCE(message_count, 0) + ?, last_used_at = ?
|
|
456
|
+
WHERE id = ?
|
|
457
|
+
`);
|
|
458
|
+
stmt.run(increment, Date.now(), sessionId);
|
|
459
|
+
saveDb();
|
|
460
|
+
} catch (error) {
|
|
461
|
+
console.warn(`[SessionManager] Failed to bump activity for ${sessionId}:`, error.message);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
445
465
|
/**
|
|
446
466
|
* Get all sessions for a conversation
|
|
447
467
|
*/
|
|
@@ -328,13 +328,8 @@ class WorkspaceManager {
|
|
|
328
328
|
// Sort by most recent first
|
|
329
329
|
sessions.sort((a, b) => b.last_used_at - a.last_used_at);
|
|
330
330
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
const totalSessions = sessions.length;
|
|
334
|
-
const limitedSessions = sessions.slice(0, limit);
|
|
335
|
-
|
|
336
|
-
console.log(`[WorkspaceManager] Loaded ${limitedSessions.length} of ${totalSessions} sessions (sorted by recency)`);
|
|
337
|
-
return limitedSessions;
|
|
331
|
+
console.log(`[WorkspaceManager] Loaded ${sessions.length} sessions (sorted by recency)`);
|
|
332
|
+
return sessions;
|
|
338
333
|
}
|
|
339
334
|
|
|
340
335
|
/**
|
|
@@ -396,7 +391,7 @@ class WorkspaceManager {
|
|
|
396
391
|
* @returns {string}
|
|
397
392
|
*/
|
|
398
393
|
getSessionPath(workspacePath) {
|
|
399
|
-
// Convert /
|
|
394
|
+
// Convert /var/www/myapp → -var-www-myapp
|
|
400
395
|
const projectDir = workspacePath.replace(/\//g, '-').replace(/^-/, '');
|
|
401
396
|
return path.join(this.claudePath, 'projects', projectDir);
|
|
402
397
|
}
|