@mmmbuto/nexuscrew 0.1.0-beta.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -120
- package/bin/nexuscrew.js +468 -264
- package/frontend/dist/apple-touch-icon.png +0 -0
- package/frontend/dist/assets/{index-C7bAndew.js → index-BG1N7bSL.js} +1710 -1702
- package/frontend/dist/assets/index-CiAtinNP.css +1 -0
- package/frontend/dist/favicon.svg +20 -0
- package/frontend/dist/icon-192.png +0 -0
- package/frontend/dist/icon-512.png +0 -0
- package/frontend/dist/index.html +11 -3
- package/frontend/dist/site.webmanifest +29 -0
- package/lib/config/hosts.js +67 -0
- package/lib/config/manager.js +379 -0
- package/lib/config/models.js +408 -0
- package/lib/server/db/adapter.js +274 -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_base_schema.sql +70 -0
- package/lib/server/middleware/auth.js +134 -0
- package/lib/server/middleware/rate-limit.js +63 -0
- package/lib/server/models/User.js +128 -0
- package/lib/server/routes/auth.js +168 -0
- package/lib/server/routes/hosts.js +11 -20
- package/lib/server/routes/keys.js +28 -0
- package/lib/server/routes/models.js +59 -4
- package/lib/server/routes/runtimes.js +34 -0
- package/lib/server/routes/send.js +76 -12
- package/lib/server/routes/sessions.js +39 -10
- package/lib/server/routes/speech.js +46 -0
- package/lib/server/routes/status.js +8 -6
- package/lib/server/routes/upload.js +135 -0
- package/lib/server/routes/wake-lock.js +95 -0
- package/lib/server/routes/workspaces.js +101 -0
- package/lib/server/server.js +66 -33
- package/lib/server/services/attachment-manager.js +57 -0
- package/lib/server/services/context-bridge.js +425 -0
- package/lib/server/services/runtime-manager.js +462 -0
- package/lib/server/services/speech-manager.js +76 -0
- package/lib/server/services/summary-generator.js +309 -0
- package/lib/server/services/workspace-manager.js +79 -0
- package/lib/services/engine-discovery.js +198 -13
- package/lib/services/log-watcher.js +40 -22
- package/lib/services/remote-pane-watcher.js +155 -0
- package/lib/services/session-store.js +60 -64
- package/lib/services/tmux-manager.js +127 -116
- package/lib/setup/postinstall.js +38 -0
- package/lib/utils/paths.js +124 -0
- package/lib/utils/termux.js +182 -0
- package/package.json +8 -4
- package/frontend/dist/assets/index-OENqI1_9.css +0 -1
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SummaryGenerator - produce contextual summaries for sessions.
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Generate AI titles for new conversations
|
|
6
|
+
* - Generate contextual summaries for sessions
|
|
7
|
+
* - Auto-save summaries to session_summaries table
|
|
8
|
+
* - Support for summary-based context bridging
|
|
9
|
+
*
|
|
10
|
+
* Note: AI generation requires external provider configuration.
|
|
11
|
+
* This implementation provides the structure; actual AI calls
|
|
12
|
+
* can be integrated via environment-provided clients or webhooks.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { v4: uuidv4 } = require('uuid');
|
|
16
|
+
const { prepare, saveDb } = require('../db/adapter');
|
|
17
|
+
|
|
18
|
+
class SummaryGenerator {
|
|
19
|
+
constructor(options = {}) {
|
|
20
|
+
this.options = options;
|
|
21
|
+
// AI client can be injected or configured later
|
|
22
|
+
this.aiClient = options.aiClient || null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Set AI client for summary generation
|
|
27
|
+
* @param {Object} client - AI client with sendMessage method
|
|
28
|
+
*/
|
|
29
|
+
setAIClient(client) {
|
|
30
|
+
this.aiClient = client;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Generate a short title for a new conversation (3-8 words)
|
|
35
|
+
* Called after first AI response, runs in background
|
|
36
|
+
* @param {string} userMessage - First user message
|
|
37
|
+
* @param {string} assistantResponse - First AI response (optional, for context)
|
|
38
|
+
* @returns {Promise<string>} Generated title
|
|
39
|
+
*/
|
|
40
|
+
async generateTitle(userMessage, assistantResponse = '') {
|
|
41
|
+
const startedAt = Date.now();
|
|
42
|
+
|
|
43
|
+
// Keep context brief for fast generation
|
|
44
|
+
const context = assistantResponse
|
|
45
|
+
? `User: ${userMessage.slice(0, 500)}\nAssistant: ${assistantResponse.slice(0, 500)}`
|
|
46
|
+
: `User: ${userMessage.slice(0, 800)}`;
|
|
47
|
+
|
|
48
|
+
const prompt = `Generate a brief title (3-8 words, no quotes) for this conversation:
|
|
49
|
+
|
|
50
|
+
${context}
|
|
51
|
+
|
|
52
|
+
Reply with ONLY the title, nothing else.`;
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
if (!this.aiClient) {
|
|
56
|
+
console.warn('[SummaryGenerator] No AI client configured, using fallback title generation');
|
|
57
|
+
return this.fallbackTitle(userMessage);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const result = await this.aiClient.sendMessage({
|
|
61
|
+
prompt,
|
|
62
|
+
conversationId: uuidv4(), // Ephemeral session
|
|
63
|
+
model: 'haiku',
|
|
64
|
+
onStatus: () => {} // Silence status
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Clean up the response
|
|
68
|
+
let title = result.text.trim()
|
|
69
|
+
.replace(/^["']|["']$/g, '') // Remove surrounding quotes
|
|
70
|
+
.replace(/^Title:\s*/i, '') // Remove "Title:" prefix
|
|
71
|
+
.slice(0, 60); // Max 60 chars
|
|
72
|
+
|
|
73
|
+
console.log(`[SummaryGenerator] Title generated in ${Date.now() - startedAt}ms: ${title}`);
|
|
74
|
+
return title || 'New Chat';
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.error('[SummaryGenerator] Title generation failed:', error.message);
|
|
77
|
+
// Fallback to truncated first message
|
|
78
|
+
return this.fallbackTitle(userMessage);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Fallback title generation (simple truncation)
|
|
84
|
+
* @param {string} userMessage
|
|
85
|
+
* @returns {string}
|
|
86
|
+
*/
|
|
87
|
+
fallbackTitle(userMessage) {
|
|
88
|
+
return userMessage.slice(0, 50).trim() + (userMessage.length > 50 ? '...' : '');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Generate or refresh summary for a session.
|
|
93
|
+
* @param {Object} params
|
|
94
|
+
* @param {string} params.conversationId
|
|
95
|
+
* @param {Array} params.messages - Array of {role, content, created_at}
|
|
96
|
+
* @param {Object|null} params.existingSummary
|
|
97
|
+
* @returns {Promise<Object>} summary payload
|
|
98
|
+
*/
|
|
99
|
+
async generateSummary({ conversationId, messages = [], existingSummary = null }) {
|
|
100
|
+
if (!conversationId) throw new Error('conversationId is required');
|
|
101
|
+
|
|
102
|
+
const startedAt = Date.now();
|
|
103
|
+
|
|
104
|
+
// Build transcript snippet (limit to 40 latest messages for brevity)
|
|
105
|
+
const transcript = this.buildTranscript(messages.slice(-40));
|
|
106
|
+
|
|
107
|
+
const prompt = this.buildPrompt({ conversationId, transcript, existingSummary });
|
|
108
|
+
|
|
109
|
+
if (!this.aiClient) {
|
|
110
|
+
console.warn('[SummaryGenerator] No AI client configured, using fallback summary');
|
|
111
|
+
return this.fallbackSummary(messages);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const result = await this.aiClient.sendMessage({
|
|
115
|
+
prompt,
|
|
116
|
+
conversationId: uuidv4(), // Ephemeral session
|
|
117
|
+
model: 'haiku',
|
|
118
|
+
onStatus: () => {} // silence status for summaries
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const parsed = this.safeParseJson(result.text);
|
|
122
|
+
|
|
123
|
+
console.log(`[SummaryGenerator] Summary for ${conversationId} took ${Date.now() - startedAt}ms`);
|
|
124
|
+
|
|
125
|
+
return parsed;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Fallback summary generation (rule-based)
|
|
130
|
+
* @param {Array} messages
|
|
131
|
+
* @returns {Object}
|
|
132
|
+
*/
|
|
133
|
+
fallbackSummary(messages) {
|
|
134
|
+
const userMessages = messages.filter(m => m.role === 'user');
|
|
135
|
+
const assistantMessages = messages.filter(m => m.role === 'assistant');
|
|
136
|
+
|
|
137
|
+
// Extract first user message as summary
|
|
138
|
+
const firstUserMsg = userMessages[0]?.content || '';
|
|
139
|
+
const summary = firstUserMsg.slice(0, 200) + (firstUserMsg.length > 200 ? '...' : '');
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
summary_short: summary,
|
|
143
|
+
summary_long: null,
|
|
144
|
+
key_decisions: [],
|
|
145
|
+
tools_used: [],
|
|
146
|
+
files_modified: []
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Save summary to database
|
|
152
|
+
* @param {string} conversationId
|
|
153
|
+
* @param {Object} summary - {summary_short, summary_long, key_decisions, tools_used, files_modified}
|
|
154
|
+
* @returns {boolean} Success
|
|
155
|
+
*/
|
|
156
|
+
saveSummary(conversationId, summary) {
|
|
157
|
+
try {
|
|
158
|
+
const stmt = prepare(`
|
|
159
|
+
INSERT OR REPLACE INTO session_summaries
|
|
160
|
+
(conversation_id, summary_short, key_decisions, files_modified, updated_at)
|
|
161
|
+
VALUES (?, ?, ?, ?, ?)
|
|
162
|
+
`);
|
|
163
|
+
|
|
164
|
+
stmt.run(
|
|
165
|
+
conversationId,
|
|
166
|
+
summary.summary_short || '',
|
|
167
|
+
JSON.stringify(summary.key_decisions || []),
|
|
168
|
+
JSON.stringify(summary.files_modified || []),
|
|
169
|
+
Date.now()
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
saveDb();
|
|
173
|
+
console.log(`[SummaryGenerator] Saved summary for ${conversationId}`);
|
|
174
|
+
return true;
|
|
175
|
+
} catch (error) {
|
|
176
|
+
console.error(`[SummaryGenerator] Failed to save summary:`, error.message);
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Get existing summary from database
|
|
183
|
+
* @param {string} conversationId
|
|
184
|
+
* @returns {Object|null}
|
|
185
|
+
*/
|
|
186
|
+
getSummary(conversationId) {
|
|
187
|
+
try {
|
|
188
|
+
const stmt = prepare('SELECT * FROM session_summaries WHERE conversation_id = ?');
|
|
189
|
+
const row = stmt.get(conversationId);
|
|
190
|
+
|
|
191
|
+
if (!row) return null;
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
summary_short: row.summary_short,
|
|
195
|
+
summary_long: row.summary_long || null,
|
|
196
|
+
key_decisions: JSON.parse(row.key_decisions || '[]'),
|
|
197
|
+
files_modified: JSON.parse(row.files_modified || '[]'),
|
|
198
|
+
updated_at: row.updated_at
|
|
199
|
+
};
|
|
200
|
+
} catch (error) {
|
|
201
|
+
console.warn(`[SummaryGenerator] Failed to get summary:`, error.message);
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Generate summary and save to DB (convenience method)
|
|
208
|
+
* @param {string} conversationId
|
|
209
|
+
* @param {Array} messages
|
|
210
|
+
* @returns {Promise<Object|null>}
|
|
211
|
+
*/
|
|
212
|
+
async generateAndSave(conversationId, messages) {
|
|
213
|
+
try {
|
|
214
|
+
const existingSummary = this.getSummary(conversationId);
|
|
215
|
+
const summary = await this.generateSummary({ conversationId, messages, existingSummary });
|
|
216
|
+
this.saveSummary(conversationId, summary);
|
|
217
|
+
return summary;
|
|
218
|
+
} catch (error) {
|
|
219
|
+
console.error(`[SummaryGenerator] generateAndSave failed:`, error.message);
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Get summary text for context bridging
|
|
226
|
+
* Returns a formatted string suitable for prefixing prompts
|
|
227
|
+
* @param {string} conversationId
|
|
228
|
+
* @returns {string|null}
|
|
229
|
+
*/
|
|
230
|
+
getBridgeContext(conversationId) {
|
|
231
|
+
const summary = this.getSummary(conversationId);
|
|
232
|
+
if (!summary || !summary.summary_short) return null;
|
|
233
|
+
|
|
234
|
+
let context = `[Session Summary]\n${summary.summary_short}`;
|
|
235
|
+
|
|
236
|
+
if (summary.key_decisions && summary.key_decisions.length > 0) {
|
|
237
|
+
context += `\n\nKey decisions:\n- ${summary.key_decisions.slice(0, 5).join('\n- ')}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (summary.files_modified && summary.files_modified.length > 0) {
|
|
241
|
+
context += `\n\nFiles worked on:\n- ${summary.files_modified.slice(0, 10).join('\n- ')}`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return context;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
buildTranscript(messages) {
|
|
248
|
+
return messages
|
|
249
|
+
.map(m => {
|
|
250
|
+
const role = m.role || 'assistant';
|
|
251
|
+
const content = (m.content || '').trim();
|
|
252
|
+
const ts = m.created_at ? new Date(m.created_at * 1000).toISOString() : '';
|
|
253
|
+
return `[${ts}] ${role.toUpperCase()}: ${content}`;
|
|
254
|
+
})
|
|
255
|
+
.join('\n')
|
|
256
|
+
.slice(-6000); // keep prompt size reasonable
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
buildPrompt({ conversationId, transcript, existingSummary }) {
|
|
260
|
+
const existing = existingSummary
|
|
261
|
+
? `Existing summary (for refresh): ${JSON.stringify(existingSummary)}`
|
|
262
|
+
: 'No existing summary.';
|
|
263
|
+
|
|
264
|
+
return `
|
|
265
|
+
You are a concise assistant. Summarize the coding/chat session into JSON.
|
|
266
|
+
Conversation ID: ${conversationId}
|
|
267
|
+
|
|
268
|
+
${existing}
|
|
269
|
+
|
|
270
|
+
Provide JSON with keys:
|
|
271
|
+
- summary_short (<=80 words)
|
|
272
|
+
- summary_long (<=200 words, optional)
|
|
273
|
+
- key_decisions (array of short bullet strings)
|
|
274
|
+
- tools_used (array of tool names or commands, optional)
|
|
275
|
+
- files_modified (array of file paths)
|
|
276
|
+
|
|
277
|
+
Do not include any extra text outside valid JSON.
|
|
278
|
+
|
|
279
|
+
Transcript:
|
|
280
|
+
${transcript}
|
|
281
|
+
`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
safeParseJson(text) {
|
|
285
|
+
// Try to extract JSON block
|
|
286
|
+
const start = text.indexOf('{');
|
|
287
|
+
const end = text.lastIndexOf('}');
|
|
288
|
+
if (start === -1 || end === -1) {
|
|
289
|
+
throw new Error('Failed to parse summary JSON');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const jsonString = text.slice(start, end + 1);
|
|
293
|
+
|
|
294
|
+
try {
|
|
295
|
+
const parsed = JSON.parse(jsonString);
|
|
296
|
+
// Normalize array fields
|
|
297
|
+
['key_decisions', 'tools_used', 'files_modified'].forEach(key => {
|
|
298
|
+
if (parsed[key] && !Array.isArray(parsed[key])) {
|
|
299
|
+
parsed[key] = [parsed[key]].filter(Boolean);
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
return parsed;
|
|
303
|
+
} catch (e) {
|
|
304
|
+
throw new Error('Invalid JSON returned by model');
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
module.exports = SummaryGenerator;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace Manager - NexusCrew tmux-based version
|
|
3
|
+
*
|
|
4
|
+
* Simplified version that discovers workspaces from conversations table
|
|
5
|
+
* instead of reading .claude/projects/ JSONL files (NexusCLI approach).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const { prepare } = require('../db/adapter');
|
|
10
|
+
|
|
11
|
+
class WorkspaceManager {
|
|
12
|
+
/**
|
|
13
|
+
* Discover workspaces from conversations table
|
|
14
|
+
* Groups by workspace path and counts sessions
|
|
15
|
+
* @returns {Array} List of workspaces with metadata
|
|
16
|
+
*/
|
|
17
|
+
discoverWorkspaces() {
|
|
18
|
+
const stmt = prepare(`
|
|
19
|
+
SELECT workspace, COUNT(*) as session_count,
|
|
20
|
+
MAX(updated_at) as last_activity
|
|
21
|
+
FROM conversations
|
|
22
|
+
WHERE workspace != '' AND workspace IS NOT NULL
|
|
23
|
+
GROUP BY workspace
|
|
24
|
+
ORDER BY last_activity DESC
|
|
25
|
+
`);
|
|
26
|
+
|
|
27
|
+
return stmt.all().map(row => ({
|
|
28
|
+
path: row.workspace,
|
|
29
|
+
session_count: row.session_count,
|
|
30
|
+
last_activity: row.last_activity
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Validate workspace path exists on filesystem
|
|
36
|
+
* @param {string} wsPath - Absolute path to workspace
|
|
37
|
+
* @returns {boolean} True if path exists and is a directory
|
|
38
|
+
*/
|
|
39
|
+
validateWorkspace(wsPath) {
|
|
40
|
+
try {
|
|
41
|
+
return fs.statSync(wsPath).isDirectory();
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get conversations for a specific workspace
|
|
49
|
+
* @param {string} workspace - Workspace path
|
|
50
|
+
* @returns {Array} List of conversations in workspace
|
|
51
|
+
*/
|
|
52
|
+
getWorkspaceConversations(workspace) {
|
|
53
|
+
const stmt = prepare(`
|
|
54
|
+
SELECT * FROM conversations
|
|
55
|
+
WHERE workspace = ?
|
|
56
|
+
ORDER BY updated_at DESC
|
|
57
|
+
`);
|
|
58
|
+
return stmt.all(workspace);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Get workspace statistics
|
|
63
|
+
* @param {string} workspace - Workspace path
|
|
64
|
+
* @returns {Object} Statistics for the workspace
|
|
65
|
+
*/
|
|
66
|
+
getWorkspaceStats(workspace) {
|
|
67
|
+
const stmt = prepare(`
|
|
68
|
+
SELECT
|
|
69
|
+
COUNT(*) as total_conversations,
|
|
70
|
+
SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active_conversations,
|
|
71
|
+
SUM(CASE WHEN pinned = 1 THEN 1 ELSE 0 END) as pinned_conversations
|
|
72
|
+
FROM conversations
|
|
73
|
+
WHERE workspace = ?
|
|
74
|
+
`);
|
|
75
|
+
return stmt.get(workspace);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = WorkspaceManager;
|
|
@@ -37,6 +37,19 @@ const CLI_SEARCH_PATHS = {
|
|
|
37
37
|
]
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
+
const SHELL_SOURCES = [
|
|
41
|
+
'${HOME}/.zshrc',
|
|
42
|
+
'${HOME}/.bashrc',
|
|
43
|
+
'${HOME}/.bash_profile',
|
|
44
|
+
'${HOME}/.profile',
|
|
45
|
+
'${HOME}/.zprofile',
|
|
46
|
+
'${HOME}/.config/ai-shell/providers.zsh',
|
|
47
|
+
'${HOME}/.config/zsh/chutes-wrapper.zsh',
|
|
48
|
+
'${HOME}/.nexuscrew/providers.zsh'
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const ENGINE_PREFIXES = ['claude', 'codex', 'gemini', 'qwen'];
|
|
52
|
+
|
|
40
53
|
/**
|
|
41
54
|
* Engine types:
|
|
42
55
|
* - interactive: runs as REPL, needs PTY-like handling (send text + Enter)
|
|
@@ -187,25 +200,169 @@ class EngineDiscovery {
|
|
|
187
200
|
}
|
|
188
201
|
|
|
189
202
|
_parseProviders() {
|
|
190
|
-
|
|
203
|
+
// Use universal shell discovery instead of single file
|
|
204
|
+
return this._discoverShellSources();
|
|
205
|
+
}
|
|
191
206
|
|
|
192
|
-
|
|
207
|
+
/**
|
|
208
|
+
* Discover shell aliases and functions from all standard shell config files
|
|
209
|
+
* Parses:
|
|
210
|
+
* - alias name='command' / alias name="command"
|
|
211
|
+
* - name() { ... } function definitions
|
|
212
|
+
* - Follows source/. directives (1 level)
|
|
213
|
+
* - Extracts model hints from env vars and command args
|
|
214
|
+
*/
|
|
215
|
+
_discoverShellSources() {
|
|
216
|
+
const HOME = process.env.HOME || '';
|
|
193
217
|
const aliases = [];
|
|
218
|
+
const processedFiles = new Set();
|
|
219
|
+
|
|
220
|
+
const searchPaths = SHELL_SOURCES.map(p =>
|
|
221
|
+
p.replace(/\$\{HOME\}/g, HOME)
|
|
222
|
+
);
|
|
194
223
|
|
|
195
|
-
|
|
196
|
-
|
|
224
|
+
for (const filePath of searchPaths) {
|
|
225
|
+
if (!fs.existsSync(filePath) || processedFiles.has(filePath)) continue;
|
|
226
|
+
processedFiles.add(filePath);
|
|
197
227
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
228
|
+
try {
|
|
229
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
230
|
+
const lines = content.split('\n');
|
|
231
|
+
|
|
232
|
+
for (let i = 0; i < lines.length; i++) {
|
|
233
|
+
const line = lines[i].trim();
|
|
234
|
+
|
|
235
|
+
// Skip comments and empty lines
|
|
236
|
+
if (!line || line.startsWith('#')) continue;
|
|
237
|
+
|
|
238
|
+
// Follow source/. directives (1 level deep)
|
|
239
|
+
if (line.startsWith('source ') || line.startsWith('. ')) {
|
|
240
|
+
const sourcedFile = line.replace(/^source\s+|^\.\s+/, '').replace(/['"]/g, '').trim();
|
|
241
|
+
const resolvedPath = sourcedFile.startsWith('~')
|
|
242
|
+
? path.join(HOME, sourcedFile.slice(1))
|
|
243
|
+
: sourcedFile;
|
|
244
|
+
|
|
245
|
+
if (fs.existsSync(resolvedPath) && !processedFiles.has(resolvedPath)) {
|
|
246
|
+
processedFiles.add(resolvedPath);
|
|
247
|
+
try {
|
|
248
|
+
const sourcedContent = fs.readFileSync(resolvedPath, 'utf8');
|
|
249
|
+
this._parseShellContent(sourcedContent, aliases, resolvedPath);
|
|
250
|
+
} catch (err) {
|
|
251
|
+
// Skip unreadable sourced files
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Parse current line
|
|
258
|
+
this._parseShellLine(line, aliases, filePath);
|
|
259
|
+
}
|
|
260
|
+
} catch (err) {
|
|
261
|
+
// Skip unreadable files
|
|
262
|
+
}
|
|
204
263
|
}
|
|
205
264
|
|
|
206
265
|
return aliases;
|
|
207
266
|
}
|
|
208
267
|
|
|
268
|
+
/**
|
|
269
|
+
* Parse a single line for aliases or functions
|
|
270
|
+
*/
|
|
271
|
+
_parseShellLine(line, aliases, source) {
|
|
272
|
+
// Match: alias name='command' or alias name="command"
|
|
273
|
+
const aliasRegex = /^alias\s+([a-z0-9][a-z0-9-]*)\s*=\s*['"]([^'"]+)['"]/;
|
|
274
|
+
const aliasMatch = line.match(aliasRegex);
|
|
275
|
+
if (aliasMatch) {
|
|
276
|
+
const name = aliasMatch[1];
|
|
277
|
+
if (this._isEngineAlias(name)) {
|
|
278
|
+
aliases.push({
|
|
279
|
+
name,
|
|
280
|
+
command: aliasMatch[2],
|
|
281
|
+
source,
|
|
282
|
+
modelHint: this._extractModelHint(aliasMatch[2])
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Match: name() { ... } (function definition - single line)
|
|
289
|
+
const funcRegex = /^([a-z0-9][a-z0-9-]*)\(\)\s*\{([^}]*)\}/;
|
|
290
|
+
const funcMatch = line.match(funcRegex);
|
|
291
|
+
if (funcMatch) {
|
|
292
|
+
const name = funcMatch[1];
|
|
293
|
+
if (this._isEngineAlias(name)) {
|
|
294
|
+
aliases.push({
|
|
295
|
+
name,
|
|
296
|
+
command: funcMatch[2].trim(),
|
|
297
|
+
source,
|
|
298
|
+
modelHint: this._extractModelHint(funcMatch[2])
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Parse full file content for aliases and functions
|
|
306
|
+
*/
|
|
307
|
+
_parseShellContent(content, aliases, source) {
|
|
308
|
+
const lines = content.split('\n');
|
|
309
|
+
|
|
310
|
+
// Multi-line function buffer
|
|
311
|
+
let inFunction = false;
|
|
312
|
+
let funcName = null;
|
|
313
|
+
let funcBody = [];
|
|
314
|
+
|
|
315
|
+
for (const line of lines) {
|
|
316
|
+
const trimmed = line.trim();
|
|
317
|
+
|
|
318
|
+
// Skip comments and empty lines
|
|
319
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
320
|
+
|
|
321
|
+
// Function start
|
|
322
|
+
if (!inFunction) {
|
|
323
|
+
const funcStart = trimmed.match(/^([a-z0-9][a-z0-9-]*)\(\)\s*\{/);
|
|
324
|
+
if (funcStart) {
|
|
325
|
+
const name = funcStart[1];
|
|
326
|
+
if (this._isEngineAlias(name)) {
|
|
327
|
+
inFunction = true;
|
|
328
|
+
funcName = name;
|
|
329
|
+
funcBody = [];
|
|
330
|
+
}
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Single-line alias/function
|
|
335
|
+
this._parseShellLine(trimmed, aliases, source);
|
|
336
|
+
} else {
|
|
337
|
+
// Function end
|
|
338
|
+
if (trimmed === '}') {
|
|
339
|
+
inFunction = false;
|
|
340
|
+
if (funcName) {
|
|
341
|
+
aliases.push({
|
|
342
|
+
name: funcName,
|
|
343
|
+
command: funcBody.join(' ').trim(),
|
|
344
|
+
source,
|
|
345
|
+
modelHint: this._extractModelHint(funcBody.join(' '))
|
|
346
|
+
});
|
|
347
|
+
funcName = null;
|
|
348
|
+
funcBody = [];
|
|
349
|
+
}
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Accumulate function body
|
|
354
|
+
funcBody.push(trimmed);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Check if a name matches any engine prefix
|
|
361
|
+
*/
|
|
362
|
+
_isEngineAlias(name) {
|
|
363
|
+
return ENGINE_PREFIXES.some(prefix => name.startsWith(prefix));
|
|
364
|
+
}
|
|
365
|
+
|
|
209
366
|
_detectBaseEngine(name) {
|
|
210
367
|
if (name.startsWith('claude')) return 'claude';
|
|
211
368
|
if (name.startsWith('codex')) return 'codex';
|
|
@@ -257,9 +414,37 @@ class EngineDiscovery {
|
|
|
257
414
|
}
|
|
258
415
|
|
|
259
416
|
_extractModelHint(command) {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
417
|
+
if (!command) return null;
|
|
418
|
+
|
|
419
|
+
// Try quoted string first
|
|
420
|
+
const quotedMatch = command.match(/["']([^"']+)["']/);
|
|
421
|
+
if (quotedMatch) {
|
|
422
|
+
return quotedMatch[1];
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Extract from --model flag
|
|
426
|
+
const modelFlagMatch = command.match(/--model\s+(\S+)/);
|
|
427
|
+
if (modelFlagMatch) {
|
|
428
|
+
return modelFlagMatch[1];
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Extract from ANTHROPIC_MODEL env var
|
|
432
|
+
const anthropicMatch = command.match(/ANTHROPIC_MODEL\s*=\s*["']?([^"'\s]+)["']?/);
|
|
433
|
+
if (anthropicMatch) {
|
|
434
|
+
return anthropicMatch[1];
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Extract from OPENAI_API_BASE (indicates provider)
|
|
438
|
+
const openaiBaseMatch = command.match(/OPENAI_API_BASE\s*=\s*["']?([^"'\s]+)["']?/);
|
|
439
|
+
if (openaiBaseMatch) {
|
|
440
|
+
// Derive model from base URL if it's a known provider
|
|
441
|
+
const base = openaiBaseMatch[1];
|
|
442
|
+
if (base.includes('deepseek')) return 'deepseek';
|
|
443
|
+
if (base.includes('z.ai') || base.includes('glm')) return 'glm';
|
|
444
|
+
if (base.includes('alibaba') || base.includes('dashscope')) return 'qwen';
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return null;
|
|
263
448
|
}
|
|
264
449
|
|
|
265
450
|
_buildModels(engineName, engine) {
|