@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.
Files changed (49) hide show
  1. package/README.md +67 -120
  2. package/bin/nexuscrew.js +468 -264
  3. package/frontend/dist/apple-touch-icon.png +0 -0
  4. package/frontend/dist/assets/{index-C7bAndew.js → index-BG1N7bSL.js} +1710 -1702
  5. package/frontend/dist/assets/index-CiAtinNP.css +1 -0
  6. package/frontend/dist/favicon.svg +20 -0
  7. package/frontend/dist/icon-192.png +0 -0
  8. package/frontend/dist/icon-512.png +0 -0
  9. package/frontend/dist/index.html +11 -3
  10. package/frontend/dist/site.webmanifest +29 -0
  11. package/lib/config/hosts.js +67 -0
  12. package/lib/config/manager.js +379 -0
  13. package/lib/config/models.js +408 -0
  14. package/lib/server/db/adapter.js +274 -0
  15. package/lib/server/db/drivers/sql-js.js +75 -0
  16. package/lib/server/db/migrate.js +174 -0
  17. package/lib/server/db/migrations/001_base_schema.sql +70 -0
  18. package/lib/server/middleware/auth.js +134 -0
  19. package/lib/server/middleware/rate-limit.js +63 -0
  20. package/lib/server/models/User.js +128 -0
  21. package/lib/server/routes/auth.js +168 -0
  22. package/lib/server/routes/hosts.js +11 -20
  23. package/lib/server/routes/keys.js +28 -0
  24. package/lib/server/routes/models.js +59 -4
  25. package/lib/server/routes/runtimes.js +34 -0
  26. package/lib/server/routes/send.js +76 -12
  27. package/lib/server/routes/sessions.js +39 -10
  28. package/lib/server/routes/speech.js +46 -0
  29. package/lib/server/routes/status.js +8 -6
  30. package/lib/server/routes/upload.js +135 -0
  31. package/lib/server/routes/wake-lock.js +95 -0
  32. package/lib/server/routes/workspaces.js +101 -0
  33. package/lib/server/server.js +66 -33
  34. package/lib/server/services/attachment-manager.js +57 -0
  35. package/lib/server/services/context-bridge.js +425 -0
  36. package/lib/server/services/runtime-manager.js +462 -0
  37. package/lib/server/services/speech-manager.js +76 -0
  38. package/lib/server/services/summary-generator.js +309 -0
  39. package/lib/server/services/workspace-manager.js +79 -0
  40. package/lib/services/engine-discovery.js +198 -13
  41. package/lib/services/log-watcher.js +40 -22
  42. package/lib/services/remote-pane-watcher.js +155 -0
  43. package/lib/services/session-store.js +60 -64
  44. package/lib/services/tmux-manager.js +127 -116
  45. package/lib/setup/postinstall.js +38 -0
  46. package/lib/utils/paths.js +124 -0
  47. package/lib/utils/termux.js +182 -0
  48. package/package.json +8 -4
  49. package/frontend/dist/assets/index-OENqI1_9.css +0 -1
@@ -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
 
