@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,516 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const readline = require('readline');
4
+ const { prepare } = require('../db');
5
+
6
+ /**
7
+ * Workspace Manager - Index CLI sessions by workspace
8
+ */
9
+ class WorkspaceManager {
10
+ constructor() {
11
+ this.claudePath = path.join(process.env.HOME, '.claude');
12
+ this.historyPath = path.join(this.claudePath, 'history.jsonl');
13
+ this.projectsPath = path.join(this.claudePath, 'projects');
14
+ this.cacheTtlMs = 5 * 60 * 1000; // 5 minutes
15
+ this.historyCache = {
16
+ entries: null,
17
+ timestamp: 0
18
+ };
19
+ this.registerWatcher();
20
+ }
21
+
22
+ /**
23
+ * Convert workspace path to slug (for .claude/projects/ directory)
24
+ * @param {string} workspacePath - Absolute path
25
+ * @returns {string} Slug
26
+ */
27
+ pathToSlug(workspacePath) {
28
+ // Claude Code slugs: /data/data/com.termux → -data-data-com-termux
29
+ return workspacePath.replace(/\//g, '-').replace(/\./g, '-');
30
+ }
31
+
32
+ /**
33
+ * Convert slug back to workspace path
34
+ * @param {string} slug - Directory name from .claude/projects/
35
+ * @returns {string} Absolute path
36
+ */
37
+ slugToPath(slug) {
38
+ return '/' + slug.replace(/^-/, '').replace(/-/g, '/');
39
+ }
40
+
41
+ /**
42
+ * Discover all workspaces from .claude/projects/
43
+ * Reads real workspace path from session file 'cwd' field
44
+ * @returns {Promise<Array>} List of workspaces with session counts
45
+ */
46
+ async discoverWorkspaces() {
47
+ if (!fs.existsSync(this.projectsPath)) {
48
+ return [];
49
+ }
50
+
51
+ const entries = fs.readdirSync(this.projectsPath, { withFileTypes: true });
52
+ const workspaces = [];
53
+
54
+ for (const entry of entries) {
55
+ if (!entry.isDirectory()) continue;
56
+
57
+ const projectDir = path.join(this.projectsPath, entry.name);
58
+
59
+ // Count session files (exclude agent-* files)
60
+ const sessionFiles = fs.readdirSync(projectDir)
61
+ .filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-'));
62
+
63
+ if (sessionFiles.length > 0) {
64
+ // Read real workspace path from first session file 'cwd' field
65
+ // NOTE: slugToPath is unreliable (dots vs dashes ambiguity), so cwd is the only source of truth
66
+ let workspacePath = null;
67
+ try {
68
+ const firstFile = path.join(projectDir, sessionFiles[0]);
69
+ const firstLine = fs.readFileSync(firstFile, 'utf8').split('\n')[0];
70
+ const parsed = JSON.parse(firstLine);
71
+ workspacePath = parsed.cwd;
72
+ } catch (error) {
73
+ // Silent - old sessions may not have cwd field
74
+ }
75
+
76
+ // Only add if we got a valid cwd and path exists
77
+ if (workspacePath && fs.existsSync(workspacePath)) {
78
+ workspaces.push({
79
+ workspace_path: workspacePath,
80
+ slug: entry.name,
81
+ session_count: sessionFiles.length
82
+ });
83
+ }
84
+ // Skip silently if cwd unavailable - these are legacy sessions
85
+ }
86
+ }
87
+
88
+ console.log(`[WorkspaceManager] Discovered ${workspaces.length} workspaces from projects directory`);
89
+ return workspaces;
90
+ }
91
+
92
+ /**
93
+ * Mount a workspace (validate + index sessions)
94
+ * @param {string} workspacePath - Absolute path to workspace
95
+ * @returns {Promise<Object>} Workspace info + sessions
96
+ */
97
+ async mountWorkspace(workspacePath) {
98
+ const startedAt = Date.now();
99
+ console.log(`[WorkspaceManager] Mounting workspace: ${workspacePath}`);
100
+
101
+ // 1. Validate workspace path
102
+ const validatedPath = await this.validateWorkspace(workspacePath);
103
+
104
+ // 2. Index CLI sessions in this workspace
105
+ const sessions = await this.indexWorkspaceSessions(validatedPath);
106
+
107
+ // 3. Get workspace memory (if exists)
108
+ const memory = await this.getWorkspaceMemory(validatedPath);
109
+
110
+ console.log(`[WorkspaceManager] Mount complete: ${sessions.length} sessions found (${Date.now() - startedAt}ms)`);
111
+
112
+ return {
113
+ workspacePath: validatedPath,
114
+ sessions,
115
+ memory,
116
+ sessionCount: sessions.length
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Validate workspace path
122
+ * @param {string} workspacePath
123
+ * @returns {Promise<string>} Resolved absolute path
124
+ */
125
+ async validateWorkspace(workspacePath) {
126
+ // Check exists
127
+ if (!fs.existsSync(workspacePath)) {
128
+ throw new Error(`Workspace path does not exist: ${workspacePath}`);
129
+ }
130
+
131
+ // Check readable
132
+ try {
133
+ fs.accessSync(workspacePath, fs.constants.R_OK);
134
+ } catch (e) {
135
+ throw new Error(`No read permission: ${workspacePath}`);
136
+ }
137
+
138
+ // Resolve symlinks
139
+ const realPath = fs.realpathSync(workspacePath);
140
+ if (realPath !== workspacePath) {
141
+ console.log(`[WorkspaceManager] Resolved symlink: ${workspacePath} → ${realPath}`);
142
+ }
143
+
144
+ // Path traversal protection
145
+ const resolved = path.resolve(realPath);
146
+
147
+ // On Termux/Android, HOME is /data/data/com.termux/files/home
148
+ const homeDir = process.env.HOME || '/home';
149
+ const allowedRoots = ['/home', '/var', '/opt', '/data', homeDir];
150
+ const isAllowed = allowedRoots.some(root => resolved.startsWith(root));
151
+
152
+ if (!isAllowed) {
153
+ throw new Error(`Workspace path not in allowed directories: ${resolved}`);
154
+ }
155
+
156
+ return resolved;
157
+ }
158
+
159
+ /**
160
+ * Index all CLI sessions in workspace
161
+ * @param {string} workspacePath
162
+ * @returns {Promise<Array>} Sessions found
163
+ */
164
+ async indexWorkspaceSessions(workspacePath) {
165
+ const startedAt = Date.now();
166
+ const sessions = [];
167
+
168
+ // Index Claude Code sessions
169
+ const claudeSessions = await this.indexClaudeCodeSessions(workspacePath);
170
+ sessions.push(...claudeSessions);
171
+
172
+ // TODO: Index other CLI tools (Codex, Aider, etc.)
173
+
174
+ // Sync sessions to database (batch mode to avoid "Statement closed" errors)
175
+ try {
176
+ await this.batchSyncSessions(sessions);
177
+ } catch (error) {
178
+ console.error('[WorkspaceManager] Batch sync error:', error);
179
+ throw error; // Re-throw to see full stack trace
180
+ }
181
+
182
+ console.log(`[WorkspaceManager] Indexed ${sessions.length} sessions in ${Date.now() - startedAt}ms`);
183
+ return sessions;
184
+ }
185
+
186
+ /**
187
+ * Batch sync sessions to database (avoids sql.js statement closure issues)
188
+ * @param {Array} sessions
189
+ */
190
+ async batchSyncSessions(sessions) {
191
+ // Process each session individually with fresh statements
192
+ // sql.js closes statements unpredictably, so we can't reuse them
193
+ for (const session of sessions) {
194
+ try {
195
+ // Check if session exists (fresh statement each time)
196
+ const checkStmt = prepare('SELECT id, message_count FROM sessions WHERE id = ?');
197
+ const existing = checkStmt.get(session.id);
198
+
199
+ if (existing) {
200
+ if (existing.message_count !== session.message_count) {
201
+ const updateStmt = prepare('UPDATE sessions SET last_used_at = ?, message_count = ? WHERE id = ?');
202
+ updateStmt.run(session.last_used_at, session.message_count, session.id);
203
+ console.log(`[WorkspaceManager] Updated session: ${session.id}`);
204
+ }
205
+ } else {
206
+ const insertStmt = prepare('INSERT INTO sessions (id, engine, workspace_path, session_path, title, last_used_at, created_at, message_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
207
+ insertStmt.run(
208
+ session.id,
209
+ session.engine,
210
+ session.workspace_path,
211
+ session.session_path,
212
+ session.title,
213
+ session.last_used_at,
214
+ session.created_at,
215
+ session.message_count
216
+ );
217
+ console.log(`[WorkspaceManager] Indexed new session: ${session.id}`);
218
+ }
219
+
220
+ // Sync messages (fresh statement for each session)
221
+ if (session.messages && session.messages.length > 0) {
222
+ for (const msg of session.messages) {
223
+ const msgStmt = prepare('INSERT OR REPLACE INTO messages (id, conversation_id, role, content, created_at) VALUES (?, ?, ?, ?, ?)');
224
+ const msgId = `${session.id}-${msg.timestamp}`;
225
+ const timestamp = new Date(msg.timestamp).getTime();
226
+ msgStmt.run(msgId, session.id, msg.role, msg.content, timestamp);
227
+ }
228
+ }
229
+ } catch (error) {
230
+ console.error(`[WorkspaceManager] Error syncing session ${session.id}:`, error);
231
+ // Continue with next session even if one fails
232
+ }
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Index Claude Code sessions from .claude/projects/[workspace-slug]/
238
+ * @param {string} workspacePath
239
+ * @returns {Promise<Array>}
240
+ */
241
+ async indexClaudeCodeSessions(workspacePath) {
242
+ const slug = this.pathToSlug(workspacePath);
243
+ const projectDir = path.join(this.projectsPath, slug);
244
+
245
+ if (!fs.existsSync(projectDir)) {
246
+ console.log(`[WorkspaceManager] No project directory for workspace: ${workspacePath}`);
247
+ return [];
248
+ }
249
+
250
+ // List session files (exclude agent-* files)
251
+ const sessionFiles = fs.readdirSync(projectDir)
252
+ .filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-'));
253
+
254
+ console.log(`[WorkspaceManager] Found ${sessionFiles.length} session files in ${slug}`);
255
+
256
+ const sessions = [];
257
+
258
+ for (const file of sessionFiles) {
259
+ const sessionId = file.replace('.jsonl', '');
260
+ const sessionPath = path.join(projectDir, file);
261
+
262
+ // Read session file line by line
263
+ const messages = [];
264
+ let firstTimestamp = null;
265
+ let lastTimestamp = null;
266
+
267
+ try {
268
+ const fileStream = fs.createReadStream(sessionPath);
269
+ const rl = readline.createInterface({
270
+ input: fileStream,
271
+ crlfDelay: Infinity
272
+ });
273
+
274
+ for await (const line of rl) {
275
+ if (!line.trim()) continue;
276
+ try {
277
+ const entry = JSON.parse(line);
278
+
279
+ // Only include user/assistant messages (skip queue operations)
280
+ if (entry.type === 'user' || entry.type === 'assistant') {
281
+ const timestamp = new Date(entry.timestamp).getTime();
282
+ if (!firstTimestamp || timestamp < firstTimestamp) firstTimestamp = timestamp;
283
+ if (!lastTimestamp || timestamp > lastTimestamp) lastTimestamp = timestamp;
284
+
285
+ // Extract content - handle both string and array of content blocks
286
+ let content = '';
287
+ const rawContent = entry.message?.content;
288
+ if (typeof rawContent === 'string') {
289
+ content = rawContent;
290
+ } else if (Array.isArray(rawContent)) {
291
+ // Claude Code uses array of content blocks: [{type: 'text', text: '...'}, ...]
292
+ content = rawContent
293
+ .filter(block => block.type === 'text' && block.text)
294
+ .map(block => block.text)
295
+ .join('\n');
296
+ }
297
+
298
+ messages.push({
299
+ role: entry.message?.role || entry.type,
300
+ content: content,
301
+ timestamp: entry.timestamp
302
+ });
303
+ }
304
+ } catch (e) {
305
+ // Skip malformed lines
306
+ }
307
+ }
308
+ } catch (error) {
309
+ console.error(`[WorkspaceManager] Error reading session ${sessionId}:`, error.message);
310
+ continue;
311
+ }
312
+
313
+ if (messages.length > 0) {
314
+ sessions.push({
315
+ id: sessionId,
316
+ engine: 'claude-code',
317
+ workspace_path: workspacePath,
318
+ session_path: sessionPath,
319
+ title: this.extractTitle(messages),
320
+ message_count: messages.length,
321
+ last_used_at: lastTimestamp,
322
+ created_at: firstTimestamp
323
+ // NOTE: messages NOT included - loaded on-demand by CliLoader (filesystem = source of truth)
324
+ });
325
+ }
326
+ }
327
+
328
+ // Sort by most recent first
329
+ sessions.sort((a, b) => b.last_used_at - a.last_used_at);
330
+
331
+ // Limit to most recent 10 sessions (lazy load the rest)
332
+ const limit = 10;
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;
338
+ }
339
+
340
+ /**
341
+ * Return cached history entries with TTL and fs.watch invalidation.
342
+ */
343
+ async getHistoryEntries() {
344
+ const now = Date.now();
345
+
346
+ if (this.historyCache.entries && now - this.historyCache.timestamp < this.cacheTtlMs) {
347
+ return this.historyCache.entries;
348
+ }
349
+
350
+ if (!fs.existsSync(this.historyPath)) {
351
+ return null;
352
+ }
353
+
354
+ const entries = [];
355
+ const fileStream = fs.createReadStream(this.historyPath);
356
+ const rl = readline.createInterface({
357
+ input: fileStream,
358
+ crlfDelay: Infinity
359
+ });
360
+
361
+ for await (const line of rl) {
362
+ if (!line.trim()) continue;
363
+ try {
364
+ const entry = JSON.parse(line);
365
+ entries.push(entry);
366
+ } catch (e) {
367
+ // skip malformed
368
+ }
369
+ }
370
+
371
+ this.historyCache = {
372
+ entries,
373
+ timestamp: now
374
+ };
375
+
376
+ return entries;
377
+ }
378
+
379
+ /**
380
+ * Watch history file and invalidate cache on change.
381
+ */
382
+ registerWatcher() {
383
+ try {
384
+ fs.watch(this.historyPath, () => {
385
+ this.historyCache = { entries: null, timestamp: 0 };
386
+ console.log('[WorkspaceManager] history cache invalidated (fs watch)');
387
+ });
388
+ } catch (error) {
389
+ // File may not exist yet; noop
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Get session path for Claude Code
395
+ * @param {string} workspacePath
396
+ * @returns {string}
397
+ */
398
+ getSessionPath(workspacePath) {
399
+ // Convert /home/user/myproject → -home-user-myproject
400
+ const projectDir = workspacePath.replace(/\//g, '-').replace(/^-/, '');
401
+ return path.join(this.claudePath, 'projects', projectDir);
402
+ }
403
+
404
+ /**
405
+ * Extract title from messages (like NexusChat)
406
+ * Uses first user message content, truncated to ~50 chars at word boundary
407
+ * @param {Array} messages
408
+ * @returns {string}
409
+ */
410
+ extractTitle(messages) {
411
+ if (messages.length === 0) return 'Empty Session';
412
+
413
+ // Find first user message
414
+ const firstUserMessage = messages.find(m => m.role === 'user');
415
+ if (!firstUserMessage) return 'New Chat';
416
+
417
+ // Get content (support both 'content' and 'display' fields)
418
+ const content = firstUserMessage.content || firstUserMessage.display || '';
419
+ if (!content || content.trim() === '') return 'New Chat';
420
+
421
+ // Clean up: normalize whitespace, remove newlines
422
+ const cleaned = content.replace(/\s+/g, ' ').trim();
423
+
424
+ // Truncate to 50 chars at word boundary (like NexusChat)
425
+ if (cleaned.length <= 50) {
426
+ return cleaned;
427
+ }
428
+
429
+ const truncated = cleaned.substring(0, 50);
430
+ const lastSpace = truncated.lastIndexOf(' ');
431
+
432
+ // If space found after first 20 chars, cut at word boundary
433
+ if (lastSpace > 20) {
434
+ return truncated.substring(0, lastSpace) + '...';
435
+ }
436
+
437
+ return truncated + '...';
438
+ }
439
+
440
+ /**
441
+ * Sync session to database
442
+ * @param {Object} session
443
+ */
444
+ async syncSessionToDb(session) {
445
+ // Check if exists
446
+ const existingStmt = prepare('SELECT id, message_count FROM sessions WHERE id = ?');
447
+ const existing = existingStmt.get(session.id);
448
+
449
+ if (existing) {
450
+ // Update only if message count changed
451
+ if (existing.message_count !== session.message_count) {
452
+ const updateStmt = prepare(`
453
+ UPDATE sessions
454
+ SET last_used_at = ?, message_count = ?
455
+ WHERE id = ?
456
+ `);
457
+ updateStmt.run(session.last_used_at, session.message_count, session.id);
458
+ console.log(`[WorkspaceManager] Updated session: ${session.id}`);
459
+ }
460
+ } else {
461
+ // Insert new session
462
+ const insertStmt = prepare(`
463
+ INSERT INTO sessions (
464
+ id, engine, workspace_path, session_path, title,
465
+ last_used_at, created_at, message_count
466
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
467
+ `);
468
+ insertStmt.run(
469
+ session.id,
470
+ session.engine,
471
+ session.workspace_path,
472
+ session.session_path,
473
+ session.title,
474
+ session.last_used_at,
475
+ session.created_at,
476
+ session.message_count
477
+ );
478
+ console.log(`[WorkspaceManager] Indexed new session: ${session.id}`);
479
+ }
480
+
481
+ // Sync messages to database (if available)
482
+ if (session.messages && session.messages.length > 0) {
483
+ const msgStmt = prepare(`
484
+ INSERT OR REPLACE INTO messages (
485
+ id, conversation_id, role, content, created_at
486
+ ) VALUES (?, ?, ?, ?, ?)
487
+ `);
488
+
489
+ for (const msg of session.messages) {
490
+ const msgId = `${session.id}-${msg.timestamp}`;
491
+ const timestamp = new Date(msg.timestamp).getTime();
492
+ msgStmt.run(msgId, session.id, msg.role, msg.content, timestamp);
493
+ }
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Get workspace memory
499
+ * @param {string} workspacePath
500
+ * @returns {Promise<Object|null>}
501
+ */
502
+ async getWorkspaceMemory(workspacePath) {
503
+ const stmt = prepare('SELECT * FROM workspace_memory WHERE workspace_path = ?');
504
+ const memory = stmt.get(workspacePath);
505
+
506
+ if (memory) {
507
+ // Parse JSON fields
508
+ if (memory.tech_stack) memory.tech_stack = JSON.parse(memory.tech_stack);
509
+ if (memory.important_files) memory.important_files = JSON.parse(memory.important_files);
510
+ }
511
+
512
+ return memory || null;
513
+ }
514
+ }
515
+
516
+ module.exports = WorkspaceManager;
@@ -0,0 +1,90 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+
5
+ describe('HistorySync', () => {
6
+ let tmpDir;
7
+ let historyPath;
8
+ let HistorySync;
9
+ let initDb;
10
+ let prepare;
11
+ let getDb;
12
+ let historySync;
13
+
14
+ const writeHistory = () => {
15
+ const entries = [
16
+ { sessionId: 'test-1', display: 'First message', timestamp: 1000, project: '/test/path' },
17
+ { sessionId: 'test-1', display: 'Second message', timestamp: 2000, project: '/test/path' },
18
+ { sessionId: 'test-2', display: 'Another session', timestamp: 3000, project: '/other/path' }
19
+ ];
20
+
21
+ const content = entries.map(e => JSON.stringify(e)).join('\n');
22
+ fs.mkdirSync(path.dirname(historyPath), { recursive: true });
23
+ fs.writeFileSync(historyPath, content, 'utf8');
24
+ };
25
+
26
+ beforeEach(async () => {
27
+ // Fresh temp DB per test
28
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nexus-db-'));
29
+ process.env.NEXUSCLI_DB_DIR = tmpDir;
30
+
31
+ jest.resetModules();
32
+ ({ initDb, prepare, getDb } = require('../db'));
33
+ await initDb();
34
+
35
+ HistorySync = require('../services/history-sync');
36
+
37
+ historyPath = path.join(tmpDir, 'test-history.jsonl');
38
+ writeHistory();
39
+
40
+ historySync = new HistorySync({ historyPath, syncCacheMs: 0 });
41
+ });
42
+
43
+ afterEach(() => {
44
+ try {
45
+ const db = getDb();
46
+ if (db && typeof db.close === 'function') {
47
+ db.close();
48
+ }
49
+ } catch (_) {
50
+ // ignore cleanup errors
51
+ }
52
+ });
53
+
54
+ test('parseHistory groups by sessionId', async () => {
55
+ const sessions = await historySync.parseHistory();
56
+
57
+ expect(sessions.size).toBe(2);
58
+ expect(sessions.get('test-1').messages).toHaveLength(2);
59
+ expect(sessions.get('test-2').messages).toHaveLength(1);
60
+ expect(sessions.get('test-1').project).toBe('/test/path');
61
+ });
62
+
63
+ test('syncToDatabase creates conversations and messages', async () => {
64
+ const sessions = await historySync.parseHistory();
65
+ const result = await historySync.syncToDatabase(sessions, { force: true });
66
+
67
+ expect(result.newConversations).toBe(2);
68
+ expect(result.newMessages).toBe(3);
69
+
70
+ const convRow = prepare('SELECT metadata FROM conversations WHERE id = ?').get('test-1');
71
+ const metadata = JSON.parse(convRow.metadata);
72
+ expect(metadata.workspace).toBe('/test/path');
73
+ });
74
+
75
+ test('incremental sync does not duplicate messages', async () => {
76
+ await historySync.sync(true);
77
+ const second = await historySync.sync(true);
78
+
79
+ expect(second.newConversations).toBe(0);
80
+ expect(second.newMessages).toBe(0);
81
+ });
82
+
83
+ test('getWorkspaceSessions filters by workspace path', async () => {
84
+ await historySync.sync(true);
85
+
86
+ const sessions = await historySync.getWorkspaceSessions('/test/path');
87
+ expect(sessions).toHaveLength(1);
88
+ expect(sessions[0].id).toBe('test-1');
89
+ });
90
+ });