@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,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GeminiWrapper - Wrapper for Gemini CLI (gemini command)
|
|
3
|
+
*
|
|
4
|
+
* Executes Gemini CLI with node-pty for real PTY support.
|
|
5
|
+
* Uses JSON streaming output for structured event parsing.
|
|
6
|
+
*
|
|
7
|
+
* CLI Arguments:
|
|
8
|
+
* - -y: YOLO mode (auto-approve all actions)
|
|
9
|
+
* - -m <model>: Model selection (gemini-3-pro-preview, etc.)
|
|
10
|
+
* - -o stream-json: JSON streaming output
|
|
11
|
+
* - --include-directories: Workspace access
|
|
12
|
+
*
|
|
13
|
+
* @version 0.4.0 - TRI CLI Support
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const pty = require('../lib/pty-adapter');
|
|
19
|
+
const GeminiOutputParser = require('./gemini-output-parser');
|
|
20
|
+
|
|
21
|
+
// Default model - Gemini 3 Pro Preview
|
|
22
|
+
const DEFAULT_MODEL = 'gemini-3-pro-preview';
|
|
23
|
+
|
|
24
|
+
// CLI timeout (10 minutes)
|
|
25
|
+
const CLI_TIMEOUT_MS = 600000;
|
|
26
|
+
|
|
27
|
+
class GeminiWrapper {
|
|
28
|
+
constructor(options = {}) {
|
|
29
|
+
this.geminiPath = this._resolveGeminiPath(options.geminiPath);
|
|
30
|
+
this.workspaceDir = options.workspaceDir || process.cwd();
|
|
31
|
+
|
|
32
|
+
console.log(`[GeminiWrapper] Initialized with binary: ${this.geminiPath}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve path to gemini CLI binary
|
|
37
|
+
*/
|
|
38
|
+
_resolveGeminiPath(overridePath) {
|
|
39
|
+
if (overridePath && fs.existsSync(overridePath)) {
|
|
40
|
+
return overridePath;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Common installation paths
|
|
44
|
+
const candidates = [
|
|
45
|
+
path.join(process.env.HOME || '', '.local/bin/gemini'),
|
|
46
|
+
path.join(process.env.PREFIX || '', 'bin/gemini'),
|
|
47
|
+
'/usr/local/bin/gemini',
|
|
48
|
+
'/usr/bin/gemini',
|
|
49
|
+
'gemini', // Fallback to PATH lookup
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
for (const candidate of candidates) {
|
|
53
|
+
try {
|
|
54
|
+
if (candidate === 'gemini' || fs.existsSync(candidate)) {
|
|
55
|
+
return candidate;
|
|
56
|
+
}
|
|
57
|
+
} catch (_) {
|
|
58
|
+
// Skip invalid paths
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return 'gemini'; // Assume in PATH
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Send a message to Gemini CLI
|
|
67
|
+
*
|
|
68
|
+
* @param {Object} params
|
|
69
|
+
* @param {string} params.prompt - User message/prompt
|
|
70
|
+
* @param {string} params.sessionId - Session UUID (for logging)
|
|
71
|
+
* @param {string} [params.model='gemini-3-pro-preview'] - Model name
|
|
72
|
+
* @param {string} [params.workspacePath] - Workspace directory
|
|
73
|
+
* @param {Function} [params.onStatus] - Callback for status events (SSE streaming)
|
|
74
|
+
* @returns {Promise<{text: string, usage: Object}>}
|
|
75
|
+
*/
|
|
76
|
+
async sendMessage({
|
|
77
|
+
prompt,
|
|
78
|
+
sessionId,
|
|
79
|
+
model = DEFAULT_MODEL,
|
|
80
|
+
workspacePath,
|
|
81
|
+
onStatus
|
|
82
|
+
}) {
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
const parser = new GeminiOutputParser();
|
|
85
|
+
let promiseSettled = false;
|
|
86
|
+
|
|
87
|
+
const cwd = workspacePath || this.workspaceDir;
|
|
88
|
+
|
|
89
|
+
// Build CLI arguments
|
|
90
|
+
// Note: cwd is set in pty.spawn() options, no need for --include-directories
|
|
91
|
+
const args = [
|
|
92
|
+
'-y', // YOLO mode - auto-approve all actions
|
|
93
|
+
'-m', model, // Model selection
|
|
94
|
+
'-o', 'stream-json', // JSON streaming for structured events
|
|
95
|
+
prompt // Prompt as positional argument
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
console.log(`[GeminiWrapper] Model: ${model}`);
|
|
99
|
+
console.log(`[GeminiWrapper] Session: ${sessionId}`);
|
|
100
|
+
console.log(`[GeminiWrapper] CWD: ${cwd}`);
|
|
101
|
+
console.log(`[GeminiWrapper] Prompt length: ${prompt.length}`);
|
|
102
|
+
|
|
103
|
+
// Spawn Gemini CLI with PTY
|
|
104
|
+
let ptyProcess;
|
|
105
|
+
try {
|
|
106
|
+
ptyProcess = pty.spawn(this.geminiPath, args, {
|
|
107
|
+
name: 'xterm-color',
|
|
108
|
+
cols: 120,
|
|
109
|
+
rows: 40,
|
|
110
|
+
cwd: cwd,
|
|
111
|
+
env: {
|
|
112
|
+
...process.env,
|
|
113
|
+
TERM: 'xterm-256color',
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
} catch (spawnError) {
|
|
117
|
+
return reject(new Error(`Failed to spawn Gemini CLI: ${spawnError.message}`));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let stdout = '';
|
|
121
|
+
|
|
122
|
+
// Handle PTY data
|
|
123
|
+
ptyProcess.onData((data) => {
|
|
124
|
+
stdout += data;
|
|
125
|
+
|
|
126
|
+
// Clean ANSI escape codes before parsing
|
|
127
|
+
const cleanData = data
|
|
128
|
+
.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '') // Standard ANSI codes
|
|
129
|
+
.replace(/\x1B\[\?[0-9;]*[a-zA-Z]/g, '') // Private modes
|
|
130
|
+
.replace(/\r/g, ''); // Carriage returns
|
|
131
|
+
|
|
132
|
+
// Parse and emit events
|
|
133
|
+
if (onStatus && cleanData.trim()) {
|
|
134
|
+
try {
|
|
135
|
+
const events = parser.parse(cleanData);
|
|
136
|
+
events.forEach(event => {
|
|
137
|
+
console.log(`[GeminiWrapper] Event: ${event.type}`, event.message || event.text?.substring(0, 50) || '');
|
|
138
|
+
onStatus(event);
|
|
139
|
+
});
|
|
140
|
+
} catch (parseError) {
|
|
141
|
+
console.error('[GeminiWrapper] Parser error:', parseError.message);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Handle PTY exit
|
|
147
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
148
|
+
if (promiseSettled) return;
|
|
149
|
+
promiseSettled = true;
|
|
150
|
+
|
|
151
|
+
console.log(`[GeminiWrapper] Exit code: ${exitCode}`);
|
|
152
|
+
|
|
153
|
+
// Non-zero exit is an error (except null which is normal)
|
|
154
|
+
if (exitCode !== 0 && exitCode !== null) {
|
|
155
|
+
console.error('[GeminiWrapper] Error output:', stdout.substring(0, 500));
|
|
156
|
+
reject(new Error(`Gemini CLI error (exit ${exitCode}): ${stdout.substring(0, 200)}`));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Get final response and usage from parser
|
|
161
|
+
const finalResponse = parser.getFinalResponse();
|
|
162
|
+
const usage = parser.getUsage();
|
|
163
|
+
|
|
164
|
+
console.log(`[GeminiWrapper] Response length: ${finalResponse.length}`);
|
|
165
|
+
console.log(`[GeminiWrapper] Usage:`, usage ? JSON.stringify(usage) : 'none');
|
|
166
|
+
|
|
167
|
+
// Calculate token counts (fallback if parser didn't get them)
|
|
168
|
+
const promptTokens = usage?.input_tokens || Math.ceil(prompt.length / 4);
|
|
169
|
+
const completionTokens = usage?.output_tokens || Math.ceil(finalResponse.length / 4);
|
|
170
|
+
|
|
171
|
+
resolve({
|
|
172
|
+
text: finalResponse,
|
|
173
|
+
usage: {
|
|
174
|
+
prompt_tokens: promptTokens,
|
|
175
|
+
completion_tokens: completionTokens,
|
|
176
|
+
total_tokens: promptTokens + completionTokens,
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Handle errors
|
|
182
|
+
if (ptyProcess.onError) {
|
|
183
|
+
ptyProcess.onError((error) => {
|
|
184
|
+
if (promiseSettled) return;
|
|
185
|
+
promiseSettled = true;
|
|
186
|
+
console.error('[GeminiWrapper] PTY error:', error);
|
|
187
|
+
reject(new Error(`Gemini CLI PTY error: ${error.message}`));
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Timeout after 10 minutes
|
|
192
|
+
const timeout = setTimeout(() => {
|
|
193
|
+
if (!promiseSettled) {
|
|
194
|
+
promiseSettled = true;
|
|
195
|
+
console.error('[GeminiWrapper] Timeout after 10 minutes');
|
|
196
|
+
try {
|
|
197
|
+
ptyProcess.kill();
|
|
198
|
+
} catch (_) {}
|
|
199
|
+
reject(new Error('Gemini CLI timeout (10 minutes)'));
|
|
200
|
+
}
|
|
201
|
+
}, CLI_TIMEOUT_MS);
|
|
202
|
+
|
|
203
|
+
// Clear timeout on exit
|
|
204
|
+
ptyProcess.onExit(() => clearTimeout(timeout));
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Check if Gemini CLI is available
|
|
210
|
+
* @returns {Promise<boolean>}
|
|
211
|
+
*/
|
|
212
|
+
async isAvailable() {
|
|
213
|
+
return new Promise((resolve) => {
|
|
214
|
+
const { exec } = require('child_process');
|
|
215
|
+
exec(`${this.geminiPath} --version`, { timeout: 5000 }, (error, stdout) => {
|
|
216
|
+
if (error) {
|
|
217
|
+
console.log('[GeminiWrapper] CLI not available:', error.message);
|
|
218
|
+
resolve(false);
|
|
219
|
+
} else {
|
|
220
|
+
console.log('[GeminiWrapper] CLI available:', stdout.trim().substring(0, 50));
|
|
221
|
+
resolve(true);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Get the default model
|
|
229
|
+
*/
|
|
230
|
+
getDefaultModel() {
|
|
231
|
+
return DEFAULT_MODEL;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Get available models
|
|
236
|
+
*/
|
|
237
|
+
getAvailableModels() {
|
|
238
|
+
return [
|
|
239
|
+
{
|
|
240
|
+
id: 'gemini-3-pro-preview',
|
|
241
|
+
name: 'Gemini 3 Pro Preview',
|
|
242
|
+
description: '🚀 Latest Gemini 3 model',
|
|
243
|
+
default: true
|
|
244
|
+
}
|
|
245
|
+
];
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
module.exports = GeminiWrapper;
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const readline = require('readline');
|
|
4
|
+
const Conversation = require('../models/Conversation');
|
|
5
|
+
const Message = require('../models/Message');
|
|
6
|
+
const { prepare } = require('../db');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* HistorySync - Sync Claude Code history.jsonl with database
|
|
10
|
+
*
|
|
11
|
+
* Reads ~/.claude/history.jsonl (JSONL format) and syncs to SQLite:
|
|
12
|
+
* - Groups messages by sessionId
|
|
13
|
+
* - Creates conversations (id = sessionId)
|
|
14
|
+
* - Populates messages table
|
|
15
|
+
* - Extracts title from first message display
|
|
16
|
+
*
|
|
17
|
+
* Architecture:
|
|
18
|
+
* - Claude Code saves all messages to history.jsonl natively
|
|
19
|
+
* - This service bridges history.jsonl → SQLite database
|
|
20
|
+
* - Frontend sidebar reads from database (synced from history)
|
|
21
|
+
*
|
|
22
|
+
* Features:
|
|
23
|
+
* - Incremental sync (only new messages)
|
|
24
|
+
* - Grouped by date (today, yesterday, last 7 days, etc.)
|
|
25
|
+
* - Preserves sessionId for resume (-r flag)
|
|
26
|
+
*/
|
|
27
|
+
class HistorySync {
|
|
28
|
+
constructor(options = {}) {
|
|
29
|
+
// Default to user's home directory
|
|
30
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE;
|
|
31
|
+
this.historyPath = options.historyPath || path.join(homeDir, '.claude', 'history.jsonl');
|
|
32
|
+
this.lastSyncTime = 0;
|
|
33
|
+
this.syncCacheMs = options.syncCacheMs || 30000; // Cache for 30 seconds
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Check if history file exists
|
|
38
|
+
*/
|
|
39
|
+
exists() {
|
|
40
|
+
try {
|
|
41
|
+
return fs.existsSync(this.historyPath);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
console.error('[HistorySync] Error checking history file:', error);
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse history.jsonl and group by sessionId
|
|
50
|
+
* @returns {Promise<Map<sessionId, Session>>}
|
|
51
|
+
*/
|
|
52
|
+
async parseHistory() {
|
|
53
|
+
const startTime = Date.now();
|
|
54
|
+
const sessions = new Map();
|
|
55
|
+
let totalMessages = 0;
|
|
56
|
+
|
|
57
|
+
if (!this.exists()) {
|
|
58
|
+
console.warn(`[HistorySync] History file not found: ${this.historyPath}`);
|
|
59
|
+
return sessions;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const fileStream = fs.createReadStream(this.historyPath);
|
|
63
|
+
const rl = readline.createInterface({
|
|
64
|
+
input: fileStream,
|
|
65
|
+
crlfDelay: Infinity
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
for await (const line of rl) {
|
|
69
|
+
if (!line.trim()) continue;
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const entry = JSON.parse(line);
|
|
73
|
+
totalMessages++;
|
|
74
|
+
|
|
75
|
+
// Only process entries with sessionId
|
|
76
|
+
if (!entry.sessionId) continue;
|
|
77
|
+
|
|
78
|
+
const timestamp = entry.timestamp || Date.now();
|
|
79
|
+
|
|
80
|
+
// Initialize session bag
|
|
81
|
+
if (!sessions.has(entry.sessionId)) {
|
|
82
|
+
sessions.set(entry.sessionId, {
|
|
83
|
+
id: entry.sessionId,
|
|
84
|
+
project: entry.project || null,
|
|
85
|
+
messages: [],
|
|
86
|
+
firstTimestamp: timestamp,
|
|
87
|
+
lastTimestamp: timestamp
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const session = sessions.get(entry.sessionId);
|
|
92
|
+
|
|
93
|
+
session.messages.push({
|
|
94
|
+
display: entry.display || '',
|
|
95
|
+
timestamp,
|
|
96
|
+
project: entry.project || null,
|
|
97
|
+
pastedContents: entry.pastedContents || {}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
session.firstTimestamp = Math.min(session.firstTimestamp, timestamp);
|
|
101
|
+
session.lastTimestamp = Math.max(session.lastTimestamp, timestamp);
|
|
102
|
+
|
|
103
|
+
// Keep most recent non-null project as workspace hint
|
|
104
|
+
if (entry.project) {
|
|
105
|
+
session.project = entry.project;
|
|
106
|
+
}
|
|
107
|
+
} catch (parseError) {
|
|
108
|
+
console.error('[HistorySync] Failed to parse line:', parseError.message);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const parseTime = Date.now() - startTime;
|
|
113
|
+
console.log(`[HistorySync] Parsed ${totalMessages} messages from history.jsonl in ${parseTime}ms`);
|
|
114
|
+
console.log(`[HistorySync] Found ${sessions.size} unique sessions`);
|
|
115
|
+
|
|
116
|
+
return sessions;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Sync history to database
|
|
121
|
+
* @param {Map<string, Session>|boolean} sessionsOrForce - Session map or force flag
|
|
122
|
+
* @param {Object} options
|
|
123
|
+
* @param {boolean} options.force - Force sync even if cache is valid
|
|
124
|
+
* @returns {Promise<{synced: number, skipped: number, cached?: boolean}>}
|
|
125
|
+
*/
|
|
126
|
+
async syncToDatabase(sessionsOrForce = false, options = {}) {
|
|
127
|
+
let sessions = null;
|
|
128
|
+
let force = false;
|
|
129
|
+
|
|
130
|
+
if (sessionsOrForce instanceof Map) {
|
|
131
|
+
sessions = sessionsOrForce;
|
|
132
|
+
force = Boolean(options.force);
|
|
133
|
+
} else {
|
|
134
|
+
force = Boolean(sessionsOrForce);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const now = Date.now();
|
|
138
|
+
|
|
139
|
+
// Check cache (only when we are allowed to self-parse)
|
|
140
|
+
if (!sessions && !force && (now - this.lastSyncTime) < this.syncCacheMs) {
|
|
141
|
+
console.log('[HistorySync] Using cached sync (< 30s old)');
|
|
142
|
+
return { synced: 0, skipped: 0, cached: true };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const startTime = Date.now();
|
|
146
|
+
const parsedSessions = sessions || await this.parseHistory();
|
|
147
|
+
|
|
148
|
+
let conversationsCreated = 0;
|
|
149
|
+
let messagesCreated = 0;
|
|
150
|
+
let conversationsSkipped = 0;
|
|
151
|
+
|
|
152
|
+
// Process each session
|
|
153
|
+
for (const [sessionId, sessionData] of parsedSessions.entries()) {
|
|
154
|
+
if (!sessionData.messages || sessionData.messages.length === 0) continue;
|
|
155
|
+
|
|
156
|
+
// Sort messages by timestamp
|
|
157
|
+
const messages = [...sessionData.messages].sort((a, b) => a.timestamp - b.timestamp);
|
|
158
|
+
|
|
159
|
+
// Check if conversation exists
|
|
160
|
+
let conversation = Conversation.getById(sessionId);
|
|
161
|
+
|
|
162
|
+
if (!conversation) {
|
|
163
|
+
// Create new conversation
|
|
164
|
+
const firstMessage = messages[0];
|
|
165
|
+
const title = this.extractTitle(firstMessage.display);
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
// Use sessionId as conversation ID
|
|
169
|
+
conversation = {
|
|
170
|
+
id: sessionId,
|
|
171
|
+
title,
|
|
172
|
+
created_at: sessionData.firstTimestamp || firstMessage.timestamp,
|
|
173
|
+
updated_at: sessionData.lastTimestamp || messages[messages.length - 1].timestamp,
|
|
174
|
+
metadata: sessionData.project ? JSON.stringify({ workspace: sessionData.project }) : null
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// Insert directly (bypass UUID generation in Conversation.create)
|
|
178
|
+
const stmt = prepare(`
|
|
179
|
+
INSERT OR REPLACE INTO conversations (id, title, created_at, updated_at, metadata)
|
|
180
|
+
VALUES (?, ?, ?, ?, ?)
|
|
181
|
+
`);
|
|
182
|
+
stmt.run(sessionId, title, conversation.created_at, conversation.updated_at, conversation.metadata);
|
|
183
|
+
|
|
184
|
+
conversationsCreated++;
|
|
185
|
+
console.log(`[HistorySync] Created conversation: ${sessionId} - "${title}"`);
|
|
186
|
+
|
|
187
|
+
} catch (error) {
|
|
188
|
+
console.error(`[HistorySync] Failed to create conversation ${sessionId}:`, error.message);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
conversationsSkipped++;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Get existing messages for this conversation
|
|
196
|
+
const existingMessages = Message.getByConversation(sessionId);
|
|
197
|
+
const existingTimestamps = new Set(existingMessages.map(m => m.created_at));
|
|
198
|
+
let newMessagesForSession = 0;
|
|
199
|
+
let sessionInserted = false;
|
|
200
|
+
|
|
201
|
+
// Insert new messages
|
|
202
|
+
for (const historyMsg of messages) {
|
|
203
|
+
// Skip if message already exists (by timestamp)
|
|
204
|
+
if (existingTimestamps.has(historyMsg.timestamp)) continue;
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
Message.create(
|
|
208
|
+
sessionId,
|
|
209
|
+
'user', // All history entries are user messages
|
|
210
|
+
historyMsg.display,
|
|
211
|
+
{
|
|
212
|
+
project: historyMsg.project,
|
|
213
|
+
pastedContents: historyMsg.pastedContents
|
|
214
|
+
},
|
|
215
|
+
historyMsg.timestamp
|
|
216
|
+
);
|
|
217
|
+
messagesCreated++;
|
|
218
|
+
newMessagesForSession++;
|
|
219
|
+
} catch (error) {
|
|
220
|
+
console.error(`[HistorySync] Failed to create message:`, error.message);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Ensure session row exists (for workspace filtering)
|
|
225
|
+
try {
|
|
226
|
+
const sessionCheckStmt = prepare('SELECT id FROM sessions WHERE id = ?');
|
|
227
|
+
const hasSession = sessionCheckStmt.get(sessionId);
|
|
228
|
+
|
|
229
|
+
if (!hasSession) {
|
|
230
|
+
const insertSessionStmt = prepare(`
|
|
231
|
+
INSERT INTO sessions (
|
|
232
|
+
id, engine, workspace_path, session_path, title,
|
|
233
|
+
last_used_at, created_at, pinned, importance, message_count, metadata,
|
|
234
|
+
conversation_id
|
|
235
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
236
|
+
`);
|
|
237
|
+
|
|
238
|
+
insertSessionStmt.run(
|
|
239
|
+
sessionId,
|
|
240
|
+
'claude-code',
|
|
241
|
+
sessionData.project || process.cwd(),
|
|
242
|
+
null,
|
|
243
|
+
conversation.title,
|
|
244
|
+
conversation.updated_at,
|
|
245
|
+
conversation.created_at,
|
|
246
|
+
0,
|
|
247
|
+
0,
|
|
248
|
+
existingMessages.length + newMessagesForSession,
|
|
249
|
+
conversation.metadata,
|
|
250
|
+
sessionId // conversation_id = sessionId for history sync
|
|
251
|
+
);
|
|
252
|
+
sessionInserted = true;
|
|
253
|
+
}
|
|
254
|
+
} catch (sessionErr) {
|
|
255
|
+
console.warn('[HistorySync] Failed to ensure session row:', sessionErr.message);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Update sessions.message_count / last_used_at if we added new messages
|
|
259
|
+
if (newMessagesForSession > 0 && !sessionInserted) {
|
|
260
|
+
try {
|
|
261
|
+
const updateSessionStmt = prepare(`
|
|
262
|
+
UPDATE sessions
|
|
263
|
+
SET message_count = COALESCE(message_count, 0) + ?, last_used_at = ?
|
|
264
|
+
WHERE id = ?
|
|
265
|
+
`);
|
|
266
|
+
updateSessionStmt.run(newMessagesForSession, sessionData.lastTimestamp || Date.now(), sessionId);
|
|
267
|
+
} catch (updateErr) {
|
|
268
|
+
console.warn('[HistorySync] Failed to update session counters:', updateErr.message);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const syncTime = Date.now() - startTime;
|
|
274
|
+
this.lastSyncTime = now;
|
|
275
|
+
|
|
276
|
+
console.log(`[HistorySync] Sync completed in ${syncTime}ms`);
|
|
277
|
+
console.log(`[HistorySync] - Conversations: ${conversationsCreated} created, ${conversationsSkipped} skipped`);
|
|
278
|
+
console.log(`[HistorySync] - Messages: ${messagesCreated} created`);
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
synced: conversationsCreated + messagesCreated,
|
|
282
|
+
skipped: conversationsSkipped,
|
|
283
|
+
newConversations: conversationsCreated,
|
|
284
|
+
newMessages: messagesCreated,
|
|
285
|
+
cached: false
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Wrapper to perform full sync with cache control
|
|
291
|
+
* @param {boolean} force
|
|
292
|
+
*/
|
|
293
|
+
async sync(force = false) {
|
|
294
|
+
const sessions = await this.parseHistory();
|
|
295
|
+
return this.syncToDatabase(sessions, { force });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Extract conversation title from display message
|
|
300
|
+
* @param {string} display - First message display text
|
|
301
|
+
* @returns {string} Title (max 80 chars)
|
|
302
|
+
*/
|
|
303
|
+
extractTitle(display) {
|
|
304
|
+
if (!display) return 'Untitled Conversation';
|
|
305
|
+
|
|
306
|
+
// Truncate to 80 characters
|
|
307
|
+
let title = display.substring(0, 80);
|
|
308
|
+
|
|
309
|
+
// If truncated, add ellipsis
|
|
310
|
+
if (display.length > 80) {
|
|
311
|
+
title += '...';
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return title;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Get grouped conversations by date
|
|
319
|
+
* @returns {Promise<Object>} Grouped conversations
|
|
320
|
+
*/
|
|
321
|
+
async getGroupedConversations() {
|
|
322
|
+
// Sync first
|
|
323
|
+
await this.syncToDatabase();
|
|
324
|
+
|
|
325
|
+
// Use existing Conversation.listGroupedByDate()
|
|
326
|
+
return Conversation.listGroupedByDate();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Get conversations filtered by workspace (sessions.workspace_path)
|
|
331
|
+
* @param {string} workspacePath
|
|
332
|
+
* @param {number} limit - Max conversations (default: 50)
|
|
333
|
+
* @returns {Promise<Object>} Grouped by date like listGroupedByDate
|
|
334
|
+
*/
|
|
335
|
+
async getWorkspaceSessions(workspacePath, limit = 50) {
|
|
336
|
+
if (!workspacePath) return { today: [], yesterday: [], last7days: [], last30days: [], older: [] };
|
|
337
|
+
|
|
338
|
+
// Skip sync if cached - workspace query is fast enough
|
|
339
|
+
// await this.sync(); // Removed - too slow!
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
const now = Date.now();
|
|
343
|
+
const oneDayMs = 24 * 60 * 60 * 1000;
|
|
344
|
+
|
|
345
|
+
// Optimized query: use index on workspace_path, limit results
|
|
346
|
+
const stmt = prepare(`
|
|
347
|
+
SELECT c.*,
|
|
348
|
+
CASE
|
|
349
|
+
WHEN (? - c.updated_at) < ? THEN 'today'
|
|
350
|
+
WHEN (? - c.updated_at) < ? THEN 'yesterday'
|
|
351
|
+
WHEN (? - c.updated_at) < ? THEN 'last7days'
|
|
352
|
+
WHEN (? - c.updated_at) < ? THEN 'last30days'
|
|
353
|
+
ELSE 'older'
|
|
354
|
+
END as date_group
|
|
355
|
+
FROM conversations c
|
|
356
|
+
INNER JOIN sessions s ON c.id = s.id
|
|
357
|
+
WHERE s.workspace_path = ?
|
|
358
|
+
ORDER BY c.updated_at DESC
|
|
359
|
+
LIMIT ?
|
|
360
|
+
`);
|
|
361
|
+
|
|
362
|
+
const rows = stmt.all(
|
|
363
|
+
now, oneDayMs, // today
|
|
364
|
+
now, 2 * oneDayMs, // yesterday
|
|
365
|
+
now, 7 * oneDayMs, // last7days
|
|
366
|
+
now, 30 * oneDayMs, // last30days
|
|
367
|
+
workspacePath,
|
|
368
|
+
limit
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
// Group results
|
|
372
|
+
const grouped = {
|
|
373
|
+
today: [],
|
|
374
|
+
yesterday: [],
|
|
375
|
+
last7days: [],
|
|
376
|
+
last30days: [],
|
|
377
|
+
older: []
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
for (const row of rows) {
|
|
381
|
+
const group = row.date_group;
|
|
382
|
+
delete row.date_group;
|
|
383
|
+
|
|
384
|
+
// Parse metadata
|
|
385
|
+
if (row.metadata) {
|
|
386
|
+
try {
|
|
387
|
+
row.metadata = JSON.parse(row.metadata);
|
|
388
|
+
if (row.metadata.bookmarked !== undefined) {
|
|
389
|
+
row.metadata.pinned = row.metadata.bookmarked;
|
|
390
|
+
}
|
|
391
|
+
} catch (e) {
|
|
392
|
+
row.metadata = null;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
grouped[group].push(row);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return grouped;
|
|
400
|
+
} catch (err) {
|
|
401
|
+
console.error('[HistorySync] Workspace filter error:', err.message);
|
|
402
|
+
return { today: [], yesterday: [], last7days: [], last30days: [], older: [] };
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
module.exports = HistorySync;
|