@mmmbuto/nexuscrew 0.2.0 → 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.
@@ -13,17 +13,16 @@ const router = Router();
13
13
  // List conversations (optionally grouped by date)
14
14
  router.get('/', (req, res) => {
15
15
  const { store, tmux } = req;
16
- const { workspace, groupBy } = req.query;
16
+ const { workspace, groupBy, host } = req.query;
17
17
 
18
18
  if (groupBy === 'date') {
19
- const grouped = store.listGroupedByDate({ workspace });
19
+ const grouped = store.listGroupedByDate({ workspace, host });
20
20
 
21
21
  // Enrich with tmux_alive status
22
22
  Object.keys(grouped).forEach(dateGroup => {
23
23
  grouped[dateGroup].forEach(conv => {
24
24
  if (conv.tmux_window) {
25
- const host = conv.host !== 'local' ? conv.host : null;
26
- conv.tmux_alive = tmux.hasWindow(conv.tmux_window, host);
25
+ conv.tmux_alive = tmux.hasWindow(conv.tmux_window, conv.host || 'local');
27
26
  } else {
28
27
  conv.tmux_alive = false;
29
28
  }
@@ -33,13 +32,12 @@ router.get('/', (req, res) => {
33
32
  return res.json(grouped);
34
33
  }
35
34
 
36
- const convs = store.listConversations({ workspace });
35
+ const convs = store.listConversations({ workspace, host });
37
36
 
38
37
  // Enrich with tmux_alive status
39
38
  convs.forEach(conv => {
40
39
  if (conv.tmux_window) {
41
- const host = conv.host !== 'local' ? conv.host : null;
42
- conv.tmux_alive = tmux.hasWindow(conv.tmux_window, host);
40
+ conv.tmux_alive = tmux.hasWindow(conv.tmux_window, conv.host || 'local');
43
41
  } else {
44
42
  conv.tmux_alive = false;
45
43
  }
@@ -58,8 +56,7 @@ router.get('/:id', (req, res) => {
58
56
  }
59
57
 
60
58
  // Check tmux window status
61
- const host = conv.host !== 'local' ? conv.host : null;
62
- const alive = conv.tmux_window ? tmux.hasWindow(conv.tmux_window, host) : false;
59
+ const alive = conv.tmux_window ? tmux.hasWindow(conv.tmux_window, conv.host || 'local') : false;
63
60
 
64
61
  res.json({
65
62
  session: {
@@ -113,21 +110,21 @@ router.post('/:id/pin', (req, res) => {
113
110
 
114
111
  // Delete conversation (kills tmux window too)
115
112
  router.delete('/:id', (req, res) => {
116
- const { store, tmux, logWatcher } = req;
113
+ const { store, tmux, logWatcher, remotePaneWatcher } = req;
117
114
  const conv = store.getConversation(req.params.id);
118
115
 
119
116
  if (!conv) return res.status(404).json({ error: 'Not found' });
120
117
 
121
118
  // Kill tmux window if active
122
119
  if (conv.tmux_window) {
123
- const host = conv.host !== 'local' ? conv.host : null;
124
- tmux.killWindow(conv.tmux_window, host);
120
+ tmux.killWindow(conv.tmux_window, conv.host || 'local');
125
121
  }
126
122
 
127
123
  // Stop log watcher
128
124
  if (conv.log_path) {
129
125
  logWatcher.unwatch(conv.log_path);
130
126
  }
127
+ remotePaneWatcher?.unwatchByConversation?.(req.params.id);
131
128
 
132
129
  store.deleteConversation(req.params.id);
133
130
  res.json({ success: true });
@@ -1,74 +1,45 @@
1
1
  const express = require('express');
2
2
  const router = express.Router();
3
3
  const multer = require('multer');
4
- const { getApiKey } = require('../db/adapter');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+ const { PATHS } = require('../../utils/paths');
5
8
 
6
9
  const upload = multer({
7
10
  storage: multer.memoryStorage(),
8
- limits: { fileSize: 25 * 1024 * 1024 } // 25MB max (Whisper limit)
11
+ limits: { fileSize: 25 * 1024 * 1024 }
12
+ });
13
+
14
+ router.get('/status', (req, res) => {
15
+ res.json(req.speech.getStatus());
9
16
  });
10
17
 
11
- /**
12
- * POST /api/v1/speech/transcribe
13
- * Transcribe audio using OpenAI Whisper API
14
- * Body: multipart/form-data with 'audio' file and 'language' (optional)
15
- * Returns: { text: string }
16
- *
17
- * Supported languages (aligned with UI): it, en, es, ja, ru, zh
18
- */
19
18
  router.post('/transcribe', upload.single('audio'), async (req, res) => {
20
- try {
21
- const apiKey = getApiKey('openai');
22
- if (!apiKey) {
23
- return res.status(400).json({ error: 'OpenAI API key not configured' });
24
- }
19
+ let tempPath = null;
25
20
 
21
+ try {
26
22
  if (!req.file) {
27
23
  return res.status(400).json({ error: 'No audio file provided' });
28
24
  }
29
25
 
30
- // Build FormData using native Node.js 22+ FormData and File
31
- const formData = new FormData();
32
- const audioFile = new File([req.file.buffer], 'audio.webm', {
33
- type: req.file.mimetype || 'audio/webm'
34
- });
35
- formData.append('file', audioFile);
36
- formData.append('model', 'whisper-1');
37
-
38
- // Use language from request - extract base language (it-IT -> it)
39
- if (req.body.language) {
40
- const lang = req.body.language.split('-')[0]; // it-IT -> it
41
- formData.append('language', lang);
42
- }
43
-
44
- console.log('[Speech] Transcribing audio:', {
45
- size: req.file.size,
46
- mimetype: req.file.mimetype,
47
- language: req.body.language || 'auto-detect'
48
- });
26
+ fs.mkdirSync(PATHS.DATA_DIR, { recursive: true });
27
+ tempPath = path.join(PATHS.DATA_DIR, `stt-${Date.now()}-${Math.random().toString(16).slice(2)}.webm`);
28
+ fs.writeFileSync(tempPath, req.file.buffer);
49
29
 
50
- // Use native fetch (Node.js 22+)
51
- const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
52
- method: 'POST',
53
- headers: {
54
- 'Authorization': `Bearer ${apiKey}`
55
- },
56
- body: formData
30
+ const result = await req.speech.transcribe({
31
+ audioPath: tempPath,
32
+ language: req.body.language
57
33
  });
58
34
 
59
- if (!response.ok) {
60
- const error = await response.json();
61
- console.error('[Speech] OpenAI error:', error);
62
- return res.status(response.status).json(error);
63
- }
64
-
65
- const data = await response.json();
66
- console.log('[Speech] Transcription success:', data.text?.substring(0, 50) + '...');
67
- res.json(data);
68
-
35
+ res.json(result);
69
36
  } catch (error) {
70
37
  console.error('[Speech] Transcription error:', error);
71
38
  res.status(500).json({ error: error.message });
39
+ } finally {
40
+ if (tempPath && fs.existsSync(tempPath)) {
41
+ fs.unlinkSync(tempPath);
42
+ }
72
43
  }
73
44
  });
74
45
 
@@ -10,13 +10,14 @@ const router = Router();
10
10
  // Overall status
11
11
  router.get('/', (req, res) => {
12
12
  const { tmux, store } = req;
13
- const windows = tmux.listWindows();
13
+ const { host = 'local' } = req.query;
14
+ const windows = tmux.listWindows(host);
14
15
  const hosts = tmux.getAllHosts();
15
16
 
16
17
  res.json({
17
18
  tmux: {
18
19
  session: tmux.sessionName,
19
- alive: tmux.hasSession(),
20
+ alive: tmux.hasSession(host),
20
21
  windowCount: windows.length,
21
22
  windows
22
23
  },
@@ -30,7 +31,8 @@ router.get('/', (req, res) => {
30
31
  // Detailed tmux windows
31
32
  router.get('/tmux', (req, res) => {
32
33
  const { tmux, store } = req;
33
- const windows = tmux.listWindows();
34
+ const { host = 'local' } = req.query;
35
+ const windows = tmux.listWindows(host);
34
36
 
35
37
  // Enrich with DB data
36
38
  const enriched = windows.map(w => {
@@ -40,7 +42,7 @@ router.get('/tmux', (req, res) => {
40
42
  conversationId: conv?.id || null,
41
43
  engine: conv?.engine || null,
42
44
  model: conv?.model || null,
43
- host: conv?.host || 'local',
45
+ host: conv?.host || host || 'local',
44
46
  title: conv?.title || w.name
45
47
  };
46
48
  });
@@ -51,8 +53,8 @@ router.get('/tmux', (req, res) => {
51
53
  // Capture pane content
52
54
  router.get('/pane/:window', (req, res) => {
53
55
  const { tmux } = req;
54
- const { host } = req.query;
55
- const content = tmux.capturePane(req.params.window, host || null);
56
+ const { host = 'local' } = req.query;
57
+ const content = tmux.capturePane(req.params.window, host);
56
58
 
57
59
  res.json({ window: req.params.window, content });
58
60
  });
@@ -2,11 +2,12 @@ const express = require('express');
2
2
  const multer = require('multer');
3
3
  const path = require('path');
4
4
  const fs = require('fs');
5
+ const { PATHS } = require('../../utils/paths');
5
6
 
6
7
  const router = express.Router();
7
8
 
8
9
  // NexusCrew: attachments in ~/.nexuscrew/attachments
9
- const ATTACHMENTS_DIR = path.join(process.env.HOME, '.nexuscrew', 'attachments');
10
+ const ATTACHMENTS_DIR = path.join(PATHS.ATTACHMENTS_DIR, 'incoming');
10
11
 
11
12
  // Ensure directory exists
12
13
  if (!fs.existsSync(ATTACHMENTS_DIR)) {
@@ -16,6 +16,10 @@ const TmuxManager = require('../services/tmux-manager');
16
16
  const EngineDiscovery = require('../services/engine-discovery');
17
17
  const SessionStore = require('../services/session-store');
18
18
  const LogWatcher = require('../services/log-watcher');
19
+ const RemotePaneWatcher = require('../services/remote-pane-watcher');
20
+ const AttachmentManager = require('./services/attachment-manager');
21
+ const SpeechManager = require('./services/speech-manager');
22
+ const { getConfig } = require('../config/manager');
19
23
 
20
24
  // Routes
21
25
  const sendRouter = require('./routes/send');
@@ -36,27 +40,15 @@ const { authMiddleware } = require('./middleware/auth');
36
40
 
37
41
  class NexusCrewServer {
38
42
  constructor(config = {}) {
39
- this.port = config.port || process.env.PORT || 41820;
40
43
  this.configDir = config.configDir || path.join(process.env.HOME, '.nexuscrew');
41
44
  this.config = this._loadConfig();
45
+ this.port = config.port || process.env.PORT || this.config.server?.port || 41820;
42
46
  this.app = express();
43
47
  this.server = null;
44
48
  }
45
49
 
46
50
  _loadConfig() {
47
- const configPath = path.join(this.configDir, 'config.json');
48
- if (fs.existsSync(configPath)) {
49
- return JSON.parse(fs.readFileSync(configPath, 'utf8'));
50
- }
51
- return { port: this.port, tmuxSession: 'nexuscrew', autoDiscovery: true };
52
- }
53
-
54
- _loadHosts() {
55
- const hostsPath = path.join(this.configDir, 'hosts.json');
56
- if (fs.existsSync(hostsPath)) {
57
- return JSON.parse(fs.readFileSync(hostsPath, 'utf8'));
58
- }
59
- return [{ name: 'local', type: 'local', default: true }];
51
+ return getConfig();
60
52
  }
61
53
 
62
54
  async start() {
@@ -67,21 +59,24 @@ class NexusCrewServer {
67
59
  const User = require('./models/User');
68
60
  if (User.count() === 0) {
69
61
  User.create('admin', 'admin', 'admin');
70
- console.log(' Created default admin user (admin/admin)');
62
+ console.log(' Created initial admin account from local defaults');
71
63
  }
72
64
 
73
65
  // 3. Init services
74
- const hosts = this._loadHosts();
75
66
  const tmuxManager = new TmuxManager({
76
67
  tmuxSession: this.config.tmuxSession || 'nexuscrew',
77
- logDir: this.config.logDir || path.join(this.configDir, 'logs'),
78
- hosts
68
+ logDir: this.config.logDir || path.join(this.configDir, 'logs')
79
69
  });
80
70
 
81
71
  const engineDiscovery = new EngineDiscovery(this.config.providersPath);
82
72
  const sessionStore = new SessionStore();
83
73
  await sessionStore.init();
84
74
  const logWatcher = new LogWatcher();
75
+ const remotePaneWatcher = new RemotePaneWatcher(tmuxManager);
76
+ const attachmentManager = new AttachmentManager(tmuxManager);
77
+ const speechManager = new SpeechManager();
78
+ this.app.locals.logWatcher = logWatcher;
79
+ this.app.locals.remotePaneWatcher = remotePaneWatcher;
85
80
 
86
81
  // Ensure tmux master session
87
82
  tmuxManager.ensureSession();
@@ -106,7 +101,7 @@ class NexusCrewServer {
106
101
  res.json({
107
102
  status: 'ok',
108
103
  service: 'nexuscrew',
109
- version: '0.2.0',
104
+ version: '0.2.1',
110
105
  tmux: tmuxManager.hasSession(),
111
106
  windows: tmuxManager.listWindows().length,
112
107
  uptime: process.uptime()
@@ -119,6 +114,9 @@ class NexusCrewServer {
119
114
  req.engines = engineDiscovery;
120
115
  req.store = sessionStore;
121
116
  req.logWatcher = logWatcher;
117
+ req.remotePaneWatcher = remotePaneWatcher;
118
+ req.attachments = attachmentManager;
119
+ req.speech = speechManager;
122
120
  req.config = this.config;
123
121
  next();
124
122
  });
@@ -168,7 +166,7 @@ class NexusCrewServer {
168
166
  console.log('');
169
167
  console.log(` HTTP: http://localhost:${this.port}`);
170
168
  console.log(` tmux: session "${tmuxManager.sessionName}" (${tmuxManager.listWindows().length} windows)`);
171
- console.log(` Hosts: ${hosts.map(h => h.name).join(', ')}`);
169
+ console.log(` Hosts: ${tmuxManager.getAllHosts().map(h => h.name).join(', ')}`);
172
170
  console.log('');
173
171
 
174
172
  const engines = engineDiscovery.discover();
@@ -184,6 +182,9 @@ class NexusCrewServer {
184
182
 
185
183
  stop() {
186
184
  if (this.server) {
185
+ const appLocals = this.app?.locals || {};
186
+ appLocals.logWatcher?.unwatchAll?.();
187
+ appLocals.remotePaneWatcher?.unwatchAll?.();
187
188
  saveDb();
188
189
  this.server.close();
189
190
  }
@@ -0,0 +1,57 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { PATHS } = require('../../utils/paths');
4
+
5
+ class AttachmentManager {
6
+ constructor(tmux) {
7
+ this.tmux = tmux;
8
+ }
9
+
10
+ getLocalAttachmentDir(conversationId = 'shared') {
11
+ return path.join(PATHS.ATTACHMENTS_DIR, 'local', conversationId);
12
+ }
13
+
14
+ getRemoteAttachmentDir(conversationId = 'shared') {
15
+ return path.posix.join('.nexuscrew', 'attachments', conversationId);
16
+ }
17
+
18
+ stageForConversation(file, conversationId, hostRef) {
19
+ if (!file?.path) {
20
+ throw new Error('Attachment path is required');
21
+ }
22
+
23
+ const host = this.tmux.resolveHost(hostRef);
24
+ const baseName = path.basename(file.path);
25
+
26
+ if (!host || host.type === 'local') {
27
+ const localDir = this.getLocalAttachmentDir(conversationId);
28
+ fs.mkdirSync(localDir, { recursive: true });
29
+ const localPath = path.join(localDir, baseName);
30
+ if (file.path !== localPath) {
31
+ fs.copyFileSync(file.path, localPath);
32
+ }
33
+ return {
34
+ ...file,
35
+ host: 'local',
36
+ localPath,
37
+ promptPath: localPath,
38
+ ready: true
39
+ };
40
+ }
41
+
42
+ const remoteDir = this.getRemoteAttachmentDir(conversationId);
43
+ const remotePath = path.posix.join(remoteDir, baseName);
44
+ this.tmux.copyToHost(file.path, remotePath, host);
45
+
46
+ return {
47
+ ...file,
48
+ host: host.name,
49
+ localPath: file.path,
50
+ remotePath,
51
+ promptPath: `~/${remotePath}`,
52
+ ready: true
53
+ };
54
+ }
55
+ }
56
+
57
+ module.exports = AttachmentManager;
@@ -0,0 +1,76 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { execFile } = require('child_process');
4
+ const { promisify } = require('util');
5
+ const { getConfig } = require('../../config/manager');
6
+ const { PATHS } = require('../../utils/paths');
7
+
8
+ const execFileAsync = promisify(execFile);
9
+
10
+ function resolveBundledWhisper() {
11
+ const candidates = [
12
+ path.join(PATHS.BIN_DIR, 'whisper-cli'),
13
+ path.join(__dirname, '../../../bin/android-arm64/whisper-cli'),
14
+ path.join(__dirname, '../../../bin/linux-x64/whisper-cli')
15
+ ];
16
+ return candidates.find((candidate) => fs.existsSync(candidate)) || 'whisper-cli';
17
+ }
18
+
19
+ class SpeechManager {
20
+ getStatus() {
21
+ const config = getConfig();
22
+ const whisperBin = config.stt?.whisper?.bin || resolveBundledWhisper();
23
+ const whisperModel = config.stt?.whisper?.model || '';
24
+
25
+ return {
26
+ provider: config.stt?.provider || 'whispercpp',
27
+ fallback: config.stt?.fallback || 'browser',
28
+ whisper: {
29
+ bin: whisperBin,
30
+ model: whisperModel,
31
+ configured: Boolean(whisperModel),
32
+ available: this.checkBinary(whisperBin)
33
+ },
34
+ tts: {
35
+ provider: config.tts?.provider || 'system'
36
+ }
37
+ };
38
+ }
39
+
40
+ checkBinary(binPath) {
41
+ if (!binPath) return false;
42
+ if (binPath === 'whisper-cli') return true;
43
+ return fs.existsSync(binPath);
44
+ }
45
+
46
+ async transcribe({ audioPath, language }) {
47
+ const config = getConfig();
48
+ const bin = config.stt?.whisper?.bin || resolveBundledWhisper();
49
+ const model = config.stt?.whisper?.model;
50
+ const threads = String(config.stt?.whisper?.threads || 2);
51
+ const lang = language || config.stt?.whisper?.language || 'it';
52
+
53
+ if (!model) {
54
+ throw new Error('whisper.cpp model not configured');
55
+ }
56
+
57
+ const args = ['-m', model, '-f', audioPath, '-l', lang, '-t', threads, '--no-timestamps'];
58
+ const { stdout, stderr } = await execFileAsync(bin, args, {
59
+ timeout: 120000,
60
+ maxBuffer: 8 * 1024 * 1024
61
+ });
62
+
63
+ const text = [stdout, stderr]
64
+ .filter(Boolean)
65
+ .join('\n')
66
+ .split('\n')
67
+ .map((line) => line.trim())
68
+ .filter((line) => line && !line.startsWith('['))
69
+ .join(' ')
70
+ .trim();
71
+
72
+ return { text };
73
+ }
74
+ }
75
+
76
+ module.exports = SpeechManager;
@@ -27,10 +27,7 @@ class LogWatcher extends EventEmitter {
27
27
 
28
28
  const state = {
29
29
  offset: 0,
30
- engine,
31
- conversationId,
32
- buffer: '',
33
- parser: this._getParser(engine)
30
+ ...this.createParserState(engine, conversationId)
34
31
  };
35
32
 
36
33
  // Read existing content (in case log already has data)
@@ -98,39 +95,60 @@ class LogWatcher extends EventEmitter {
98
95
 
99
96
  state.offset = stat.size;
100
97
  const chunk = buf.toString('utf8');
101
- state.buffer += chunk;
102
-
103
- // Process complete lines
104
- this._processBuffer(state);
98
+ this.processTextChunk(chunk, state);
105
99
  } catch (err) {
106
100
  // File might be temporarily unavailable
107
101
  }
108
102
  }
109
103
 
104
+ createParserState(engine, conversationId) {
105
+ return {
106
+ engine,
107
+ conversationId,
108
+ buffer: '',
109
+ parser: this._getParser(engine)
110
+ };
111
+ }
112
+
113
+ processTextChunk(chunk, state) {
114
+ state.buffer += chunk;
115
+ this._processBuffer(state);
116
+ }
117
+
118
+ flushState(state) {
119
+ if (!state.buffer?.trim()) return;
120
+ const line = state.buffer;
121
+ state.buffer = '';
122
+ this._emitParsedLine(line, state);
123
+ }
124
+
110
125
  _processBuffer(state) {
111
126
  const lines = state.buffer.split('\n');
112
127
  state.buffer = lines.pop() || ''; // Keep incomplete line in buffer
113
128
 
114
129
  for (const line of lines) {
115
- if (!line.trim()) continue;
116
- try {
117
- const events = state.parser(line, state);
118
- for (const event of events) {
119
- this.emit('data', {
120
- ...event,
121
- conversationId: state.conversationId,
122
- engine: state.engine
123
- });
124
- }
125
- } catch {
126
- // Emit raw text if parser fails
130
+ this._emitParsedLine(line, state);
131
+ }
132
+ }
133
+
134
+ _emitParsedLine(line, state) {
135
+ if (!line.trim()) return;
136
+ try {
137
+ const events = state.parser(line, state);
138
+ for (const event of events) {
127
139
  this.emit('data', {
128
- type: 'raw',
129
- content: line,
140
+ ...event,
130
141
  conversationId: state.conversationId,
131
142
  engine: state.engine
132
143
  });
133
144
  }
145
+ } catch {
146
+ this.emit('data', {
147
+ type: 'raw',
148
+ content: line,
149
+ conversationId: state.conversationId,
150
+ engine: state.engine
151
+ });
134
152
  }
135
153
  }
136
154