@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,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;
|