@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,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
|
+
});
|