@@ -0,0 +1,155 @@
1
+ const { EventEmitter } = require('events');
2
+ const LogWatcher = require('./log-watcher');
3
+
4
+ function normalizeSnapshot(text = '') {
5
+ return String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
6
+ }
7
+
8
+ function commonPrefixLength(a = '', b = '') {
9
+ const max = Math.min(a.length, b.length);
10
+ let index = 0;
11
+ while (index < max && a[index] === b[index]) {
12
+ index += 1;
13
+ }
14
+ return index;
15
+ }
16
+
17
+ class RemotePaneWatcher extends EventEmitter {
18
+ constructor(tmux, options = {}) {
19
+ super();
20
+ this.tmux = tmux;
21
+ this.parserAdapter = new LogWatcher();
22
+ this.watchers = new Map();
23
+ this.pollMs = options.pollMs || 400;
24
+ this.idleMs = options.idleMs || 4500;
25
+ this.timeoutMs = options.timeoutMs || 120000;
26
+
27
+ this.parserAdapter.on('data', (event) => this.emit('data', event));
28
+ }
29
+
30
+ watch({ conversationId, engine, host, windowName, promptText = '', initialSnapshot = '' }) {
31
+ const key = `${host?.name || host || 'local'}:${windowName}:${conversationId}`;
32
+ if (this.watchers.has(key)) return key;
33
+
34
+ const parserState = this.parserAdapter.createParserState(engine, conversationId);
35
+ const state = {
36
+ key,
37
+ conversationId,
38
+ engine,
39
+ host,
40
+ windowName,
41
+ parserState,
42
+ promptText: String(promptText || ''),
43
+ lastSnapshot: normalizeSnapshot(initialSnapshot),
44
+ startedAt: Date.now(),
45
+ lastChangeAt: Date.now(),
46
+ lastEmitAt: 0,
47
+ stopped: false,
48
+ sawAnyOutput: false
49
+ };
50
+
51
+ const intervalId = setInterval(() => this._poll(state), this.pollMs);
52
+ state.intervalId = intervalId;
53
+ this.watchers.set(key, state);
54
+ return key;
55
+ }
56
+
57
+ unwatch(key) {
58
+ const state = this.watchers.get(key);
59
+ if (!state) return;
60
+ clearInterval(state.intervalId);
61
+ state.stopped = true;
62
+ this.watchers.delete(key);
63
+ }
64
+
65
+ unwatchByConversation(conversationId) {
66
+ for (const [key, state] of this.watchers.entries()) {
67
+ if (state.conversationId === conversationId) {
68
+ this.unwatch(key);
69
+ }
70
+ }
71
+ }
72
+
73
+ unwatchAll() {
74
+ for (const key of this.watchers.keys()) {
75
+ this.unwatch(key);
76
+ }
77
+ }
78
+
79
+ _poll(state) {
80
+ if (state.stopped) return;
81
+
82
+ if (Date.now() - state.startedAt > this.timeoutMs) {
83
+ this._finish(state, true);
84
+ return;
85
+ }
86
+
87
+ const snapshot = normalizeSnapshot(
88
+ this.tmux.capturePane(state.windowName, state.host, 250)
89
+ );
90
+
91
+ if (!snapshot) {
92
+ if (Date.now() - state.lastChangeAt > this.idleMs) {
93
+ this._finish(state, true);
94
+ }
95
+ return;
96
+ }
97
+
98
+ if (snapshot === state.lastSnapshot) {
99
+ if (state.sawAnyOutput && Date.now() - state.lastChangeAt > this.idleMs) {
100
+ this._finish(state, true);
101
+ }
102
+ return;
103
+ }
104
+
105
+ const prefixLength = commonPrefixLength(state.lastSnapshot, snapshot);
106
+ let delta = snapshot.slice(prefixLength);
107
+ state.lastSnapshot = snapshot;
108
+ state.lastChangeAt = Date.now();
109
+
110
+ if (!delta.trim()) {
111
+ return;
112
+ }
113
+
114
+ delta = this._stripPromptEcho(delta, state);
115
+ if (!delta.trim()) {
116
+ return;
117
+ }
118
+
119
+ state.sawAnyOutput = true;
120
+ this.parserAdapter.processTextChunk(delta, state.parserState);
121
+ state.lastEmitAt = Date.now();
122
+ }
123
+
124
+ _stripPromptEcho(delta, state) {
125
+ if (!state.promptText) return delta;
126
+
127
+ const prompt = state.promptText.trim();
128
+ if (!prompt) return delta;
129
+
130
+ if (delta.includes(prompt)) {
131
+ return delta.replace(prompt, '');
132
+ }
133
+
134
+ const compact = delta.trim();
135
+ if (prompt.startsWith(compact) && compact.length > 10) {
136
+ return '';
137
+ }
138
+
139
+ return delta;
140
+ }
141
+
142
+ _finish(state, emitDone = false) {
143
+ this.parserAdapter.flushState(state.parserState);
144
+ if (emitDone) {
145
+ this.emit('data', {
146
+ type: 'done',
147
+ conversationId: state.conversationId,
148
+ engine: state.engine
149
+ });
150
+ }
151
+ this.unwatch(state.key);
152
+ }
153
+ }
154
+
155
+ module.exports = RemotePaneWatcher;
@@ -1,81 +1,49 @@
1
1
  /**
2
- * SessionStore — SQLite persistence for nexuscrew
2
+ * SessionStore — SQLite persistence for nexuscrew (sql.js version)
3
3
  *
4
4
  * Schema:
5
- * - conversations: id, title, engine, model, tmux_window, log_path, workspace, host, status, created_at, updated_at
5
+ * - conversations: id, title, engine, model, tmux_window, log_path, workspace, host, status, pinned, created_at, updated_at
6
6
  * - messages: id, conversation_id, role, content, engine, model, tokens_in, tokens_out, created_at
7
7
  */
8
8
 
9
- const Database = require('better-sqlite3');
10
9
  const path = require('path');
11
10
  const fs = require('fs');
12
11
  const { v4: uuidv4 } = require('uuid');
12
+ const { prepare, saveDb } = require('../server/db/adapter');
13
13
 
