otherwise-cli 0.1.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/README.md +193 -0
- package/bin/otherwise.js +5 -0
- package/frontend/404.html +84 -0
- package/frontend/assets/OpenDyslexic3-Bold-CDyRs55Y.ttf +0 -0
- package/frontend/assets/OpenDyslexic3-Regular-CIBXa4WE.ttf +0 -0
- package/frontend/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/frontend/assets/conversational-worker-CeKiciGk.js +2929 -0
- package/frontend/assets/dictation-worker-D0aYfq8b.js +29 -0
- package/frontend/assets/gemini-color-CgSQmmva.png +0 -0
- package/frontend/assets/index-BLux5ps4.js +21 -0
- package/frontend/assets/index-Blh8_TEM.js +5272 -0
- package/frontend/assets/index-BpQ1PuKu.js +18 -0
- package/frontend/assets/index-Df737c8w.css +1 -0
- package/frontend/assets/index-xaYHL6wb.js +113 -0
- package/frontend/assets/ort-wasm-simd-threaded.asyncify-BynIiDiv.wasm +0 -0
- package/frontend/assets/ort-wasm-simd-threaded.jsep-B0T3yYHD.wasm +0 -0
- package/frontend/assets/transformers-tULNc5V3.js +31 -0
- package/frontend/assets/tts-worker-DPJWqT7N.js +2899 -0
- package/frontend/assets/voice-mode-worker-GzvIE_uh.js +2927 -0
- package/frontend/assets/worker-2d5ABSLU.js +31 -0
- package/frontend/banner.png +0 -0
- package/frontend/favicon.svg +3 -0
- package/frontend/google55e5ec47ee14a5f8.html +1 -0
- package/frontend/index.html +234 -0
- package/frontend/manifest.json +17 -0
- package/frontend/pdf.worker.min.mjs +21 -0
- package/frontend/robots.txt +5 -0
- package/frontend/sitemap.xml +27 -0
- package/package.json +81 -0
- package/src/agent/index.js +1066 -0
- package/src/agent/location.js +51 -0
- package/src/agent/prompt.js +548 -0
- package/src/agent/tools.js +4372 -0
- package/src/browser/detect.js +68 -0
- package/src/browser/session.js +1109 -0
- package/src/config.js +137 -0
- package/src/email/client.js +503 -0
- package/src/index.js +557 -0
- package/src/inference/anthropic.js +113 -0
- package/src/inference/google.js +373 -0
- package/src/inference/index.js +81 -0
- package/src/inference/ollama.js +383 -0
- package/src/inference/openai.js +140 -0
- package/src/inference/openrouter.js +378 -0
- package/src/inference/xai.js +200 -0
- package/src/logBridge.js +9 -0
- package/src/models.js +146 -0
- package/src/remote/client.js +225 -0
- package/src/scheduler/cron.js +243 -0
- package/src/server.js +3876 -0
- package/src/storage/db.js +1135 -0
- package/src/storage/supabase.js +364 -0
- package/src/tunnel/cloudflare.js +241 -0
- package/src/ui/components/App.jsx +687 -0
- package/src/ui/components/BrowserSelect.jsx +111 -0
- package/src/ui/components/FilePicker.jsx +472 -0
- package/src/ui/components/Header.jsx +444 -0
- package/src/ui/components/HelpPanel.jsx +173 -0
- package/src/ui/components/HistoryPanel.jsx +158 -0
- package/src/ui/components/MessageList.jsx +235 -0
- package/src/ui/components/ModelSelector.jsx +304 -0
- package/src/ui/components/PromptInput.jsx +515 -0
- package/src/ui/components/StreamingResponse.jsx +134 -0
- package/src/ui/components/ThinkingIndicator.jsx +365 -0
- package/src/ui/components/ToolExecution.jsx +714 -0
- package/src/ui/components/index.js +82 -0
- package/src/ui/context/TerminalContext.jsx +150 -0
- package/src/ui/context/index.js +13 -0
- package/src/ui/hooks/index.js +16 -0
- package/src/ui/hooks/useChatState.js +675 -0
- package/src/ui/hooks/useCommands.js +280 -0
- package/src/ui/hooks/useFileAttachments.js +216 -0
- package/src/ui/hooks/useKeyboardShortcuts.js +173 -0
- package/src/ui/hooks/useNotifications.js +185 -0
- package/src/ui/hooks/useTerminalSize.js +151 -0
- package/src/ui/hooks/useWebSocket.js +273 -0
- package/src/ui/index.js +94 -0
- package/src/ui/ink-runner.js +22 -0
- package/src/ui/utils/formatters.js +424 -0
- package/src/ui/utils/index.js +6 -0
- package/src/ui/utils/markdown.js +166 -0
|
@@ -0,0 +1,1135 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { randomUUID } from "crypto";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { mkdirSync, existsSync } from "fs";
|
|
6
|
+
|
|
7
|
+
let db = null;
|
|
8
|
+
|
|
9
|
+
/** UUID regex for validation */
|
|
10
|
+
const UUID_REGEX =
|
|
11
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
12
|
+
export function isValidChatId(id) {
|
|
13
|
+
if (id == null || typeof id !== "string") return false;
|
|
14
|
+
return UUID_REGEX.test(id);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Get the database directory path
|
|
19
|
+
*/
|
|
20
|
+
function getDbPath() {
|
|
21
|
+
const dataDir = join(homedir(), ".otherwise");
|
|
22
|
+
if (!existsSync(dataDir)) {
|
|
23
|
+
mkdirSync(dataDir, { recursive: true });
|
|
24
|
+
}
|
|
25
|
+
return join(dataDir, "otherwise.db");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Initialize the database and create tables
|
|
30
|
+
*/
|
|
31
|
+
export async function initDb() {
|
|
32
|
+
const dbPath = getDbPath();
|
|
33
|
+
db = new Database(dbPath);
|
|
34
|
+
|
|
35
|
+
// Enable WAL mode for better concurrent access
|
|
36
|
+
db.pragma("journal_mode = WAL");
|
|
37
|
+
|
|
38
|
+
// Create tables
|
|
39
|
+
db.exec(`
|
|
40
|
+
-- Chats table
|
|
41
|
+
CREATE TABLE IF NOT EXISTS chats (
|
|
42
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
43
|
+
title TEXT,
|
|
44
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
45
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
-- Messages table
|
|
49
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
50
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
51
|
+
chat_id INTEGER NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
|
|
52
|
+
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
|
|
53
|
+
content TEXT NOT NULL,
|
|
54
|
+
metadata JSON,
|
|
55
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
-- Index for faster message lookup by chat
|
|
59
|
+
CREATE INDEX IF NOT EXISTS idx_messages_chat_id ON messages(chat_id);
|
|
60
|
+
|
|
61
|
+
-- Embeddings table for memory visualization
|
|
62
|
+
CREATE TABLE IF NOT EXISTS embeddings (
|
|
63
|
+
chat_id INTEGER PRIMARY KEY REFERENCES chats(id) ON DELETE CASCADE,
|
|
64
|
+
embedding BLOB NOT NULL,
|
|
65
|
+
provider TEXT DEFAULT 'openai',
|
|
66
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
-- Scheduled tasks table
|
|
70
|
+
CREATE TABLE IF NOT EXISTS scheduled_tasks (
|
|
71
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
72
|
+
cron_expression TEXT NOT NULL,
|
|
73
|
+
description TEXT NOT NULL,
|
|
74
|
+
next_run DATETIME,
|
|
75
|
+
last_run DATETIME,
|
|
76
|
+
enabled INTEGER DEFAULT 1,
|
|
77
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
-- Index for finding due tasks
|
|
81
|
+
CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_next_run ON scheduled_tasks(next_run);
|
|
82
|
+
|
|
83
|
+
-- File snapshots table for undo on regeneration
|
|
84
|
+
-- Stores file content BEFORE write_file/edit_file tool modifies it
|
|
85
|
+
CREATE TABLE IF NOT EXISTS file_snapshots (
|
|
86
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
87
|
+
chat_id INTEGER NOT NULL,
|
|
88
|
+
message_index INTEGER NOT NULL,
|
|
89
|
+
tool_call_id TEXT,
|
|
90
|
+
file_path TEXT NOT NULL,
|
|
91
|
+
original_content TEXT,
|
|
92
|
+
file_existed INTEGER NOT NULL DEFAULT 1,
|
|
93
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
94
|
+
UNIQUE(chat_id, message_index, file_path)
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
-- Index for fast lookup during revert
|
|
98
|
+
CREATE INDEX IF NOT EXISTS idx_file_snapshots_chat_message ON file_snapshots(chat_id, message_index);
|
|
99
|
+
|
|
100
|
+
-- RAG Documents table for document storage
|
|
101
|
+
CREATE TABLE IF NOT EXISTS rag_documents (
|
|
102
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
103
|
+
name TEXT NOT NULL,
|
|
104
|
+
chunk_count INTEGER DEFAULT 0,
|
|
105
|
+
file_count INTEGER DEFAULT 1,
|
|
106
|
+
files TEXT,
|
|
107
|
+
summary TEXT,
|
|
108
|
+
upload_date DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
109
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
110
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
-- RAG Chunks table for document chunks with embeddings
|
|
114
|
+
CREATE TABLE IF NOT EXISTS rag_chunks (
|
|
115
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
116
|
+
doc_id INTEGER NOT NULL REFERENCES rag_documents(id) ON DELETE CASCADE,
|
|
117
|
+
text TEXT NOT NULL,
|
|
118
|
+
page_number INTEGER,
|
|
119
|
+
chunk_index INTEGER NOT NULL,
|
|
120
|
+
embedding BLOB NOT NULL,
|
|
121
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
-- Index for fast chunk lookup by document
|
|
125
|
+
CREATE INDEX IF NOT EXISTS idx_rag_chunks_doc_id ON rag_chunks(doc_id);
|
|
126
|
+
|
|
127
|
+
-- Shell undo log for revert on regeneration (Strategy 1: undo shell commands like mkdir)
|
|
128
|
+
CREATE TABLE IF NOT EXISTS shell_undo_log (
|
|
129
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
130
|
+
chat_id INTEGER NOT NULL,
|
|
131
|
+
message_index INTEGER NOT NULL,
|
|
132
|
+
tool_call_id TEXT,
|
|
133
|
+
op TEXT NOT NULL,
|
|
134
|
+
path TEXT,
|
|
135
|
+
path_src TEXT,
|
|
136
|
+
path_dest TEXT,
|
|
137
|
+
cwd TEXT,
|
|
138
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
139
|
+
);
|
|
140
|
+
CREATE INDEX IF NOT EXISTS idx_shell_undo_log_chat_message ON shell_undo_log(chat_id, message_index);
|
|
141
|
+
`);
|
|
142
|
+
|
|
143
|
+
// Migration: add created_dir to file_snapshots if missing (Strategy 2: undo dirs created by write_file)
|
|
144
|
+
const tableInfo = db.prepare("PRAGMA table_info(file_snapshots)").all();
|
|
145
|
+
if (!tableInfo.some((col) => col.name === "created_dir")) {
|
|
146
|
+
db.exec("ALTER TABLE file_snapshots ADD COLUMN created_dir TEXT");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Migration: add cloud_chat_id to chats for CLI→Supabase sync (frontend reads from Supabase only)
|
|
150
|
+
const chatsInfo = db.prepare("PRAGMA table_info(chats)").all();
|
|
151
|
+
if (!chatsInfo.some((col) => col.name === "cloud_chat_id")) {
|
|
152
|
+
db.exec("ALTER TABLE chats ADD COLUMN cloud_chat_id TEXT");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Migration: integer chat id -> UUID (unified local/cloud id)
|
|
156
|
+
const idCol = chatsInfo.find((c) => c.name === "id");
|
|
157
|
+
if (idCol && idCol.type.toUpperCase() === "INTEGER") {
|
|
158
|
+
if (process.env.SILENT_MODE !== "true") {
|
|
159
|
+
console.log("Database: migrating chat ids to UUID...");
|
|
160
|
+
}
|
|
161
|
+
const idMap = new Map(); // old integer id -> new uuid
|
|
162
|
+
|
|
163
|
+
db.exec(`
|
|
164
|
+
CREATE TABLE chats_new (
|
|
165
|
+
id TEXT PRIMARY KEY,
|
|
166
|
+
title TEXT,
|
|
167
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
168
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
169
|
+
cloud_chat_id TEXT
|
|
170
|
+
);
|
|
171
|
+
`);
|
|
172
|
+
const oldChats = db.prepare("SELECT * FROM chats").all();
|
|
173
|
+
const insertChat = db.prepare(
|
|
174
|
+
"INSERT INTO chats_new (id, title, created_at, updated_at, cloud_chat_id) VALUES (?, ?, ?, ?, ?)",
|
|
175
|
+
);
|
|
176
|
+
for (const c of oldChats) {
|
|
177
|
+
const uuid =
|
|
178
|
+
c.cloud_chat_id && UUID_REGEX.test(String(c.cloud_chat_id))
|
|
179
|
+
? c.cloud_chat_id
|
|
180
|
+
: randomUUID();
|
|
181
|
+
idMap.set(c.id, uuid);
|
|
182
|
+
insertChat.run(
|
|
183
|
+
uuid,
|
|
184
|
+
c.title,
|
|
185
|
+
c.created_at,
|
|
186
|
+
c.updated_at,
|
|
187
|
+
c.cloud_chat_id || null,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
db.exec(`
|
|
192
|
+
CREATE TABLE messages_new (
|
|
193
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
194
|
+
chat_id TEXT NOT NULL,
|
|
195
|
+
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
|
|
196
|
+
content TEXT NOT NULL,
|
|
197
|
+
metadata JSON,
|
|
198
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
199
|
+
);
|
|
200
|
+
CREATE INDEX IF NOT EXISTS idx_messages_new_chat_id ON messages_new(chat_id);
|
|
201
|
+
`);
|
|
202
|
+
const oldMessages = db.prepare("SELECT * FROM messages").all();
|
|
203
|
+
const insertMsg = db.prepare(
|
|
204
|
+
"INSERT INTO messages_new (chat_id, role, content, metadata, created_at) VALUES (?, ?, ?, ?, ?)",
|
|
205
|
+
);
|
|
206
|
+
for (const m of oldMessages) {
|
|
207
|
+
const newChatId = idMap.get(m.chat_id);
|
|
208
|
+
if (newChatId)
|
|
209
|
+
insertMsg.run(newChatId, m.role, m.content, m.metadata, m.created_at);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
db.exec(`
|
|
213
|
+
CREATE TABLE embeddings_new (
|
|
214
|
+
chat_id TEXT PRIMARY KEY,
|
|
215
|
+
embedding BLOB NOT NULL,
|
|
216
|
+
provider TEXT DEFAULT 'openai',
|
|
217
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
218
|
+
);
|
|
219
|
+
`);
|
|
220
|
+
const oldEmbeddings = db.prepare("SELECT * FROM embeddings").all();
|
|
221
|
+
const insertEmb = db.prepare(
|
|
222
|
+
"INSERT INTO embeddings_new (chat_id, embedding, provider, updated_at) VALUES (?, ?, ?, ?)",
|
|
223
|
+
);
|
|
224
|
+
for (const e of oldEmbeddings) {
|
|
225
|
+
const newChatId = idMap.get(e.chat_id);
|
|
226
|
+
if (newChatId)
|
|
227
|
+
insertEmb.run(newChatId, e.embedding, e.provider, e.updated_at);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
db.exec(`
|
|
231
|
+
CREATE TABLE file_snapshots_new (
|
|
232
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
233
|
+
chat_id TEXT NOT NULL,
|
|
234
|
+
message_index INTEGER NOT NULL,
|
|
235
|
+
tool_call_id TEXT,
|
|
236
|
+
file_path TEXT NOT NULL,
|
|
237
|
+
original_content TEXT,
|
|
238
|
+
file_existed INTEGER NOT NULL DEFAULT 1,
|
|
239
|
+
created_dir TEXT,
|
|
240
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
241
|
+
UNIQUE(chat_id, message_index, file_path)
|
|
242
|
+
);
|
|
243
|
+
CREATE INDEX IF NOT EXISTS idx_file_snapshots_new_chat_message ON file_snapshots_new(chat_id, message_index);
|
|
244
|
+
`);
|
|
245
|
+
const oldSnapshots = db.prepare("SELECT * FROM file_snapshots").all();
|
|
246
|
+
const insertSnap = db.prepare(
|
|
247
|
+
"INSERT INTO file_snapshots_new (chat_id, message_index, tool_call_id, file_path, original_content, file_existed, created_dir, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
248
|
+
);
|
|
249
|
+
for (const s of oldSnapshots) {
|
|
250
|
+
const newChatId = idMap.get(s.chat_id);
|
|
251
|
+
if (newChatId)
|
|
252
|
+
insertSnap.run(
|
|
253
|
+
newChatId,
|
|
254
|
+
s.message_index,
|
|
255
|
+
s.tool_call_id,
|
|
256
|
+
s.file_path,
|
|
257
|
+
s.original_content,
|
|
258
|
+
s.file_existed,
|
|
259
|
+
s.created_dir,
|
|
260
|
+
s.created_at,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
db.exec(`
|
|
265
|
+
CREATE TABLE shell_undo_log_new (
|
|
266
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
267
|
+
chat_id TEXT NOT NULL,
|
|
268
|
+
message_index INTEGER NOT NULL,
|
|
269
|
+
tool_call_id TEXT,
|
|
270
|
+
op TEXT NOT NULL,
|
|
271
|
+
path TEXT,
|
|
272
|
+
path_src TEXT,
|
|
273
|
+
path_dest TEXT,
|
|
274
|
+
cwd TEXT,
|
|
275
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
276
|
+
);
|
|
277
|
+
CREATE INDEX IF NOT EXISTS idx_shell_undo_log_new_chat_message ON shell_undo_log_new(chat_id, message_index);
|
|
278
|
+
`);
|
|
279
|
+
const oldUndos = db.prepare("SELECT * FROM shell_undo_log").all();
|
|
280
|
+
const insertUndo = db.prepare(
|
|
281
|
+
"INSERT INTO shell_undo_log_new (chat_id, message_index, tool_call_id, op, path, path_src, path_dest, cwd, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
282
|
+
);
|
|
283
|
+
for (const u of oldUndos) {
|
|
284
|
+
const newChatId = idMap.get(u.chat_id);
|
|
285
|
+
if (newChatId)
|
|
286
|
+
insertUndo.run(
|
|
287
|
+
newChatId,
|
|
288
|
+
u.message_index,
|
|
289
|
+
u.tool_call_id,
|
|
290
|
+
u.op,
|
|
291
|
+
u.path,
|
|
292
|
+
u.path_src,
|
|
293
|
+
u.path_dest,
|
|
294
|
+
u.cwd,
|
|
295
|
+
u.created_at,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
db.exec(`
|
|
300
|
+
DROP TABLE shell_undo_log;
|
|
301
|
+
DROP TABLE file_snapshots;
|
|
302
|
+
DROP TABLE embeddings;
|
|
303
|
+
DROP TABLE messages;
|
|
304
|
+
DROP TABLE chats;
|
|
305
|
+
ALTER TABLE chats_new RENAME TO chats;
|
|
306
|
+
ALTER TABLE messages_new RENAME TO messages;
|
|
307
|
+
ALTER TABLE embeddings_new RENAME TO embeddings;
|
|
308
|
+
ALTER TABLE file_snapshots_new RENAME TO file_snapshots;
|
|
309
|
+
ALTER TABLE shell_undo_log_new RENAME TO shell_undo_log;
|
|
310
|
+
`);
|
|
311
|
+
if (process.env.SILENT_MODE !== "true") {
|
|
312
|
+
console.log("Database: migration to UUID chat ids complete.");
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (process.env.SILENT_MODE !== "true") {
|
|
317
|
+
console.log("Database initialized at:", dbPath);
|
|
318
|
+
}
|
|
319
|
+
return db;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Get the database instance
|
|
324
|
+
*/
|
|
325
|
+
export function getDb() {
|
|
326
|
+
if (!db) {
|
|
327
|
+
throw new Error("Database not initialized. Call initDb() first.");
|
|
328
|
+
}
|
|
329
|
+
return db;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Close the database connection
|
|
334
|
+
*/
|
|
335
|
+
export function closeDb() {
|
|
336
|
+
if (db) {
|
|
337
|
+
db.close();
|
|
338
|
+
db = null;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ============================================
|
|
343
|
+
// Chat Operations
|
|
344
|
+
// ============================================
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Create a new chat with a UUID id (same id can be used for cloud/Supabase).
|
|
348
|
+
* @param {string|null} title - Chat title
|
|
349
|
+
* @param {string|null} id - Optional UUID to use (e.g. from Supabase); if not provided, one is generated
|
|
350
|
+
* @returns {string} The chat id (UUID)
|
|
351
|
+
*/
|
|
352
|
+
export function createChat(title = null, id = null) {
|
|
353
|
+
const chatId = id && UUID_REGEX.test(String(id)) ? id : randomUUID();
|
|
354
|
+
getDb()
|
|
355
|
+
.prepare("INSERT INTO chats (id, title, cloud_chat_id) VALUES (?, ?, ?)")
|
|
356
|
+
.run(chatId, title, id && UUID_REGEX.test(String(id)) ? id : null);
|
|
357
|
+
return chatId;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function getChat(id) {
|
|
361
|
+
return getDb().prepare("SELECT * FROM chats WHERE id = ?").get(id);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Get local chat id by cloud chat UUID. With unified ids, local id equals cloud id. */
|
|
365
|
+
export function getLocalChatIdByCloudId(cloudChatId) {
|
|
366
|
+
if (!cloudChatId || typeof cloudChatId !== "string") return null;
|
|
367
|
+
const row = getDb()
|
|
368
|
+
.prepare("SELECT id FROM chats WHERE id = ? OR cloud_chat_id = ?")
|
|
369
|
+
.get(cloudChatId, cloudChatId);
|
|
370
|
+
return row ? row.id : null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Set cloud_chat_id for an existing local chat (after creating in Supabase) */
|
|
374
|
+
export function setCloudChatId(localChatId, cloudChatId) {
|
|
375
|
+
getDb()
|
|
376
|
+
.prepare("UPDATE chats SET cloud_chat_id = ? WHERE id = ?")
|
|
377
|
+
.run(cloudChatId, localChatId);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export function getAllChats() {
|
|
381
|
+
return getDb().prepare("SELECT * FROM chats ORDER BY updated_at DESC").all();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export function updateChatTitle(id, title) {
|
|
385
|
+
getDb()
|
|
386
|
+
.prepare(
|
|
387
|
+
"UPDATE chats SET title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
|
388
|
+
)
|
|
389
|
+
.run(title, id);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function deleteChat(id) {
|
|
393
|
+
const db = getDb();
|
|
394
|
+
db.prepare("DELETE FROM messages WHERE chat_id = ?").run(id);
|
|
395
|
+
db.prepare("DELETE FROM embeddings WHERE chat_id = ?").run(id);
|
|
396
|
+
db.prepare("DELETE FROM chats WHERE id = ?").run(id);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ============================================
|
|
400
|
+
// Message Operations
|
|
401
|
+
// ============================================
|
|
402
|
+
|
|
403
|
+
export function addMessage(chatId, role, content, metadata = null) {
|
|
404
|
+
const result = getDb()
|
|
405
|
+
.prepare(
|
|
406
|
+
`
|
|
407
|
+
INSERT INTO messages (chat_id, role, content, metadata) VALUES (?, ?, ?, ?)
|
|
408
|
+
`,
|
|
409
|
+
)
|
|
410
|
+
.run(chatId, role, content, metadata ? JSON.stringify(metadata) : null);
|
|
411
|
+
|
|
412
|
+
// Update chat timestamp
|
|
413
|
+
getDb()
|
|
414
|
+
.prepare("UPDATE chats SET updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
|
415
|
+
.run(chatId);
|
|
416
|
+
|
|
417
|
+
return result.lastInsertRowid;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export function getMessages(chatId) {
|
|
421
|
+
const messages = getDb()
|
|
422
|
+
.prepare(
|
|
423
|
+
`
|
|
424
|
+
SELECT * FROM messages WHERE chat_id = ? ORDER BY created_at ASC
|
|
425
|
+
`,
|
|
426
|
+
)
|
|
427
|
+
.all(chatId);
|
|
428
|
+
|
|
429
|
+
return messages.map((m) => ({
|
|
430
|
+
...m,
|
|
431
|
+
metadata: m.metadata ? JSON.parse(m.metadata) : null,
|
|
432
|
+
}));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export function deleteMessage(id) {
|
|
436
|
+
getDb().prepare("DELETE FROM messages WHERE id = ?").run(id);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ============================================
|
|
440
|
+
// Memory Search Operations
|
|
441
|
+
// ============================================
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Extract a snippet showing where the match was found with context
|
|
445
|
+
* @param {string} content - Full message content
|
|
446
|
+
* @param {string} query - Search query
|
|
447
|
+
* @param {number} contextChars - Characters of context on each side
|
|
448
|
+
* @returns {string} Snippet with match highlighted
|
|
449
|
+
*/
|
|
450
|
+
function extractMatchSnippet(content, query, contextChars = 80) {
|
|
451
|
+
if (!content || !query) return "";
|
|
452
|
+
|
|
453
|
+
const lowerContent = content.toLowerCase();
|
|
454
|
+
const lowerQuery = query.toLowerCase();
|
|
455
|
+
const matchIndex = lowerContent.indexOf(lowerQuery);
|
|
456
|
+
|
|
457
|
+
if (matchIndex === -1) return content.substring(0, 150) + "...";
|
|
458
|
+
|
|
459
|
+
const start = Math.max(0, matchIndex - contextChars);
|
|
460
|
+
const end = Math.min(
|
|
461
|
+
content.length,
|
|
462
|
+
matchIndex + query.length + contextChars,
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
let snippet = "";
|
|
466
|
+
if (start > 0) snippet += "...";
|
|
467
|
+
snippet += content.substring(start, end);
|
|
468
|
+
if (end < content.length) snippet += "...";
|
|
469
|
+
|
|
470
|
+
return snippet;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Text search across chats and messages with better snippets
|
|
475
|
+
* @param {string} query - Search query string
|
|
476
|
+
* @param {number} limit - Maximum results to return
|
|
477
|
+
* @returns {Array} Matching chats with relevant snippets
|
|
478
|
+
*/
|
|
479
|
+
export function searchChatsText(query, limit = 10) {
|
|
480
|
+
const searchPattern = `%${query}%`;
|
|
481
|
+
const db = getDb();
|
|
482
|
+
|
|
483
|
+
// First, find all matching messages with their chat info
|
|
484
|
+
const matchingMessages = db
|
|
485
|
+
.prepare(
|
|
486
|
+
`
|
|
487
|
+
SELECT
|
|
488
|
+
m.chat_id,
|
|
489
|
+
m.role,
|
|
490
|
+
m.content,
|
|
491
|
+
m.created_at as message_date,
|
|
492
|
+
c.title,
|
|
493
|
+
c.updated_at
|
|
494
|
+
FROM messages m
|
|
495
|
+
JOIN chats c ON c.id = m.chat_id
|
|
496
|
+
WHERE m.content LIKE ?
|
|
497
|
+
ORDER BY c.updated_at DESC, m.created_at DESC
|
|
498
|
+
`,
|
|
499
|
+
)
|
|
500
|
+
.all(searchPattern);
|
|
501
|
+
|
|
502
|
+
// Also find chats matching by title
|
|
503
|
+
const titleMatches = db
|
|
504
|
+
.prepare(
|
|
505
|
+
`
|
|
506
|
+
SELECT id, title, updated_at
|
|
507
|
+
FROM chats
|
|
508
|
+
WHERE title LIKE ?
|
|
509
|
+
ORDER BY updated_at DESC
|
|
510
|
+
`,
|
|
511
|
+
)
|
|
512
|
+
.all(searchPattern);
|
|
513
|
+
|
|
514
|
+
// Group by chat and pick best snippet
|
|
515
|
+
const chatMap = new Map();
|
|
516
|
+
|
|
517
|
+
// Add title matches first
|
|
518
|
+
for (const chat of titleMatches) {
|
|
519
|
+
if (!chatMap.has(chat.id)) {
|
|
520
|
+
chatMap.set(chat.id, {
|
|
521
|
+
id: chat.id,
|
|
522
|
+
title: chat.title,
|
|
523
|
+
updated_at: chat.updated_at,
|
|
524
|
+
match_type: "title",
|
|
525
|
+
matching_snippet: `Title matches: "${chat.title}"`,
|
|
526
|
+
match_count: 0,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Add message matches with snippets
|
|
532
|
+
for (const msg of matchingMessages) {
|
|
533
|
+
const existing = chatMap.get(msg.chat_id);
|
|
534
|
+
const snippet = extractMatchSnippet(msg.content, query);
|
|
535
|
+
const roleLabel = msg.role === "user" ? "You said" : "I said";
|
|
536
|
+
|
|
537
|
+
if (!existing) {
|
|
538
|
+
chatMap.set(msg.chat_id, {
|
|
539
|
+
id: msg.chat_id,
|
|
540
|
+
title: msg.title,
|
|
541
|
+
updated_at: msg.updated_at,
|
|
542
|
+
match_type: "content",
|
|
543
|
+
matching_snippet: `${roleLabel}: "${snippet}"`,
|
|
544
|
+
match_count: 1,
|
|
545
|
+
});
|
|
546
|
+
} else {
|
|
547
|
+
existing.match_count++;
|
|
548
|
+
// Keep the first (most recent) snippet but update count
|
|
549
|
+
if (existing.match_type === "title") {
|
|
550
|
+
// Title matched, but also has content match - upgrade snippet
|
|
551
|
+
existing.matching_snippet = `${roleLabel}: "${snippet}"`;
|
|
552
|
+
existing.match_type = "both";
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Convert to array and limit
|
|
558
|
+
const results = Array.from(chatMap.values())
|
|
559
|
+
.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at))
|
|
560
|
+
.slice(0, limit);
|
|
561
|
+
|
|
562
|
+
return results;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Get chat with all its messages for full context
|
|
567
|
+
* @param {number} chatId - The chat ID
|
|
568
|
+
* @returns {Object|null} Chat with messages array, or null if not found
|
|
569
|
+
*/
|
|
570
|
+
export function getChatWithMessages(chatId) {
|
|
571
|
+
const db = getDb();
|
|
572
|
+
|
|
573
|
+
const chat = db.prepare("SELECT * FROM chats WHERE id = ?").get(chatId);
|
|
574
|
+
if (!chat) return null;
|
|
575
|
+
|
|
576
|
+
const messages = db
|
|
577
|
+
.prepare(
|
|
578
|
+
`
|
|
579
|
+
SELECT role, content, created_at
|
|
580
|
+
FROM messages
|
|
581
|
+
WHERE chat_id = ?
|
|
582
|
+
ORDER BY created_at ASC
|
|
583
|
+
`,
|
|
584
|
+
)
|
|
585
|
+
.all(chatId);
|
|
586
|
+
|
|
587
|
+
return { ...chat, messages };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// ============================================
|
|
591
|
+
// Embedding Operations
|
|
592
|
+
// ============================================
|
|
593
|
+
|
|
594
|
+
export function saveEmbedding(chatId, embedding, provider = "openai") {
|
|
595
|
+
// Convert array to Buffer
|
|
596
|
+
const buffer = Buffer.from(new Float32Array(embedding).buffer);
|
|
597
|
+
|
|
598
|
+
getDb()
|
|
599
|
+
.prepare(
|
|
600
|
+
`
|
|
601
|
+
INSERT OR REPLACE INTO embeddings (chat_id, embedding, provider, updated_at)
|
|
602
|
+
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
|
603
|
+
`,
|
|
604
|
+
)
|
|
605
|
+
.run(chatId, buffer, provider);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
export function getEmbedding(chatId) {
|
|
609
|
+
const row = getDb()
|
|
610
|
+
.prepare("SELECT * FROM embeddings WHERE chat_id = ?")
|
|
611
|
+
.get(chatId);
|
|
612
|
+
if (!row) return null;
|
|
613
|
+
|
|
614
|
+
// Convert Buffer back to array
|
|
615
|
+
const embedding = Array.from(
|
|
616
|
+
new Float32Array(
|
|
617
|
+
row.embedding.buffer,
|
|
618
|
+
row.embedding.byteOffset,
|
|
619
|
+
row.embedding.length / 4,
|
|
620
|
+
),
|
|
621
|
+
);
|
|
622
|
+
return { ...row, embedding };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export function getAllEmbeddings() {
|
|
626
|
+
const rows = getDb().prepare("SELECT * FROM embeddings").all();
|
|
627
|
+
return rows.map((row) => ({
|
|
628
|
+
...row,
|
|
629
|
+
embedding: Array.from(
|
|
630
|
+
new Float32Array(
|
|
631
|
+
row.embedding.buffer,
|
|
632
|
+
row.embedding.byteOffset,
|
|
633
|
+
row.embedding.length / 4,
|
|
634
|
+
),
|
|
635
|
+
),
|
|
636
|
+
}));
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// ============================================
|
|
640
|
+
// Scheduled Task Operations
|
|
641
|
+
// ============================================
|
|
642
|
+
|
|
643
|
+
export function createScheduledTask(cronExpression, description) {
|
|
644
|
+
const result = getDb()
|
|
645
|
+
.prepare(
|
|
646
|
+
`
|
|
647
|
+
INSERT INTO scheduled_tasks (cron_expression, description) VALUES (?, ?)
|
|
648
|
+
`,
|
|
649
|
+
)
|
|
650
|
+
.run(cronExpression, description);
|
|
651
|
+
return result.lastInsertRowid;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
export function getScheduledTasks(enabledOnly = true) {
|
|
655
|
+
if (enabledOnly) {
|
|
656
|
+
return getDb()
|
|
657
|
+
.prepare(
|
|
658
|
+
"SELECT * FROM scheduled_tasks WHERE enabled = 1 ORDER BY next_run ASC",
|
|
659
|
+
)
|
|
660
|
+
.all();
|
|
661
|
+
}
|
|
662
|
+
return getDb()
|
|
663
|
+
.prepare("SELECT * FROM scheduled_tasks ORDER BY created_at DESC")
|
|
664
|
+
.all();
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
export function getScheduledTask(id) {
|
|
668
|
+
return getDb().prepare("SELECT * FROM scheduled_tasks WHERE id = ?").get(id);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
export function updateScheduledTaskNextRun(id, nextRun) {
|
|
672
|
+
getDb()
|
|
673
|
+
.prepare("UPDATE scheduled_tasks SET next_run = ? WHERE id = ?")
|
|
674
|
+
.run(nextRun, id);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
export function markScheduledTaskRun(id) {
|
|
678
|
+
getDb()
|
|
679
|
+
.prepare(
|
|
680
|
+
"UPDATE scheduled_tasks SET last_run = CURRENT_TIMESTAMP WHERE id = ?",
|
|
681
|
+
)
|
|
682
|
+
.run(id);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
export function disableScheduledTask(id) {
|
|
686
|
+
getDb()
|
|
687
|
+
.prepare("UPDATE scheduled_tasks SET enabled = 0 WHERE id = ?")
|
|
688
|
+
.run(id);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
export function deleteScheduledTask(id) {
|
|
692
|
+
getDb().prepare("DELETE FROM scheduled_tasks WHERE id = ?").run(id);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// ============================================
|
|
696
|
+
// File Snapshot Operations (for undo on regeneration)
|
|
697
|
+
// ============================================
|
|
698
|
+
|
|
699
|
+
/** Max size (bytes) for original_content in file_snapshots; larger files are not snapshotted (revert will skip them) */
|
|
700
|
+
const MAX_SNAPSHOT_CONTENT_BYTES = 2 * 1024 * 1024; // 2MB
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Save a file snapshot before a tool modifies the file
|
|
704
|
+
* Uses INSERT OR IGNORE to keep only the first (original) snapshot per file per message.
|
|
705
|
+
* Skips saving if originalContent exceeds MAX_SNAPSHOT_CONTENT_BYTES to avoid DB bloat and OOM.
|
|
706
|
+
* @param {number} chatId - The chat ID
|
|
707
|
+
* @param {number} messageIndex - Index of the assistant message containing the tool call
|
|
708
|
+
* @param {string} toolCallId - Unique ID of the tool call
|
|
709
|
+
* @param {string} filePath - Absolute path to the file
|
|
710
|
+
* @param {string|null} originalContent - Content before modification (null if file didn't exist)
|
|
711
|
+
* @param {boolean} fileExisted - Whether the file existed before the tool call
|
|
712
|
+
* @param {string|null} createdDir - If write_file created the parent dir, its absolute path (for revert)
|
|
713
|
+
*/
|
|
714
|
+
export function saveFileSnapshot(
|
|
715
|
+
chatId,
|
|
716
|
+
messageIndex,
|
|
717
|
+
toolCallId,
|
|
718
|
+
filePath,
|
|
719
|
+
originalContent,
|
|
720
|
+
fileExisted,
|
|
721
|
+
createdDir = null,
|
|
722
|
+
) {
|
|
723
|
+
const contentLength =
|
|
724
|
+
originalContent != null
|
|
725
|
+
? typeof originalContent === "string"
|
|
726
|
+
? Buffer.byteLength(originalContent, "utf8")
|
|
727
|
+
: originalContent.length
|
|
728
|
+
: 0;
|
|
729
|
+
if (contentLength > MAX_SNAPSHOT_CONTENT_BYTES) {
|
|
730
|
+
if (process.env.SILENT_MODE !== "true") {
|
|
731
|
+
console.warn(
|
|
732
|
+
"[db] Skipping file snapshot (too large):",
|
|
733
|
+
filePath,
|
|
734
|
+
`${(contentLength / 1024 / 1024).toFixed(2)}MB > ${MAX_SNAPSHOT_CONTENT_BYTES / 1024 / 1024}MB`,
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
// Use INSERT OR IGNORE to keep only the first snapshot per file per message
|
|
740
|
+
// This ensures we capture the ORIGINAL state before any modifications
|
|
741
|
+
getDb()
|
|
742
|
+
.prepare(
|
|
743
|
+
`
|
|
744
|
+
INSERT OR IGNORE INTO file_snapshots (chat_id, message_index, tool_call_id, file_path, original_content, file_existed, created_dir)
|
|
745
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
746
|
+
`,
|
|
747
|
+
)
|
|
748
|
+
.run(
|
|
749
|
+
chatId,
|
|
750
|
+
messageIndex,
|
|
751
|
+
toolCallId,
|
|
752
|
+
filePath,
|
|
753
|
+
originalContent,
|
|
754
|
+
fileExisted ? 1 : 0,
|
|
755
|
+
createdDir,
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Get all snapshots that need to be reverted for a regeneration
|
|
761
|
+
* Returns snapshots where messageIndex >= fromMessageIndex
|
|
762
|
+
* @param {number} chatId - The chat ID
|
|
763
|
+
* @param {number} fromMessageIndex - The message index being regenerated (inclusive)
|
|
764
|
+
* @returns {Array} Snapshots to revert, ordered by message_index DESC (revert newest first)
|
|
765
|
+
*/
|
|
766
|
+
export function getSnapshotsToRevert(chatId, fromMessageIndex) {
|
|
767
|
+
return getDb()
|
|
768
|
+
.prepare(
|
|
769
|
+
`
|
|
770
|
+
SELECT * FROM file_snapshots
|
|
771
|
+
WHERE chat_id = ? AND message_index >= ?
|
|
772
|
+
ORDER BY message_index DESC, id DESC
|
|
773
|
+
`,
|
|
774
|
+
)
|
|
775
|
+
.all(chatId, fromMessageIndex);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Delete snapshots after they've been reverted
|
|
780
|
+
* @param {number} chatId - The chat ID
|
|
781
|
+
* @param {number} fromMessageIndex - Delete snapshots at or after this index
|
|
782
|
+
*/
|
|
783
|
+
export function deleteSnapshots(chatId, fromMessageIndex) {
|
|
784
|
+
getDb()
|
|
785
|
+
.prepare(
|
|
786
|
+
`
|
|
787
|
+
DELETE FROM file_snapshots WHERE chat_id = ? AND message_index >= ?
|
|
788
|
+
`,
|
|
789
|
+
)
|
|
790
|
+
.run(chatId, fromMessageIndex);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Delete all snapshots for a chat (used when chat is deleted)
|
|
795
|
+
* @param {number} chatId - The chat ID
|
|
796
|
+
*/
|
|
797
|
+
export function deleteAllChatSnapshots(chatId) {
|
|
798
|
+
getDb().prepare("DELETE FROM file_snapshots WHERE chat_id = ?").run(chatId);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Get all snapshots for a chat (for debugging/inspection)
|
|
803
|
+
* @param {number} chatId - The chat ID
|
|
804
|
+
* @returns {Array} All snapshots for the chat
|
|
805
|
+
*/
|
|
806
|
+
export function getChatSnapshots(chatId) {
|
|
807
|
+
return getDb()
|
|
808
|
+
.prepare(
|
|
809
|
+
`
|
|
810
|
+
SELECT * FROM file_snapshots WHERE chat_id = ? ORDER BY message_index ASC, id ASC
|
|
811
|
+
`,
|
|
812
|
+
)
|
|
813
|
+
.all(chatId);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// ============================================
|
|
817
|
+
// Shell Undo Log (Strategy 1: undo shell commands on regeneration)
|
|
818
|
+
// ============================================
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Save a reversible shell operation for undo on regeneration
|
|
822
|
+
* @param {number} chatId - The chat ID
|
|
823
|
+
* @param {number} messageIndex - Index of the assistant message
|
|
824
|
+
* @param {string|null} toolCallId - Unique ID of the tool call
|
|
825
|
+
* @param {object} entry - { op, path?, path_src?, path_dest?, cwd }
|
|
826
|
+
*/
|
|
827
|
+
export function saveShellUndo(chatId, messageIndex, toolCallId, entry) {
|
|
828
|
+
getDb()
|
|
829
|
+
.prepare(
|
|
830
|
+
`
|
|
831
|
+
INSERT INTO shell_undo_log (chat_id, message_index, tool_call_id, op, path, path_src, path_dest, cwd)
|
|
832
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
833
|
+
`,
|
|
834
|
+
)
|
|
835
|
+
.run(
|
|
836
|
+
chatId,
|
|
837
|
+
messageIndex,
|
|
838
|
+
toolCallId,
|
|
839
|
+
entry.op,
|
|
840
|
+
entry.path ?? null,
|
|
841
|
+
entry.path_src ?? null,
|
|
842
|
+
entry.path_dest ?? null,
|
|
843
|
+
entry.cwd ?? null,
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Get shell undo entries to revert for a regeneration (newest first for reverse-order revert)
|
|
849
|
+
* @param {number} chatId - The chat ID
|
|
850
|
+
* @param {number} fromMessageIndex - The message index being regenerated (inclusive)
|
|
851
|
+
* @returns {Array} Entries to revert, ordered by message_index DESC, id DESC
|
|
852
|
+
*/
|
|
853
|
+
export function getShellUndosToRevert(chatId, fromMessageIndex) {
|
|
854
|
+
return getDb()
|
|
855
|
+
.prepare(
|
|
856
|
+
`
|
|
857
|
+
SELECT * FROM shell_undo_log
|
|
858
|
+
WHERE chat_id = ? AND message_index >= ?
|
|
859
|
+
ORDER BY message_index DESC, id DESC
|
|
860
|
+
`,
|
|
861
|
+
)
|
|
862
|
+
.all(chatId, fromMessageIndex);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Delete shell undo entries after they've been reverted
|
|
867
|
+
* @param {number} chatId - The chat ID
|
|
868
|
+
* @param {number} fromMessageIndex - Delete entries at or after this index
|
|
869
|
+
*/
|
|
870
|
+
export function deleteShellUndos(chatId, fromMessageIndex) {
|
|
871
|
+
getDb()
|
|
872
|
+
.prepare(
|
|
873
|
+
`
|
|
874
|
+
DELETE FROM shell_undo_log WHERE chat_id = ? AND message_index >= ?
|
|
875
|
+
`,
|
|
876
|
+
)
|
|
877
|
+
.run(chatId, fromMessageIndex);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Delete all shell undo entries for a chat (e.g. when chat is deleted)
|
|
882
|
+
* @param {number} chatId - The chat ID
|
|
883
|
+
*/
|
|
884
|
+
export function deleteAllChatShellUndos(chatId) {
|
|
885
|
+
getDb().prepare("DELETE FROM shell_undo_log WHERE chat_id = ?").run(chatId);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// ============================================
|
|
889
|
+
// RAG Document Operations
|
|
890
|
+
// ============================================
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Create a new RAG document
|
|
894
|
+
* @param {Object} doc - Document data { name, chunkCount, fileCount, files, summary }
|
|
895
|
+
* @returns {number} Document ID
|
|
896
|
+
*/
|
|
897
|
+
export function createRagDocument(doc) {
|
|
898
|
+
const result = getDb()
|
|
899
|
+
.prepare(
|
|
900
|
+
`
|
|
901
|
+
INSERT INTO rag_documents (name, chunk_count, file_count, files, summary, upload_date)
|
|
902
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
903
|
+
`,
|
|
904
|
+
)
|
|
905
|
+
.run(
|
|
906
|
+
doc.name,
|
|
907
|
+
doc.chunkCount || 0,
|
|
908
|
+
doc.fileCount || 1,
|
|
909
|
+
doc.files || null,
|
|
910
|
+
doc.summary || null,
|
|
911
|
+
doc.uploadDate || new Date().toISOString(),
|
|
912
|
+
);
|
|
913
|
+
return result.lastInsertRowid;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Get all RAG documents
|
|
918
|
+
* @returns {Array} All documents
|
|
919
|
+
*/
|
|
920
|
+
export function getAllRagDocuments() {
|
|
921
|
+
return getDb()
|
|
922
|
+
.prepare(
|
|
923
|
+
`
|
|
924
|
+
SELECT id, name, chunk_count as chunkCount, file_count as fileCount,
|
|
925
|
+
files, summary, upload_date as uploadDate, created_at, updated_at
|
|
926
|
+
FROM rag_documents
|
|
927
|
+
ORDER BY created_at DESC
|
|
928
|
+
`,
|
|
929
|
+
)
|
|
930
|
+
.all();
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Get a single RAG document by ID
|
|
935
|
+
* @param {number} id - Document ID
|
|
936
|
+
* @returns {Object|null} Document or null
|
|
937
|
+
*/
|
|
938
|
+
export function getRagDocument(id) {
|
|
939
|
+
return getDb()
|
|
940
|
+
.prepare(
|
|
941
|
+
`
|
|
942
|
+
SELECT id, name, chunk_count as chunkCount, file_count as fileCount,
|
|
943
|
+
files, summary, upload_date as uploadDate, created_at, updated_at
|
|
944
|
+
FROM rag_documents WHERE id = ?
|
|
945
|
+
`,
|
|
946
|
+
)
|
|
947
|
+
.get(id);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Update a RAG document
|
|
952
|
+
* @param {number} id - Document ID
|
|
953
|
+
* @param {Object} updates - Fields to update
|
|
954
|
+
*/
|
|
955
|
+
export function updateRagDocument(id, updates) {
|
|
956
|
+
const fields = [];
|
|
957
|
+
const values = [];
|
|
958
|
+
|
|
959
|
+
if (updates.name !== undefined) {
|
|
960
|
+
fields.push("name = ?");
|
|
961
|
+
values.push(updates.name);
|
|
962
|
+
}
|
|
963
|
+
if (updates.chunkCount !== undefined) {
|
|
964
|
+
fields.push("chunk_count = ?");
|
|
965
|
+
values.push(updates.chunkCount);
|
|
966
|
+
}
|
|
967
|
+
if (updates.summary !== undefined) {
|
|
968
|
+
fields.push("summary = ?");
|
|
969
|
+
values.push(updates.summary);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
if (fields.length === 0) return;
|
|
973
|
+
|
|
974
|
+
fields.push("updated_at = CURRENT_TIMESTAMP");
|
|
975
|
+
values.push(id);
|
|
976
|
+
|
|
977
|
+
getDb()
|
|
978
|
+
.prepare(
|
|
979
|
+
`
|
|
980
|
+
UPDATE rag_documents SET ${fields.join(", ")} WHERE id = ?
|
|
981
|
+
`,
|
|
982
|
+
)
|
|
983
|
+
.run(...values);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Delete a RAG document and its chunks
|
|
988
|
+
* @param {number} id - Document ID
|
|
989
|
+
*/
|
|
990
|
+
export function deleteRagDocument(id) {
|
|
991
|
+
const db = getDb();
|
|
992
|
+
db.prepare("DELETE FROM rag_chunks WHERE doc_id = ?").run(id);
|
|
993
|
+
db.prepare("DELETE FROM rag_documents WHERE id = ?").run(id);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Add chunks for a RAG document
|
|
998
|
+
* @param {number} docId - Document ID
|
|
999
|
+
* @param {Array} chunks - Array of { text, pageNumber, chunkIndex, embedding }
|
|
1000
|
+
*/
|
|
1001
|
+
export function addRagChunks(docId, chunks) {
|
|
1002
|
+
const db = getDb();
|
|
1003
|
+
const insert = db.prepare(`
|
|
1004
|
+
INSERT INTO rag_chunks (doc_id, text, page_number, chunk_index, embedding)
|
|
1005
|
+
VALUES (?, ?, ?, ?, ?)
|
|
1006
|
+
`);
|
|
1007
|
+
|
|
1008
|
+
const insertMany = db.transaction((items) => {
|
|
1009
|
+
for (const chunk of items) {
|
|
1010
|
+
// Convert embedding array to Buffer
|
|
1011
|
+
const embeddingBuffer = Buffer.from(
|
|
1012
|
+
new Float32Array(chunk.embedding).buffer,
|
|
1013
|
+
);
|
|
1014
|
+
insert.run(
|
|
1015
|
+
docId,
|
|
1016
|
+
chunk.text,
|
|
1017
|
+
chunk.pageNumber || null,
|
|
1018
|
+
chunk.chunkIndex,
|
|
1019
|
+
embeddingBuffer,
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
insertMany(chunks);
|
|
1025
|
+
|
|
1026
|
+
// Update document chunk count
|
|
1027
|
+
db.prepare("UPDATE rag_documents SET chunk_count = ? WHERE id = ?").run(
|
|
1028
|
+
chunks.length,
|
|
1029
|
+
docId,
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* Get all chunks for a RAG document
|
|
1035
|
+
* @param {number} docId - Document ID
|
|
1036
|
+
* @returns {Array} Chunks with embeddings converted to arrays
|
|
1037
|
+
*/
|
|
1038
|
+
export function getRagChunksByDocument(docId) {
|
|
1039
|
+
const chunks = getDb()
|
|
1040
|
+
.prepare(
|
|
1041
|
+
`
|
|
1042
|
+
SELECT id, doc_id as docId, text, page_number as pageNumber, chunk_index as chunkIndex, embedding
|
|
1043
|
+
FROM rag_chunks WHERE doc_id = ?
|
|
1044
|
+
ORDER BY chunk_index ASC
|
|
1045
|
+
`,
|
|
1046
|
+
)
|
|
1047
|
+
.all(docId);
|
|
1048
|
+
|
|
1049
|
+
// Convert embedding Buffers back to arrays
|
|
1050
|
+
return chunks.map((chunk) => ({
|
|
1051
|
+
...chunk,
|
|
1052
|
+
embedding: Array.from(
|
|
1053
|
+
new Float32Array(
|
|
1054
|
+
chunk.embedding.buffer,
|
|
1055
|
+
chunk.embedding.byteOffset,
|
|
1056
|
+
chunk.embedding.length / 4,
|
|
1057
|
+
),
|
|
1058
|
+
),
|
|
1059
|
+
}));
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* Get chunks from multiple documents for RAG search
|
|
1064
|
+
* @param {Array<number>} docIds - Array of document IDs
|
|
1065
|
+
* @returns {Array} Chunks with embeddings
|
|
1066
|
+
*/
|
|
1067
|
+
export function getRagChunksByDocuments(docIds) {
|
|
1068
|
+
if (!docIds || docIds.length === 0) return [];
|
|
1069
|
+
|
|
1070
|
+
const placeholders = docIds.map(() => "?").join(",");
|
|
1071
|
+
const chunks = getDb()
|
|
1072
|
+
.prepare(
|
|
1073
|
+
`
|
|
1074
|
+
SELECT id, doc_id as docId, text, page_number as pageNumber, chunk_index as chunkIndex, embedding
|
|
1075
|
+
FROM rag_chunks WHERE doc_id IN (${placeholders})
|
|
1076
|
+
ORDER BY doc_id, chunk_index ASC
|
|
1077
|
+
`,
|
|
1078
|
+
)
|
|
1079
|
+
.all(...docIds);
|
|
1080
|
+
|
|
1081
|
+
// Convert embedding Buffers back to arrays
|
|
1082
|
+
return chunks.map((chunk) => ({
|
|
1083
|
+
...chunk,
|
|
1084
|
+
embedding: Array.from(
|
|
1085
|
+
new Float32Array(
|
|
1086
|
+
chunk.embedding.buffer,
|
|
1087
|
+
chunk.embedding.byteOffset,
|
|
1088
|
+
chunk.embedding.length / 4,
|
|
1089
|
+
),
|
|
1090
|
+
),
|
|
1091
|
+
}));
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
export default {
|
|
1095
|
+
initDb,
|
|
1096
|
+
getDb,
|
|
1097
|
+
closeDb,
|
|
1098
|
+
createChat,
|
|
1099
|
+
getChat,
|
|
1100
|
+
getLocalChatIdByCloudId,
|
|
1101
|
+
setCloudChatId,
|
|
1102
|
+
getAllChats,
|
|
1103
|
+
updateChatTitle,
|
|
1104
|
+
deleteChat,
|
|
1105
|
+
addMessage,
|
|
1106
|
+
getMessages,
|
|
1107
|
+
deleteMessage,
|
|
1108
|
+
searchChatsText,
|
|
1109
|
+
getChatWithMessages,
|
|
1110
|
+
saveEmbedding,
|
|
1111
|
+
getEmbedding,
|
|
1112
|
+
getAllEmbeddings,
|
|
1113
|
+
createScheduledTask,
|
|
1114
|
+
getScheduledTasks,
|
|
1115
|
+
getScheduledTask,
|
|
1116
|
+
updateScheduledTaskNextRun,
|
|
1117
|
+
markScheduledTaskRun,
|
|
1118
|
+
disableScheduledTask,
|
|
1119
|
+
deleteScheduledTask,
|
|
1120
|
+
// File snapshot operations for undo on regeneration
|
|
1121
|
+
saveFileSnapshot,
|
|
1122
|
+
getSnapshotsToRevert,
|
|
1123
|
+
deleteSnapshots,
|
|
1124
|
+
deleteAllChatSnapshots,
|
|
1125
|
+
getChatSnapshots,
|
|
1126
|
+
// RAG document operations
|
|
1127
|
+
createRagDocument,
|
|
1128
|
+
getAllRagDocuments,
|
|
1129
|
+
getRagDocument,
|
|
1130
|
+
updateRagDocument,
|
|
1131
|
+
deleteRagDocument,
|
|
1132
|
+
addRagChunks,
|
|
1133
|
+
getRagChunksByDocument,
|
|
1134
|
+
getRagChunksByDocuments,
|
|
1135
|
+
};
|