@mmmbuto/nexuscli 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -0
  3. package/bin/nexuscli.js +117 -0
  4. package/frontend/dist/apple-touch-icon.png +0 -0
  5. package/frontend/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  6. package/frontend/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  7. package/frontend/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  8. package/frontend/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  9. package/frontend/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  10. package/frontend/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  11. package/frontend/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  12. package/frontend/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  13. package/frontend/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  14. package/frontend/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  15. package/frontend/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  16. package/frontend/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  17. package/frontend/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  18. package/frontend/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  19. package/frontend/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  20. package/frontend/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  21. package/frontend/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  22. package/frontend/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  23. package/frontend/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  24. package/frontend/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  25. package/frontend/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  26. package/frontend/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  27. package/frontend/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  28. package/frontend/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  29. package/frontend/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  30. package/frontend/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  31. package/frontend/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  32. package/frontend/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  33. package/frontend/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  34. package/frontend/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  35. package/frontend/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  36. package/frontend/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  37. package/frontend/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  38. package/frontend/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  39. package/frontend/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  40. package/frontend/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  41. package/frontend/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  42. package/frontend/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  43. package/frontend/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  44. package/frontend/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  45. package/frontend/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  46. package/frontend/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  47. package/frontend/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  48. package/frontend/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  49. package/frontend/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  50. package/frontend/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  51. package/frontend/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  52. package/frontend/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  53. package/frontend/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  54. package/frontend/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  55. package/frontend/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  56. package/frontend/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  57. package/frontend/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  58. package/frontend/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  59. package/frontend/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  60. package/frontend/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  61. package/frontend/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  62. package/frontend/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  63. package/frontend/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  64. package/frontend/dist/assets/index-Bn_l1e6e.css +1 -0
  65. package/frontend/dist/assets/index-CikJbUR5.js +8617 -0
  66. package/frontend/dist/browserconfig.xml +12 -0
  67. package/frontend/dist/favicon-16x16.png +0 -0
  68. package/frontend/dist/favicon-32x32.png +0 -0
  69. package/frontend/dist/favicon-48x48.png +0 -0
  70. package/frontend/dist/favicon.ico +0 -0
  71. package/frontend/dist/icon-192.png +0 -0
  72. package/frontend/dist/icon-512.png +0 -0
  73. package/frontend/dist/icon-maskable-192.png +0 -0
  74. package/frontend/dist/icon-maskable-512.png +0 -0
  75. package/frontend/dist/index.html +79 -0
  76. package/frontend/dist/manifest.json +75 -0
  77. package/frontend/dist/sw.js +122 -0
  78. package/frontend/package.json +28 -0
  79. package/lib/cli/api.js +156 -0
  80. package/lib/cli/boot.js +172 -0
  81. package/lib/cli/config.js +185 -0
  82. package/lib/cli/engines.js +257 -0
  83. package/lib/cli/init.js +660 -0
  84. package/lib/cli/logs.js +72 -0
  85. package/lib/cli/start.js +220 -0
  86. package/lib/cli/status.js +187 -0
  87. package/lib/cli/stop.js +64 -0
  88. package/lib/cli/uninstall.js +194 -0
  89. package/lib/cli/users.js +295 -0
  90. package/lib/cli/workspaces.js +337 -0
  91. package/lib/config/manager.js +233 -0
  92. package/lib/server/.env.example +20 -0
  93. package/lib/server/db/adapter.js +314 -0
  94. package/lib/server/db/drivers/better-sqlite3.js +38 -0
  95. package/lib/server/db/drivers/sql-js.js +75 -0
  96. package/lib/server/db/migrate.js +174 -0
  97. package/lib/server/db/migrations/001_ultra_light_schema.sql +96 -0
  98. package/lib/server/db/migrations/002_session_conversation_mapping.sql +19 -0
  99. package/lib/server/db/migrations/003_message_engine_tracking.sql +18 -0
  100. package/lib/server/db/migrations/004_performance_indexes.sql +16 -0
  101. package/lib/server/db.js +2 -0
  102. package/lib/server/lib/cli-wrapper.js +164 -0
  103. package/lib/server/lib/output-parser.js +132 -0
  104. package/lib/server/lib/pty-adapter.js +57 -0
  105. package/lib/server/middleware/auth.js +103 -0
  106. package/lib/server/models/Conversation.js +259 -0
  107. package/lib/server/models/Message.js +228 -0
  108. package/lib/server/models/User.js +115 -0
  109. package/lib/server/package-lock.json +5895 -0
  110. package/lib/server/routes/auth.js +168 -0
  111. package/lib/server/routes/chat.js +206 -0
  112. package/lib/server/routes/codex.js +205 -0
  113. package/lib/server/routes/conversations.js +224 -0
  114. package/lib/server/routes/gemini.js +228 -0
  115. package/lib/server/routes/jobs.js +317 -0
  116. package/lib/server/routes/messages.js +60 -0
  117. package/lib/server/routes/models.js +198 -0
  118. package/lib/server/routes/sessions.js +285 -0
  119. package/lib/server/routes/upload.js +134 -0
  120. package/lib/server/routes/wake-lock.js +95 -0
  121. package/lib/server/routes/workspace.js +80 -0
  122. package/lib/server/routes/workspaces.js +142 -0
  123. package/lib/server/scripts/cleanup-ghost-sessions.js +71 -0
  124. package/lib/server/scripts/seed-users.js +37 -0
  125. package/lib/server/scripts/test-history-access.js +50 -0
  126. package/lib/server/server.js +227 -0
  127. package/lib/server/services/cache.js +85 -0
  128. package/lib/server/services/claude-wrapper.js +312 -0
  129. package/lib/server/services/cli-loader.js +384 -0
  130. package/lib/server/services/codex-output-parser.js +277 -0
  131. package/lib/server/services/codex-wrapper.js +224 -0
  132. package/lib/server/services/context-bridge.js +289 -0
  133. package/lib/server/services/gemini-output-parser.js +398 -0
  134. package/lib/server/services/gemini-wrapper.js +249 -0
  135. package/lib/server/services/history-sync.js +407 -0
  136. package/lib/server/services/output-parser.js +415 -0
  137. package/lib/server/services/session-manager.js +465 -0
  138. package/lib/server/services/summary-generator.js +259 -0
  139. package/lib/server/services/workspace-manager.js +516 -0
  140. package/lib/server/tests/history-sync.test.js +90 -0
  141. package/lib/server/tests/integration-session-sync.test.js +151 -0
  142. package/lib/server/tests/integration.test.js +76 -0
  143. package/lib/server/tests/performance.test.js +118 -0
  144. package/lib/server/tests/services.test.js +160 -0
  145. package/lib/setup/postinstall.js +216 -0
  146. package/lib/utils/paths.js +107 -0
  147. package/lib/utils/termux.js +145 -0
  148. package/package.json +82 -0