14
14
  class SessionStore {
15
- constructor(dbPath) {
16
- const dir = path.dirname(dbPath);
17
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
18
-
19
- this.db = new Database(dbPath);
20
- this.db.pragma('journal_mode = WAL');
21
- this.db.pragma('foreign_keys = ON');
22
- this._migrate();
23
- }
24
-
25
- _migrate() {
26
- this.db.exec(`
27
- CREATE TABLE IF NOT EXISTS conversations (
28
- id TEXT PRIMARY KEY,
29
- title TEXT DEFAULT 'New Chat',
30
- engine TEXT NOT NULL DEFAULT 'claude',
31
- model TEXT,
32
- tmux_window TEXT,
33
- log_path TEXT,
34
- workspace TEXT DEFAULT '',
35
- host TEXT DEFAULT 'local',
36
- status TEXT DEFAULT 'active',
37
- created_at INTEGER DEFAULT (unixepoch()),
38
- updated_at INTEGER DEFAULT (unixepoch())
39
- );
40
-
41
- CREATE TABLE IF NOT EXISTS messages (
42
- id TEXT PRIMARY KEY,
43
- conversation_id TEXT NOT NULL,
44
- role TEXT NOT NULL,
45
- content TEXT DEFAULT '',
46
- engine TEXT,
47
- model TEXT,
48
- tokens_in INTEGER DEFAULT 0,
49
- tokens_out INTEGER DEFAULT 0,
50
- created_at INTEGER DEFAULT (unixepoch()),
51
- FOREIGN KEY (conversation_id) REFERENCES conversations(id)
52
- );
53
-
54
- CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id, created_at);
55
- CREATE INDEX IF NOT EXISTS idx_conversations_status ON conversations(status, updated_at);
56
- `);
15
+ constructor() {
16
+ // DB is managed by adapter - no direct connection here
17
+ this.initialized = false;
18
+ }
19
+
20
+ async init() {
21
+ // Adapter is already initialized by server.js
22
+ this.initialized = true;
57
23
  }
58
24
 
59
25
  // --- Conversations ---
60
26
 
61
27
  createConversation({ engine, model, workspace, host, tmuxWindow, logPath } = {}) {
62
28
  const id = uuidv4();
63
- this.db.prepare(`
29
+ const stmt = prepare(`
64
30
  INSERT INTO conversations (id, engine, model, workspace, host, tmux_window, log_path)
65
31
  VALUES (?, ?, ?, ?, ?, ?, ?)
66
- `).run(id, engine || 'claude', model || null, workspace || '', host || 'local', tmuxWindow || null, logPath || null);
32
+ `);
33
+ stmt.run(id, engine || 'claude', model || null, workspace || '', host || 'local', tmuxWindow || null, logPath || null);
67
34
  return this.getConversation(id);
68
35
  }
69
36
 
70
37
  getConversation(id) {
71
- return this.db.prepare('SELECT * FROM conversations WHERE id = ?').get(id);
38
+ const stmt = prepare('SELECT * FROM conversations WHERE id = ?');
39
+ return stmt.get(id);
72
40
  }
73
41
 
74
42
  updateConversation(id, updates) {
75
43
  const fields = [];
76
44
  const values = [];
77
45
  for (const [key, value] of Object.entries(updates)) {
78
- if (['title', 'engine', 'model', 'tmux_window', 'log_path', 'workspace', 'host', 'status'].includes(key)) {
46
+ if (['title', 'engine', 'model', 'tmux_window', 'log_path', 'workspace', 'host', 'status', 'pinned'].includes(key)) {
79
47
  fields.push(`${key} = ?`);
80
48
  values.push(value);
81
49
  }
@@ -83,7 +51,8 @@ class SessionStore {
83
51
  if (fields.length === 0) return;
84
52
  fields.push('updated_at = unixepoch()');
85
53
  values.push(id);
86
- this.db.prepare(`UPDATE conversations SET ${fields.join(', ')} WHERE id = ?`).run(...values);
54
+ const stmt = prepare(`UPDATE conversations SET ${fields.join(', ')} WHERE id = ?`);
55
+ stmt.run(...values);
87
56
  }
88
57
 
89
58
  listConversations({ workspace, host, status, limit = 50 } = {}) {
@@ -94,22 +63,26 @@ class SessionStore {
94
63
  if (status) { sql += ' AND status = ?'; params.push(status); }
95
64
  sql += ' ORDER BY updated_at DESC LIMIT ?';
96
65
  params.push(limit);
97
- return this.db.prepare(sql).all(...params);
66
+ const stmt = prepare(sql);
67
+ return stmt.all(...params);
98
68
  }
99
69
 
100
70
  deleteConversation(id) {
101
- this.db.prepare('DELETE FROM messages WHERE conversation_id = ?').run(id);
102
- this.db.prepare('DELETE FROM conversations WHERE id = ?').run(id);
71
+ const stmt1 = prepare('DELETE FROM messages WHERE conversation_id = ?');
72
+ stmt1.run(id);
73
+ const stmt2 = prepare('DELETE FROM conversations WHERE id = ?');
74
+ stmt2.run(id);
103
75
  }
104
76
 
105
77
  /** Find conversation by tmux window name */
106
78
  findByWindow(windowName) {
107
- return this.db.prepare('SELECT * FROM conversations WHERE tmux_window = ?').get(windowName);
79
+ const stmt = prepare('SELECT * FROM conversations WHERE tmux_window = ?');
80
+ return stmt.get(windowName);
108
81
  }
109
82
 
110
83
  /** Get conversations grouped by date */
111
- listGroupedByDate({ workspace, limit = 100 } = {}) {
112
- const convs = this.listConversations({ workspace, limit });
84
+ listGroupedByDate({ workspace, host, limit = 100 } = {}) {
85
+ const convs = this.listConversations({ workspace, host, limit });
113
86
  const grouped = {};
114
87
  for (const c of convs) {
115
88
  const date = new Date(c.updated_at * 1000).toISOString().split('T')[0];
@@ -119,17 +92,37 @@ class SessionStore {
119
92
  return grouped;
120
93
  }
121
94
 
95
+ /** Toggle conversation pinned status */
96
+ pinConversation(id) {
97
+ const stmt = prepare('SELECT pinned FROM conversations WHERE id = ?');
98
+ const conv = stmt.get(id);
99
+ if (!conv) return;
100
+
101
+ const newPinned = conv.pinned ? 0 : 1;
102
+ const updateStmt = prepare('UPDATE conversations SET pinned = ? WHERE id = ?');
103
+ updateStmt.run(newPinned, id);
104
+ return newPinned === 1;
105
+ }
106
+
107
+ /** Search conversations by title */
108
+ searchConversations(query) {
109
+ const stmt = prepare('SELECT * FROM conversations WHERE title LIKE ? ORDER BY updated_at DESC LIMIT 50');
110
+ return stmt.all(`%${query}%`);
111
+ }
112
+
122
113
  // --- Messages ---
123
114
 
124
115
  addMessage({ conversationId, role, content, engine, model, tokensIn, tokensOut } = {}) {
125
116
  const id = uuidv4();
126
- this.db.prepare(`
117
+ const stmt = prepare(`
127
118
  INSERT INTO messages (id, conversation_id, role, content, engine, model, tokens_in, tokens_out)
128
119
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
129
- `).run(id, conversationId, role, content || '', engine || null, model || null, tokensIn || 0, tokensOut || 0);
120
+ `);
121
+ stmt.run(id, conversationId, role, content || '', engine || null, model || null, tokensIn || 0, tokensOut || 0);
130
122
 
131
123
  // Update conversation timestamp
132
- this.db.prepare('UPDATE conversations SET updated_at = unixepoch() WHERE id = ?').run(conversationId);
124
+ const updateStmt = prepare('UPDATE conversations SET updated_at = unixepoch() WHERE id = ?');
125
+ updateStmt.run(conversationId);
133
126
  return id;
134
127
  }
135
128
 
@@ -142,11 +135,13 @@ class SessionStore {
142
135
  }
143
136
  sql += ' ORDER BY created_at ASC LIMIT ?';
144
137
  params.push(limit);
145
- return this.db.prepare(sql).all(...params);
138
+ const stmt = prepare(sql);
139
+ return stmt.all(...params);
146
140
  }
147
141
 
148
142
  getMessageCount(conversationId) {
149
- return this.db.prepare('SELECT COUNT(*) as count FROM messages WHERE conversation_id = ?').get(conversationId).count;
143
+ const stmt = prepare('SELECT COUNT(*) as count FROM messages WHERE conversation_id = ?');
144
+ return stmt.get(conversationId).count;
150
145
  }
151
146
 
152
147
  // --- Recovery ---
@@ -156,10 +151,11 @@ class SessionStore {
156
151
  * Mark conversations as 'dead' if their tmux window no longer exists
157
152
  */
158
153
  syncWithTmux(tmuxManager) {
159
- const active = this.db.prepare("SELECT * FROM conversations WHERE status = 'active'").all();
154
+ const stmt = prepare("SELECT * FROM conversations WHERE status = 'active'");
155
+ const active = stmt.all();
160
156
  for (const conv of active) {
161
157
  if (conv.tmux_window) {
162
- const exists = tmuxManager.hasWindow(conv.tmux_window, conv.host !== 'local' ? conv.host : null);
158
+ const exists = tmuxManager.hasWindow(conv.tmux_window, conv.host || 'local');
163
159
  if (!exists) {
164
160
  this.updateConversation(conv.id, { status: 'dead' });
165
161
  }
@@ -169,7 +165,7 @@ class SessionStore {
169
165
 
170
166
  /** Close DB connection */
171
167
  close() {
172
- this.db.close();
168
+ saveDb();
173
169
  }
174
170
  }
175
171