@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,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;