@@ -0,0 +1,259 @@
1
+ const { prepare } = require('../db');
2
+ const { v4: uuidv4 } = require('uuid');
3
+
4
+ /**
5
+ * Conversation model - Manages chat sessions
6
+ */
7
+ class Conversation {
8
+ /**
9
+ * Create new conversation
10
+ * @param {string} title - Conversation title
11
+ * @returns {Object} Created conversation
12
+ */
13
+ static create(title = 'New Conversation', workspacePath = null) {
14
+ const id = uuidv4();
15
+ const now = Date.now();
16
+
17
+ // Insert conversation
18
+ const stmt = prepare(`
19
+ INSERT INTO conversations (id, title, created_at, updated_at)
20
+ VALUES (?, ?, ?, ?)
21
+ `);
22
+
23
+ stmt.run(id, title, now, now);
24
+
25
+ // Also create session entry (for sidebar visibility)
26
+ // Use workspace where Claude CLI is executed (backend workdir)
27
+ const workspace = workspacePath || process.cwd();
28
+
29
+ const sessionStmt = prepare(`
30
+ INSERT INTO sessions (
31
+ id, engine, workspace_path, title,
32
+ last_used_at, created_at, message_count
33
+ )
34
+ VALUES (?, ?, ?, ?, ?, ?, ?)
35
+ `);
36
+
37
+ sessionStmt.run(
38
+ id,
39
+ 'claude-code',
40
+ workspace,
41
+ title,
42
+ now,
43
+ now,
44
+ 0
45
+ );
46
+
47
+ console.log(`[Conversation] Created conversation + session: ${id} in workspace ${workspace}`);
48
+
49
+ return {
50
+ id,
51
+ title,
52
+ created_at: now,
53
+ updated_at: now,
54
+ metadata: null
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Get conversation by ID
60
+ * @param {string} id - Conversation ID
61
+ * @returns {Object|null} Conversation or null if not found
62
+ */
63
+ static getById(id) {
64
+ const stmt = prepare('SELECT * FROM conversations WHERE id = ?');
65
+ const conversation = stmt.get(id);
66
+
67
+ if (conversation && conversation.metadata) {
68
+ try {
69
+ conversation.metadata = JSON.parse(conversation.metadata);
70
+ // Map bookmarked to pinned for frontend compatibility
71
+ if (conversation.metadata.bookmarked !== undefined) {
72
+ conversation.metadata.pinned = conversation.metadata.bookmarked;
73
+ }
74
+ } catch (e) {
75
+ conversation.metadata = null;
76
+ }
77
+ }
78
+
79
+ return conversation;
80
+ }
81
+
82
+ /**
83
+ * List recent conversations
84
+ * @param {number} limit - Max number of conversations
85
+ * @returns {Array} List of conversations
86
+ */
87
+ static listRecent(limit = 20) {
88
+ const stmt = prepare(`
89
+ SELECT * FROM conversations
90
+ ORDER BY updated_at DESC
91
+ LIMIT ?
92
+ `);
93
+
94
+ const conversations = stmt.all(limit);
95
+
96
+ // Parse metadata and map bookmarked to pinned
97
+ conversations.forEach(conv => {
98
+ if (conv.metadata) {
99
+ try {
100
+ conv.metadata = JSON.parse(conv.metadata);
101
+ // Map bookmarked to pinned for frontend compatibility
102
+ if (conv.metadata.bookmarked !== undefined) {
103
+ conv.metadata.pinned = conv.metadata.bookmarked;
104
+ }
105
+ } catch (e) {
106
+ conv.metadata = null;
107
+ }
108
+ }
109
+ });
110
+
111
+ return conversations;
112
+ }
113
+
114
+ /**
115
+ * Update conversation title
116
+ * @param {string} id - Conversation ID
117
+ * @param {string} title - New title
118
+ */
119
+ static updateTitle(id, title) {
120
+ const stmt = prepare(`
121
+ UPDATE conversations
122
+ SET title = ?, updated_at = ?
123
+ WHERE id = ?
124
+ `);
125
+
126
+ stmt.run(title, Date.now(), id);
127
+ }
128
+
129
+ /**
130
+ * Touch conversation (update updated_at)
131
+ * @param {string} id - Conversation ID
132
+ */
133
+ static touch(id) {
134
+ const stmt = prepare(`
135
+ UPDATE conversations
136
+ SET updated_at = ?
137
+ WHERE id = ?
138
+ `);
139
+
140
+ stmt.run(Date.now(), id);
141
+ }
142
+
143
+ /**
144
+ * Update metadata
145
+ * @param {string} id - Conversation ID
146
+ * @param {Object} metadata - Metadata object
147
+ */
148
+ static updateMetadata(id, metadata) {
149
+ const stmt = prepare(`
150
+ UPDATE conversations
151
+ SET metadata = ?, updated_at = ?
152
+ WHERE id = ?
153
+ `);
154
+
155
+ stmt.run(JSON.stringify(metadata), Date.now(), id);
156
+ }
157
+
158
+ /**
159
+ * Delete conversation (cascade deletes messages)
160
+ * @param {string} id - Conversation ID
161
+ */
162
+ static delete(id) {
163
+ const stmt = prepare('DELETE FROM conversations WHERE id = ?');
164
+ stmt.run(id);
165
+ }
166
+
167
+ /**
168
+ * Count total conversations
169
+ * @returns {number} Total count
170
+ */
171
+ static count() {
172
+ const stmt = prepare('SELECT COUNT(*) as count FROM conversations');
173
+ const result = stmt.get();
174
+ return result ? result.count : 0;
175
+ }
176
+
177
+ /**
178
+ * List conversations grouped by date
179
+ * @param {number} limit - Max conversations per group (default: 20, 0 = unlimited)
180
+ * @returns {Object} Conversations grouped by: today, yesterday, last7days, last30days
181
+ */
182
+ static listGroupedByDate(limit = 20) {
183
+ // Use SQL CASE for date grouping - much faster than JS loop
184
+ const now = Date.now();
185
+ const oneDayMs = 24 * 60 * 60 * 1000;
186
+
187
+ const stmt = prepare(`
188
+ SELECT *,
189
+ CASE
190
+ WHEN (? - updated_at) < ? THEN 'today'
191
+ WHEN (? - updated_at) < ? THEN 'yesterday'
192
+ WHEN (? - updated_at) < ? THEN 'last7days'
193
+ WHEN (? - updated_at) < ? THEN 'last30days'
194
+ ELSE 'older'
195
+ END as date_group
196
+ FROM conversations
197
+ ORDER BY updated_at DESC
198
+ ${limit > 0 ? `LIMIT ${limit * 5}` : ''}
199
+ `);
200
+
201
+ const conversations = stmt.all(
202
+ now, oneDayMs, // today
203
+ now, 2 * oneDayMs, // yesterday
204
+ now, 7 * oneDayMs, // last7days
205
+ now, 30 * oneDayMs // last30days
206
+ );
207
+
208
+ const grouped = {
209
+ today: [],
210
+ yesterday: [],
211
+ last7days: [],
212
+ last30days: [],
213
+ older: []
214
+ };
215
+
216
+ // Single pass - group and parse metadata
217
+ for (const conv of conversations) {
218
+ const group = conv.date_group;
219
+ delete conv.date_group; // Clean up temp field
220
+
221
+ // Parse metadata
222
+ if (conv.metadata) {
223
+ try {
224
+ conv.metadata = JSON.parse(conv.metadata);
225
+ if (conv.metadata.bookmarked !== undefined) {
226
+ conv.metadata.pinned = conv.metadata.bookmarked;
227
+ }
228
+ } catch (e) {
229
+ conv.metadata = null;
230
+ }
231
+ }
232
+
233
+ // Add to group (respect limit per group)
234
+ if (limit === 0 || grouped[group].length < limit) {
235
+ grouped[group].push(conv);
236
+ }
237
+ }
238
+
239
+ return grouped;
240
+ }
241
+
242
+ /**
243
+ * Toggle bookmark status
244
+ * @param {string} id - Conversation ID
245
+ * @returns {boolean} New bookmark status
246
+ */
247
+ static toggleBookmark(id) {
248
+ const conversation = this.getById(id);
249
+ if (!conversation) return null;
250
+
251
+ const metadata = conversation.metadata || {};
252
+ metadata.bookmarked = !metadata.bookmarked;
253
+
254
+ this.updateMetadata(id, metadata);
255
+ return metadata.bookmarked;
256
+ }
257
+ }
258
+
259
+ module.exports = Conversation;
@@ -0,0 +1,228 @@
1
+ const { prepare } = require('../db');
2
+ const { v4: uuidv4 } = require('uuid');
3
+ const Conversation = require('./Conversation');
4
+
5
+ /**
6
+ * Message model - Individual chat messages
7
+ */
8
+ class Message {
9
+ /**
10
+ * Create new message
11
+ * @param {string} conversationId - Parent conversation ID
12
+ * @param {string} role - Message role ('user' | 'assistant' | 'system')
13
+ * @param {string} content - Message content (markdown)
14
+ * @param {Object} metadata - Optional metadata
15
+ * @param {number} createdAt - Optional timestamp override
16
+ * @param {string} engine - Engine used ('claude' | 'codex')
17
+ * @returns {Object} Created message
18
+ */
19
+ static create(conversationId, role, content, metadata = null, createdAt = Date.now(), engine = 'claude') {
20
+ const id = uuidv4();
21
+ const now = createdAt || Date.now();
22
+
23
+ const stmt = prepare(`
24
+ INSERT INTO messages (id, conversation_id, role, content, created_at, metadata, engine)
25
+ VALUES (?, ?, ?, ?, ?, ?, ?)
26
+ `);
27
+
28
+ stmt.run(
29
+ id,
30
+ conversationId,
31
+ role,
32
+ content,
33
+ now,
34
+ metadata ? JSON.stringify(metadata) : null,
35
+ engine
36
+ );
37
+
38
+ // Touch conversation (update updated_at)
39
+ Conversation.touch(conversationId);
40
+
41
+ return {
42
+ id,
43
+ conversation_id: conversationId,
44
+ role,
45
+ content,
46
+ created_at: now,
47
+ metadata,
48
+ engine
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Get message by ID
54
+ * @param {string} id - Message ID
55
+ * @returns {Object|null} Message or null if not found
56
+ */
57
+ static getById(id) {
58
+ const stmt = prepare('SELECT * FROM messages WHERE id = ?');
59
+ const message = stmt.get(id);
60
+
61
+ if (message && message.metadata) {
62
+ try {
63
+ message.metadata = JSON.parse(message.metadata);
64
+ } catch (e) {
65
+ message.metadata = null;
66
+ }
67
+ }
68
+
69
+ return message;
70
+ }
71
+
72
+ /**
73
+ * Get messages for conversation
74
+ * @param {string} conversationId - Conversation ID
75
+ * @param {number} limit - Max messages to return
76
+ * @param {number} offset - Offset for pagination
77
+ * @returns {Array} List of messages
78
+ */
79
+ static getByConversation(conversationId, limit = 100, offset = 0) {
80
+ const stmt = prepare(`
81
+ SELECT * FROM messages
82
+ WHERE conversation_id = ?
83
+ ORDER BY created_at ASC
84
+ LIMIT ? OFFSET ?
85
+ `);
86
+
87
+ const messages = stmt.all(conversationId, limit, offset);
88
+
89
+ // Parse metadata
90
+ messages.forEach(msg => {
91
+ if (msg.metadata) {
92
+ try {
93
+ msg.metadata = JSON.parse(msg.metadata);
94
+ } catch (e) {
95
+ msg.metadata = null;
96
+ }
97
+ }
98
+ });
99
+
100
+ return messages;
101
+ }
102
+
103
+ /**
104
+ * Get recent messages (across all conversations)
105
+ * @param {number} limit - Max messages
106
+ * @returns {Array} List of messages
107
+ */
108
+ static getRecent(limit = 50) {
109
+ const stmt = prepare(`
110
+ SELECT * FROM messages
111
+ ORDER BY created_at DESC
112
+ LIMIT ?
113
+ `);
114
+
115
+ return stmt.all(limit);
116
+ }
117
+
118
+ /**
119
+ * Update message content
120
+ * @param {string} id - Message ID
121
+ * @param {string} content - New content
122
+ */
123
+ static updateContent(id, content) {
124
+ const stmt = prepare(`
125
+ UPDATE messages
126
+ SET content = ?
127
+ WHERE id = ?
128
+ `);
129
+
130
+ const info = stmt.run(content, id);
131
+ return info.changes > 0;
132
+ }
133
+
134
+ /**
135
+ * Delete message
136
+ * @param {string} id - Message ID
137
+ */
138
+ static delete(id) {
139
+ const stmt = prepare('DELETE FROM messages WHERE id = ?');
140
+ const info = stmt.run(id);
141
+ return info.changes > 0;
142
+ }
143
+
144
+ /**
145
+ * Count messages in conversation
146
+ * @param {string} conversationId - Conversation ID
147
+ * @returns {number} Message count
148
+ */
149
+ static countByConversation(conversationId) {
150
+ const stmt = prepare(`
151
+ SELECT COUNT(*) as count FROM messages
152
+ WHERE conversation_id = ?
153
+ `);
154
+ const result = stmt.get(conversationId);
155
+ return result.count;
156
+ }
157
+
158
+ /**
159
+ * Get last engine used in conversation
160
+ * @param {string} conversationId - Conversation ID
161
+ * @returns {string|null} Last engine ('claude' | 'codex') or null
162
+ */
163
+ static getLastEngine(conversationId) {
164
+ const stmt = prepare(`
165
+ SELECT engine FROM messages
166
+ WHERE conversation_id = ?
167
+ ORDER BY created_at DESC
168
+ LIMIT 1
169
+ `);
170
+ const result = stmt.get(conversationId);
171
+ return result?.engine || null;
172
+ }
173
+
174
+ /**
175
+ * Get recent messages for context bridging
176
+ * Returns last N messages formatted for context injection
177
+ * @param {string} conversationId - Conversation ID
178
+ * @param {number} limit - Max messages (default 5)
179
+ * @returns {Array} Messages with role, content, engine
180
+ */
181
+ static getContextMessages(conversationId, limit = 5) {
182
+ const stmt = prepare(`
183
+ SELECT role, content, engine, created_at FROM messages
184
+ WHERE conversation_id = ?
185
+ ORDER BY created_at DESC
186
+ LIMIT ?
187
+ `);
188
+ const messages = stmt.all(conversationId, limit);
189
+ // Reverse to chronological order
190
+ return messages.reverse();
191
+ }
192
+
193
+ /**
194
+ * Build context string for engine bridging
195
+ * @param {string} conversationId - Conversation ID
196
+ * @param {string} newEngine - Engine being switched to
197
+ * @param {number} limit - Max messages for context
198
+ * @returns {string|null} Context string or null if no bridging needed
199
+ */
200
+ static buildBridgeContext(conversationId, newEngine, limit = 5) {
201
+ const lastEngine = this.getLastEngine(conversationId);
202
+
203
+ // No bridging needed if same engine or no previous messages
204
+ if (!lastEngine || lastEngine === newEngine) {
205
+ return null;
206
+ }
207
+
208
+ const messages = this.getContextMessages(conversationId, limit);
209
+ if (messages.length === 0) {
210
+ return null;
211
+ }
212
+
213
+ // Build context string
214
+ const contextLines = messages.map(m => {
215
+ const role = m.role === 'user' ? 'User' : 'Assistant';
216
+ const engineTag = m.engine ? ` [${m.engine}]` : '';
217
+ // Truncate long messages
218
+ const content = m.content.length > 500
219
+ ? m.content.substring(0, 500) + '...'
220
+ : m.content;
221
+ return `${role}${engineTag}: ${content}`;
222
+ });
223
+
224
+ return `[Context from previous ${lastEngine} session]\n${contextLines.join('\n\n')}`;
225
+ }
226
+ }
227
+
228
+ module.exports = Message;
@@ -0,0 +1,115 @@
1
+ const { prepare } = require('../db');
2
+ const bcrypt = require('bcryptjs');
3
+ const { v4: uuidv4 } = require('uuid');
4
+
5
+ class User {
6
+ static create(username, password, role = 'user') {
7
+ const id = uuidv4();
8
+ const passwordHash = bcrypt.hashSync(password, 10);
9
+ const now = Date.now();
10
+
11
+ const stmt = prepare(`
12
+ INSERT INTO users (id, username, password_hash, role, created_at)
13
+ VALUES (?, ?, ?, ?, ?)
14
+ `);
15
+
16
+ stmt.run(id, username, passwordHash, role, now);
17
+
18
+ return { id, username, role, created_at: now };
19
+ }
20
+
21
+ static findByUsername(username) {
22
+ const stmt = prepare('SELECT * FROM users WHERE username = ?');
23
+ return stmt.get(username);
24
+ }
25
+
26
+ static findById(id) {
27
+ const stmt = prepare('SELECT * FROM users WHERE id = ?');
28
+ return stmt.get(id);
29
+ }
30
+
31
+ static verifyPassword(user, password) {
32
+ return bcrypt.compareSync(password, user.password_hash);
33
+ }
34
+
35
+ static updateLastLogin(userId) {
36
+ const stmt = prepare('UPDATE users SET last_login = ? WHERE id = ?');
37
+ stmt.run(Date.now(), userId);
38
+ }
39
+
40
+ static incrementFailedAttempts(userId) {
41
+ const now = Date.now();
42
+ const stmt = prepare(`
43
+ UPDATE users
44
+ SET failed_attempts = failed_attempts + 1,
45
+ last_failed_attempt = ?
46
+ WHERE id = ?
47
+ `);
48
+ stmt.run(now, userId);
49
+
50
+ // Lock account after 5 failed attempts for 15 minutes
51
+ const user = this.findById(userId);
52
+ if (user.failed_attempts >= 5) {
53
+ const lockUntil = now + (15 * 60 * 1000); // 15 minutes
54
+ const lockStmt = prepare(`
55
+ UPDATE users
56
+ SET is_locked = 1, locked_until = ?
57
+ WHERE id = ?
58
+ `);
59
+ lockStmt.run(lockUntil, userId);
60
+ }
61
+ }
62
+
63
+ static resetFailedAttempts(userId) {
64
+ const stmt = prepare(`
65
+ UPDATE users
66
+ SET failed_attempts = 0,
67
+ is_locked = 0,
68
+ locked_until = NULL
69
+ WHERE id = ?
70
+ `);
71
+ stmt.run(userId);
72
+ }
73
+
74
+ static isAccountLocked(user) {
75
+ if (!user.is_locked) return false;
76
+ if (!user.locked_until) return true;
77
+
78
+ const now = Date.now();
79
+ if (now < user.locked_until) {
80
+ return true;
81
+ }
82
+
83
+ // Unlock account if lock period expired
84
+ this.resetFailedAttempts(user.id);
85
+ return false;
86
+ }
87
+
88
+ static logLoginAttempt(ipAddress, username, success) {
89
+ const stmt = prepare(`
90
+ INSERT INTO login_attempts (ip_address, username, success, timestamp)
91
+ VALUES (?, ?, ?, ?)
92
+ `);
93
+ stmt.run(ipAddress, username, success ? 1 : 0, Date.now());
94
+ }
95
+
96
+ static getRecentLoginAttempts(ipAddress, windowMs = 15 * 60 * 1000) {
97
+ const since = Date.now() - windowMs;
98
+ const stmt = prepare(`
99
+ SELECT COUNT(*) as count
100
+ FROM login_attempts
101
+ WHERE ip_address = ? AND timestamp > ?
102
+ `);
103
+ const result = stmt.get(ipAddress, since);
104
+ return result ? result.count : 0;
105
+ }
106
+
107
+ static cleanupOldLoginAttempts(daysToKeep = 7) {
108
+ const cutoff = Date.now() - (daysToKeep * 24 * 60 * 60 * 1000);
109
+ const stmt = prepare('DELETE FROM login_attempts WHERE timestamp < ?');
110
+ stmt.run(cutoff);
111
+ return 0; // sql.js doesn't return changes count easily
112
+ }
113
+ }
114
+
115
+ module.exports = User;