mouaif 0.3.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 (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/bin/mouaif.js +281 -0
  4. package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
  5. package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
  6. package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
  7. package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
  8. package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
  9. package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
  10. package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
  11. package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
  12. package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
  13. package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
  14. package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
  15. package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
  16. package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
  17. package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
  18. package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
  19. package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
  20. package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
  21. package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
  22. package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
  23. package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
  24. package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
  25. package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
  26. package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
  27. package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
  28. package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
  29. package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
  30. package/frontend/dist/assets/index-CANPYzQg.css +1 -0
  31. package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
  32. package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
  33. package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
  34. package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
  35. package/frontend/dist/icons/favicon-32.png +0 -0
  36. package/frontend/dist/icons/icon-180-apple.png +0 -0
  37. package/frontend/dist/icons/icon-192.png +0 -0
  38. package/frontend/dist/icons/icon-512.png +0 -0
  39. package/frontend/dist/icons/icon-maskable-512.png +0 -0
  40. package/frontend/dist/index.html +83 -0
  41. package/frontend/dist/manifest.webmanifest +33 -0
  42. package/frontend/dist/sw.js +482 -0
  43. package/package.json +98 -0
  44. package/scripts/patch-zimmerframe.js +58 -0
  45. package/src/access-auth.js +515 -0
  46. package/src/agentFeatures.js +294 -0
  47. package/src/agentFiles.js +164 -0
  48. package/src/agentSkills.js +147 -0
  49. package/src/agents.js +230 -0
  50. package/src/ai-chat.js +21 -0
  51. package/src/ai-endpoints.js +1880 -0
  52. package/src/ai-stream.js +2048 -0
  53. package/src/ai.js +68 -0
  54. package/src/auth.js +391 -0
  55. package/src/chatdb.js +816 -0
  56. package/src/chats.js +275 -0
  57. package/src/custom-actions.js +65 -0
  58. package/src/files.js +431 -0
  59. package/src/hideFileContent.js +327 -0
  60. package/src/http-server.js +535 -0
  61. package/src/index.js +15 -0
  62. package/src/inspector.js +731 -0
  63. package/src/inspectorProfiles.js +503 -0
  64. package/src/live-chat.js +107 -0
  65. package/src/mcp.js +1517 -0
  66. package/src/messages.js +238 -0
  67. package/src/modelList.js +137 -0
  68. package/src/notifications.js +52 -0
  69. package/src/oauth-anthropic.js +280 -0
  70. package/src/oauth-github-copilot.js +417 -0
  71. package/src/oauth-mcp.js +216 -0
  72. package/src/oauth-openrouter.js +285 -0
  73. package/src/package-version.js +20 -0
  74. package/src/projects.js +285 -0
  75. package/src/promptProfiles.js +256 -0
  76. package/src/prompts.js +384 -0
  77. package/src/providerShapes.js +44 -0
  78. package/src/providers/base.js +41 -0
  79. package/src/providers/index.js +25 -0
  80. package/src/push.js +315 -0
  81. package/src/qr.js +192 -0
  82. package/src/restart.js +47 -0
  83. package/src/server-handlers-access.js +306 -0
  84. package/src/server-handlers-actions.js +100 -0
  85. package/src/server-handlers-ai.js +248 -0
  86. package/src/server-handlers-auth.js +273 -0
  87. package/src/server-handlers-chats.js +1436 -0
  88. package/src/server-handlers-git.js +467 -0
  89. package/src/server-handlers-mcp-oauth.js +56 -0
  90. package/src/server-handlers-misc.js +783 -0
  91. package/src/server-handlers-projects.js +289 -0
  92. package/src/server-handlers-prompts.js +259 -0
  93. package/src/server-handlers-push.js +102 -0
  94. package/src/server-handlers-settings.js +406 -0
  95. package/src/server-handlers-tools.js +654 -0
  96. package/src/server-handlers-transcribe.js +399 -0
  97. package/src/server-shared.js +780 -0
  98. package/src/server-web-static.js +191 -0
  99. package/src/settings.js +898 -0
  100. package/src/statusBar.js +541 -0
  101. package/src/tags.js +414 -0
  102. package/src/toolFeedback.js +225 -0
  103. package/src/tools/ask.js +154 -0
  104. package/src/tools/authorization.js +932 -0
  105. package/src/tools/files.js +1150 -0
  106. package/src/tools/progress.js +71 -0
  107. package/src/tools/restart.js +32 -0
  108. package/src/tools/searchEngine.js +957 -0
  109. package/src/tools/shell.js +341 -0
  110. package/src/tools/subagent.js +47 -0
  111. package/src/tools/task.js +234 -0
  112. package/src/tools/webpreview.js +448 -0
  113. package/src/trace.js +103 -0
  114. package/src/transcribe.js +683 -0
  115. package/src/usage.js +389 -0
  116. package/src/util.js +151 -0
package/src/chatdb.js ADDED
@@ -0,0 +1,816 @@
1
+ 'use strict';
2
+
3
+ // Chat DB — SQLite-backed chat and message storage.
4
+ //
5
+ // Implements the "store chats in DB by default" design:
6
+ // - chat metadata in `chat_store` table
7
+ // - messages in `message_store` table
8
+ // - both in the same ~/.mouaif/store.sqlite used by settings.js
9
+ //
10
+ // This module exposes the DB helpers. The public API surface remains
11
+ // src/chats.js and src/messages.js, which route through this module.
12
+ // It is the only chat/message storage backend.
13
+
14
+ const path = require('path');
15
+ const { assertChatId, CHAT_ID_RE, normalizeMessage } = require('./messages.js');
16
+
17
+ // ---- Schema DDL – created lazily via IF NOT EXISTS ------------------------
18
+
19
+ const CREATE_CHAT_TABLE = `
20
+ CREATE TABLE IF NOT EXISTS chat_store (
21
+ project_dir TEXT NOT NULL,
22
+ id TEXT NOT NULL,
23
+ title TEXT NOT NULL DEFAULT 'New chat',
24
+ created_at TEXT NOT NULL,
25
+ last_opened_at TEXT,
26
+ trace INTEGER NOT NULL DEFAULT 0,
27
+ prompt_size TEXT NOT NULL DEFAULT 'average',
28
+ prompt_id TEXT,
29
+ provider_id TEXT,
30
+ model_id TEXT,
31
+ thinking_level TEXT DEFAULT '',
32
+ max_output_tokens TEXT DEFAULT '',
33
+ draft TEXT NOT NULL DEFAULT '',
34
+ draft_attachments TEXT,
35
+ tools TEXT,
36
+ agent_id TEXT,
37
+ agent_files INTEGER,
38
+ skills INTEGER,
39
+ disabled_skills TEXT,
40
+ tool_auth TEXT,
41
+ auto_retry INTEGER NOT NULL DEFAULT 1,
42
+ total_cost REAL NOT NULL DEFAULT 0,
43
+ cost_known_count INTEGER NOT NULL DEFAULT 0,
44
+ PRIMARY KEY (project_dir, id)
45
+ )
46
+ `;
47
+
48
+ const CREATE_MESSAGE_TABLE = `
49
+ CREATE TABLE IF NOT EXISTS message_store (
50
+ project_dir TEXT NOT NULL,
51
+ chat_id TEXT NOT NULL,
52
+ seq INTEGER NOT NULL,
53
+ role TEXT NOT NULL,
54
+ content TEXT NOT NULL,
55
+ ts TEXT NOT NULL,
56
+ reasoning TEXT,
57
+ usage TEXT,
58
+ cost TEXT,
59
+ streaming_ms INTEGER,
60
+ model_id TEXT,
61
+ attachments TEXT,
62
+ tool_call_id TEXT,
63
+ name TEXT,
64
+ args TEXT,
65
+ ok INTEGER,
66
+ phase TEXT,
67
+ PRIMARY KEY (project_dir, chat_id, seq)
68
+ )
69
+ `;
70
+
71
+ // ---- Lazy DB init (reuses settings.js connection) -------------------------
72
+
73
+ const INDEX_SQL = `
74
+ CREATE INDEX IF NOT EXISTS idx_chat_store_recent
75
+ ON chat_store (project_dir, COALESCE(last_opened_at, created_at) DESC, id DESC);
76
+ CREATE INDEX IF NOT EXISTS idx_message_store_lookup
77
+ ON message_store (project_dir, chat_id, seq);
78
+ CREATE INDEX IF NOT EXISTS idx_message_store_cost
79
+ ON message_store (project_dir, chat_id)
80
+ WHERE role = 'assistant' AND cost IS NOT NULL
81
+ `;
82
+
83
+ function ensureTables() {
84
+ const settings = require('./settings.js');
85
+ const d = settings.getDb();
86
+ d.exec(CREATE_CHAT_TABLE);
87
+ d.exec(CREATE_MESSAGE_TABLE);
88
+ const chatColumns = d.prepare("PRAGMA table_info('chat_store')").all();
89
+ if (!chatColumns.some((column) => column.name === 'total_cost')) {
90
+ d.exec('ALTER TABLE chat_store ADD COLUMN total_cost REAL NOT NULL DEFAULT 0');
91
+ }
92
+ if (!chatColumns.some((column) => column.name === 'cost_known_count')) {
93
+ d.exec('ALTER TABLE chat_store ADD COLUMN cost_known_count INTEGER NOT NULL DEFAULT 0');
94
+ }
95
+ if (!chatColumns.some((column) => column.name === 'auto_retry')) {
96
+ d.exec('ALTER TABLE chat_store ADD COLUMN auto_retry INTEGER NOT NULL DEFAULT 1');
97
+ }
98
+ // Per-chat skill opt-outs (JSON array of skill ids). NULL means "no
99
+ // per-skill opt-outs", which is what every chat written before this
100
+ // column existed has.
101
+ if (!chatColumns.some((column) => column.name === 'disabled_skills')) {
102
+ d.exec('ALTER TABLE chat_store ADD COLUMN disabled_skills TEXT');
103
+ }
104
+ // Per-chat tool authorization overrides (decisions §17): the Off / Ask /
105
+ // Allow choice made in the chat's own Tools card and composer tool popup.
106
+ // NULL means "inherit the project / app value", which is what every chat
107
+ // written before this column existed has and what a chat goes back to
108
+ // when the user has not touched a tool's mode in this chat.
109
+ if (!chatColumns.some((column) => column.name === 'tool_auth')) {
110
+ d.exec('ALTER TABLE chat_store ADD COLUMN tool_auth TEXT');
111
+ }
112
+ d.exec(INDEX_SQL);
113
+ }
114
+
115
+ // ---- Row <-> object mappers -----------------------------------------------
116
+
117
+ function rowToChat(row) {
118
+ if (!row) return null;
119
+ const chat = {
120
+ id: row.id,
121
+ title: row.title,
122
+ createdAt: row.created_at,
123
+ lastOpenedAt: row.last_opened_at || null,
124
+ trace: row.trace === 1,
125
+ promptSize: row.prompt_size,
126
+ promptId: row.prompt_id || null,
127
+ providerId: row.provider_id || null,
128
+ modelId: row.model_id || null,
129
+ thinkingLevel: row.thinking_level || '',
130
+ maxOutputTokens: row.max_output_tokens || '',
131
+ draft: row.draft || '',
132
+ agentFiles: row.agent_files === null ? undefined : (row.agent_files === 1),
133
+ skills: row.skills === null ? undefined : (row.skills === 1),
134
+ // disabledSkills: NULL -> undefined (no per-chat opt-outs); otherwise the
135
+ // JSON array of skill ids this chat switched off one by one. A chat can
136
+ // carry this list while the family flag above stays unset/true — that is
137
+ // what a per-skill switch means.
138
+ autoRetry: row.auto_retry !== 0,
139
+ totalCost: {
140
+ total: typeof row.total_cost === 'number' ? row.total_cost : 0,
141
+ known: typeof row.cost_known_count === 'number' && row.cost_known_count > 0,
142
+ currency: 'USD',
143
+ knownCount: typeof row.cost_known_count === 'number' ? row.cost_known_count : 0
144
+ }
145
+ };
146
+ // draftAttachments: null/'' -> undefined (no image draft); otherwise the
147
+ // JSON array of pending image attachments for the composer.
148
+ if (row.draft_attachments) {
149
+ try {
150
+ const parsed = JSON.parse(row.draft_attachments);
151
+ if (Array.isArray(parsed)) chat.draftAttachments = parsed;
152
+ } catch { /* keep undefined */ }
153
+ }
154
+ // tools: null/undefined -> all tools; [] -> advertise none; [names] -> filter
155
+ if (row.tools !== null) {
156
+ try { chat.tools = JSON.parse(row.tools); } catch { /* keep undefined */ }
157
+ }
158
+ if (row.disabled_skills) {
159
+ try {
160
+ const parsed = JSON.parse(row.disabled_skills);
161
+ if (Array.isArray(parsed) && parsed.length) {
162
+ chat.disabledSkills = parsed.map((n) => String(n)).filter(Boolean);
163
+ }
164
+ } catch { /* keep undefined */ }
165
+ }
166
+ // toolAuth: this chat's own Off/Ask/Allow overrides, keyed by tool name
167
+ // (`shell`, `file`, …) for native tools and by full composed name
168
+ // (`mcp__<slug>__<tool>`) for MCP ones. NULL/{} -> undefined, meaning
169
+ // every tool inherits the project / app gate. See decisions §17.
170
+ if (row.tool_auth) {
171
+ try {
172
+ const parsed = JSON.parse(row.tool_auth);
173
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && Object.keys(parsed).length) {
174
+ chat.toolAuth = parsed;
175
+ }
176
+ } catch { /* keep undefined */ }
177
+ }
178
+ // Drop undefined tools so the caller can distinguish "not set" from "empty array"
179
+ if (chat.tools === undefined) delete chat.tools;
180
+ return chat;
181
+ }
182
+
183
+ // ---- Chat list projection -------------------------------------------------
184
+ //
185
+ // `chat_store` has two unbounded TEXT columns: `draft` (whatever the composer
186
+ // holds) and `draft_attachments` (the pending image draft — up to 8 base64
187
+ // data URLs, each bounded at 12 MB by frontend/src/components/chat/annotation.js).
188
+ // A `SELECT *` list therefore reads those bodies out of SQLite, JSON-parses
189
+ // them, and ships them for every row of the page, although the chat list only
190
+ // needs a one-line preview of the draft. With an image in a draft that is
191
+ // megabytes per row — the difference between an instant Chats tab and a
192
+ // multi-second one.
193
+ //
194
+ // The list query therefore never touches the attachment body. It reads:
195
+ // - `draft_snippet` — a bounded head of the text draft, for the card preview;
196
+ // - `has_draft_image` — whether `draft_attachments` is non-NULL. SQLite
197
+ // answers `IS NOT NULL` from the record header (OPFLAG_TYPEOFARG), so the
198
+ // JSON body is never materialized.
199
+ // The full `draft` / `draftAttachments` pair still comes back from getChat() /
200
+ // GET /api/chats/:id, which is what the composer restores from.
201
+ const DRAFT_SNIPPET_CHARS = 400;
202
+
203
+ const LIST_COLUMNS = `
204
+ project_dir, id, title, created_at, last_opened_at, trace, prompt_size,
205
+ prompt_id, provider_id, model_id, thinking_level, max_output_tokens,
206
+ substr(draft, 1, ${DRAFT_SNIPPET_CHARS}) AS draft_snippet,
207
+ (draft_attachments IS NOT NULL) AS has_draft_image,
208
+ tools, agent_id, agent_files, skills, auto_retry,
209
+ total_cost, cost_known_count
210
+ `;
211
+
212
+ // rowToChatSummary(row) -> chat list entry.
213
+ //
214
+ // Same shape as rowToChat() minus the two unbounded columns: `draft` is
215
+ // replaced by `draftSnippet` and `hasDraftImage` reports whether an image
216
+ // draft exists without transferring it. Anything that needs the whole draft
217
+ // must call getChat().
218
+ function rowToChatSummary(row) {
219
+ if (!row) return null;
220
+ const chat = rowToChat(Object.assign({}, row, { draft: '', draft_attachments: null }));
221
+ delete chat.draft;
222
+ chat.draftSnippet = typeof row.draft_snippet === 'string' ? row.draft_snippet : '';
223
+ chat.hasDraftImage = row.has_draft_image === 1;
224
+ return chat;
225
+ }
226
+
227
+ function chatToRow(projectDir, chat) {
228
+ return {
229
+ project_dir: projectDir,
230
+ id: chat.id,
231
+ title: chat.title || 'New chat',
232
+ created_at: chat.createdAt || new Date().toISOString(),
233
+ last_opened_at: chat.lastOpenedAt || null,
234
+ trace: chat.trace ? 1 : 0,
235
+ prompt_size: chat.promptSize || 'average',
236
+ prompt_id: chat.promptId || null,
237
+ provider_id: chat.providerId || null,
238
+ model_id: chat.modelId || null,
239
+ thinking_level: chat.thinkingLevel || '',
240
+ max_output_tokens: chat.maxOutputTokens || '',
241
+ draft: chat.draft || '',
242
+ draft_attachments: (chat.draftAttachments === undefined || chat.draftAttachments === null) ? null : JSON.stringify(chat.draftAttachments),
243
+ tools: chat.tools === undefined ? null : JSON.stringify(chat.tools),
244
+ // agent_id is a legacy column from the removed chat-persona design.
245
+ // It stays in the schema for old DBs but is always written as null.
246
+ agent_id: null,
247
+ agent_files: chat.agentFiles === undefined ? null : (chat.agentFiles ? 1 : 0),
248
+ skills: chat.skills === undefined ? null : (chat.skills ? 1 : 0),
249
+ // An empty list is written as NULL so "no per-skill opt-outs" has a
250
+ // single representation in the store instead of two.
251
+ disabled_skills: (Array.isArray(chat.disabledSkills) && chat.disabledSkills.length)
252
+ ? JSON.stringify(chat.disabledSkills.map((n) => String(n)).filter(Boolean))
253
+ : null,
254
+ // Same single-representation rule as disabled_skills: "no per-chat
255
+ // overrides" is NULL, never "{}".
256
+ tool_auth: (chat.toolAuth && typeof chat.toolAuth === 'object' && !Array.isArray(chat.toolAuth) && Object.keys(chat.toolAuth).length)
257
+ ? JSON.stringify(chat.toolAuth)
258
+ : null,
259
+ auto_retry: chat.autoRetry === undefined ? 1 : (chat.autoRetry ? 1 : 0),
260
+ total_cost: chat.totalCost && typeof chat.totalCost.total === 'number' ? chat.totalCost.total : 0,
261
+ cost_known_count: chat.totalCost && chat.totalCost.known
262
+ ? (Number.isInteger(chat.totalCost.knownCount) ? chat.totalCost.knownCount : 1)
263
+ : 0
264
+ };
265
+ }
266
+
267
+ function rowToMessage(row) {
268
+ if (!row) return null;
269
+ // seq is the row's stable, monotonically increasing position within
270
+ // this (project_dir, chat_id). It is the ONLY identity the client
271
+ // can reliably dedup on: the SQLite backend assigns it at insert
272
+ // (append-only), so a row kept its seq forever. The client merges
273
+ // by seq and never re-adds a row it already holds.
274
+ const msg = { role: row.role, content: row.content, ts: row.ts, seq: row.seq };
275
+ if (row.role === 'user' && row.attachments) {
276
+ try { msg.attachments = JSON.parse(row.attachments); } catch { /* ignore */ }
277
+ }
278
+ if (row.role === 'assistant') {
279
+ if (row.reasoning) msg.reasoning = row.reasoning;
280
+ if (row.usage) try { msg.usage = JSON.parse(row.usage); } catch { /* ignore */ }
281
+ if (row.cost) try { msg.cost = JSON.parse(row.cost); } catch { /* ignore */ }
282
+ if (row.streaming_ms != null) msg.streamingMs = row.streaming_ms;
283
+ if (row.model_id) msg.modelId = row.model_id;
284
+ }
285
+ if (row.role === 'tool') {
286
+ if (row.tool_call_id) msg.toolCallId = row.tool_call_id;
287
+ if (row.name) msg.name = row.name;
288
+ if (row.args) try { msg.args = JSON.parse(row.args); } catch { /* ignore */ }
289
+ if (row.ok != null) msg.ok = row.ok === 1;
290
+ if (row.phase) msg.phase = row.phase;
291
+ }
292
+ return msg;
293
+ }
294
+
295
+ function messageToRow(projectDir, chatId, seq, msg) {
296
+ return {
297
+ project_dir: projectDir,
298
+ chat_id: chatId,
299
+ seq,
300
+ role: msg.role,
301
+ content: msg.content,
302
+ ts: msg.ts || new Date().toISOString(),
303
+ reasoning: msg.role === 'assistant' ? (msg.reasoning || null) : null,
304
+ usage: msg.role === 'assistant' && msg.usage ? JSON.stringify(msg.usage) : null,
305
+ cost: msg.role === 'assistant' && msg.cost ? JSON.stringify(msg.cost) : null,
306
+ streaming_ms: msg.role === 'assistant' && msg.streamingMs != null ? msg.streamingMs : null,
307
+ model_id: msg.role === 'assistant' && msg.modelId ? msg.modelId : null,
308
+ attachments: msg.role === 'user' && msg.attachments ? JSON.stringify(msg.attachments) : null,
309
+ tool_call_id: msg.role === 'tool' && msg.toolCallId ? msg.toolCallId : null,
310
+ name: msg.role === 'tool' && msg.name ? msg.name : null,
311
+ args: msg.role === 'tool' && msg.args ? JSON.stringify(msg.args) : null,
312
+ ok: msg.role === 'tool' && msg.ok != null ? (msg.ok ? 1 : 0) : null,
313
+ phase: msg.role === 'tool' && msg.phase ? msg.phase : null
314
+ };
315
+ }
316
+
317
+ // ---- Chat CRUD ------------------------------------------------------------
318
+
319
+ function listChats(projectDir, options = {}) {
320
+ ensureTables();
321
+ const d = require('./settings.js').getDb();
322
+ const offset = Number.isInteger(options.offset) && options.offset > 0 ? options.offset : 0;
323
+ const limit = Number.isInteger(options.limit) && options.limit > 0 ? options.limit : 0;
324
+ const order = 'ORDER BY COALESCE(last_opened_at, created_at) DESC, id DESC';
325
+ // List rows are summaries, never the draft bodies: see LIST_COLUMNS.
326
+ const rows = limit > 0
327
+ ? d.prepare(`SELECT ${LIST_COLUMNS} FROM chat_store WHERE project_dir = ? ${order} LIMIT ? OFFSET ?`)
328
+ .all(projectDir, limit, offset)
329
+ : d.prepare(`SELECT ${LIST_COLUMNS} FROM chat_store WHERE project_dir = ? ${order}`).all(projectDir);
330
+ return rows.map(rowToChatSummary);
331
+ }
332
+
333
+ function countChats(projectDir) {
334
+ ensureTables();
335
+ const d = require('./settings.js').getDb();
336
+ const row = d.prepare('SELECT COUNT(*) AS total FROM chat_store WHERE project_dir = ?').get(projectDir);
337
+ return row ? row.total : 0;
338
+ }
339
+
340
+ function getChat(projectDir, chatId) {
341
+ ensureTables();
342
+ const d = require('./settings.js').getDb();
343
+ const row = d.prepare(
344
+ 'SELECT * FROM chat_store WHERE project_dir = ? AND id = ?'
345
+ ).get(projectDir, chatId);
346
+ return rowToChat(row);
347
+ }
348
+
349
+ function createChat(projectDir, chat) {
350
+ ensureTables();
351
+ const d = require('./settings.js').getDb();
352
+ const row = chatToRow(projectDir, chat);
353
+ d.prepare(`
354
+ INSERT INTO chat_store (project_dir, id, title, created_at, last_opened_at,
355
+ trace, prompt_size, prompt_id, provider_id, model_id, thinking_level, max_output_tokens, draft, draft_attachments, tools,
356
+ agent_id, agent_files, skills, disabled_skills, tool_auth, auto_retry, total_cost, cost_known_count)
357
+ VALUES (@project_dir, @id, @title, @created_at, @last_opened_at,
358
+ @trace, @prompt_size, @prompt_id, @provider_id, @model_id, @thinking_level, @max_output_tokens, @draft, @draft_attachments, @tools,
359
+ @agent_id, @agent_files, @skills, @disabled_skills, @tool_auth, @auto_retry, @total_cost, @cost_known_count)
360
+ `).run(row);
361
+ return rowToChat(d.prepare(
362
+ 'SELECT * FROM chat_store WHERE project_dir = ? AND id = ?'
363
+ ).get(projectDir, chat.id));
364
+ }
365
+
366
+ function updateChat(projectDir, chatId, patch) {
367
+ ensureTables();
368
+ const d = require('./settings.js').getDb();
369
+ const existing = d.prepare(
370
+ 'SELECT * FROM chat_store WHERE project_dir = ? AND id = ?'
371
+ ).get(projectDir, chatId);
372
+ if (!existing) return null;
373
+
374
+ const merged = Object.assign({}, rowToChat(existing), patch);
375
+ const row = chatToRow(projectDir, merged);
376
+ d.prepare(`
377
+ UPDATE chat_store SET
378
+ title = @title, last_opened_at = @last_opened_at,
379
+ trace = @trace, prompt_size = @prompt_size,
380
+ prompt_id = @prompt_id, provider_id = @provider_id,
381
+ model_id = @model_id, thinking_level = @thinking_level,
382
+ max_output_tokens = @max_output_tokens,
383
+ draft = @draft, draft_attachments = @draft_attachments, tools = @tools,
384
+ agent_id = @agent_id, agent_files = @agent_files,
385
+ skills = @skills, disabled_skills = @disabled_skills,
386
+ tool_auth = @tool_auth,
387
+ auto_retry = @auto_retry, total_cost = @total_cost,
388
+ cost_known_count = @cost_known_count
389
+ WHERE project_dir = @project_dir AND id = @id
390
+ `).run(row);
391
+ return rowToChat(d.prepare(
392
+ 'SELECT * FROM chat_store WHERE project_dir = ? AND id = ?'
393
+ ).get(projectDir, chatId));
394
+ }
395
+
396
+ function deleteChat(projectDir, chatId) {
397
+ ensureTables();
398
+ const d = require('./settings.js').getDb();
399
+ const previous = readChatCostRow(d, projectDir, chatId);
400
+ const tx = d.transaction(() => {
401
+ const info = d.prepare(
402
+ 'DELETE FROM chat_store WHERE project_dir = ? AND id = ?'
403
+ ).run(projectDir, chatId);
404
+ d.prepare(
405
+ 'DELETE FROM message_store WHERE project_dir = ? AND chat_id = ?'
406
+ ).run(projectDir, chatId);
407
+ if (info.changes > 0 && (previous.total || previous.knownCount)) {
408
+ require('./projects.js').adjustProjectTotalCost(projectDir, -previous.total, -previous.knownCount);
409
+ }
410
+ return info.changes > 0;
411
+ });
412
+ return tx();
413
+ }
414
+
415
+ // ---- Message CRUD ---------------------------------------------------------
416
+
417
+ function knownCostDelta(msg) {
418
+ if (!msg || msg.role !== 'assistant' || !msg.cost || msg.cost.known !== true) return null;
419
+ const total = msg.cost.total;
420
+ if (typeof total !== 'number' || !Number.isFinite(total) || total < 0) return null;
421
+ return total;
422
+ }
423
+
424
+ function readChatCostRow(d, projectDir, chatId) {
425
+ const row = d.prepare(
426
+ 'SELECT total_cost, cost_known_count FROM chat_store WHERE project_dir = ? AND id = ?'
427
+ ).get(projectDir, chatId);
428
+ return {
429
+ total: row && typeof row.total_cost === 'number' ? row.total_cost : 0,
430
+ knownCount: row && typeof row.cost_known_count === 'number' ? row.cost_known_count : 0
431
+ };
432
+ }
433
+
434
+ function listMessages(projectDir, chatId) {
435
+ ensureTables();
436
+ const d = require('./settings.js').getDb();
437
+ const rows = d.prepare(
438
+ 'SELECT * FROM message_store WHERE project_dir = ? AND chat_id = ? ORDER BY seq ASC'
439
+ ).all(projectDir, chatId);
440
+ return rows.map(rowToMessage);
441
+ }
442
+ // listMessagesWindow(projectDir, chatId, opts) -> Array<Message>
443
+ //
444
+ // Windowed backward fetch for chat pagination. Returns the `limit`
445
+ // messages at or just BELOW `beforeSeq` in chronological order. Used
446
+ // by the transcript's scroll-up loader so opening a long chat only
447
+ // transfers the newest page; older pages are fetched on demand as the
448
+ // user scrolls up. The `seq` is the stable, monotonic per-chat row id —
449
+ // the same identity the tail sync uses — so the window and the
450
+ // append-only tail never disagree.
451
+ //
452
+ // opts = { limit, beforeSeq } (limit defaults to 100; beforeSeq is the
453
+ // exclusive upper bound, defaulting to "everything")
454
+ function listMessagesWindow(projectDir, chatId, opts) {
455
+ ensureTables();
456
+ const d = require('./settings.js').getDb();
457
+ const limitRaw = Number.isInteger(opts && opts.limit) && (opts.limit > 0) ? opts.limit : 100;
458
+ const beforeSeq = Number.isInteger(opts && opts.beforeSeq) && opts.beforeSeq >= 0 ? opts.beforeSeq : Infinity;
459
+ // Fetch `limit` rows strictly below beforeSeq, ordered newest-last in
460
+ // SQL then reversed so the returned array runs oldest -> newest within
461
+ // the window. Without a beforeSeq (the first page) we want the newest
462
+ // rows overall: ORDER BY seq DESC LIMIT n.
463
+ const rows = d.prepare(
464
+ 'SELECT * FROM message_store WHERE project_dir = ? AND chat_id = ? AND seq < ? ORDER BY seq DESC LIMIT ?'
465
+ ).all(projectDir, chatId, beforeSeq, limitRaw);
466
+ rows.reverse();
467
+ return rows.map(rowToMessage);
468
+ }
469
+
470
+ function appendMessage(projectDir, chatId, msg) {
471
+ ensureTables();
472
+ const d = require('./settings.js').getDb();
473
+ const maxSeq = d.prepare(
474
+ 'SELECT COALESCE(MAX(seq), -1) AS max_seq FROM message_store WHERE project_dir = ? AND chat_id = ?'
475
+ ).get(projectDir, chatId);
476
+ const seq = (maxSeq ? maxSeq.max_seq : -1) + 1;
477
+ const row = messageToRow(projectDir, chatId, seq, msg);
478
+ const costDelta = knownCostDelta(msg);
479
+ const tx = d.transaction(() => {
480
+ d.prepare(`
481
+ INSERT INTO message_store (project_dir, chat_id, seq, role, content, ts,
482
+ reasoning, usage, cost, streaming_ms, model_id, attachments,
483
+ tool_call_id, name, args, ok, phase)
484
+ VALUES (@project_dir, @chat_id, @seq, @role, @content, @ts,
485
+ @reasoning, @usage, @cost, @streaming_ms, @model_id, @attachments,
486
+ @tool_call_id, @name, @args, @ok, @phase)
487
+ `).run(row);
488
+ if (costDelta !== null) {
489
+ const updated = d.prepare(`
490
+ UPDATE chat_store
491
+ SET total_cost = total_cost + ?, cost_known_count = cost_known_count + 1
492
+ WHERE project_dir = ? AND id = ?
493
+ `).run(costDelta, projectDir, chatId);
494
+ if (updated.changes !== 1) throw new Error('Chat not found while adding message cost');
495
+ require('./projects.js').adjustProjectTotalCost(projectDir, costDelta, 1);
496
+ }
497
+ });
498
+ tx();
499
+ return rowToMessage(row);
500
+ }
501
+
502
+ function replaceMessages(projectDir, chatId, list) {
503
+ ensureTables();
504
+ const d = require('./settings.js').getDb();
505
+ const previous = readChatCostRow(d, projectDir, chatId);
506
+ let nextTotal = 0;
507
+ let nextKnownCount = 0;
508
+ for (const msg of list) {
509
+ const cost = knownCostDelta(msg);
510
+ if (cost !== null) {
511
+ nextTotal += cost;
512
+ nextKnownCount++;
513
+ }
514
+ }
515
+ const totalDelta = nextTotal - previous.total;
516
+ const knownCountDelta = nextKnownCount - previous.knownCount;
517
+ const tx = d.transaction(() => {
518
+ d.prepare('DELETE FROM message_store WHERE project_dir = ? AND chat_id = ?').run(projectDir, chatId);
519
+ const insert = d.prepare(`
520
+ INSERT INTO message_store (project_dir, chat_id, seq, role, content, ts,
521
+ reasoning, usage, cost, streaming_ms, model_id, attachments,
522
+ tool_call_id, name, args, ok, phase)
523
+ VALUES (@project_dir, @chat_id, @seq, @role, @content, @ts,
524
+ @reasoning, @usage, @cost, @streaming_ms, @model_id, @attachments,
525
+ @tool_call_id, @name, @args, @ok, @phase)
526
+ `);
527
+ for (let i = 0; i < list.length; i++) {
528
+ insert.run(messageToRow(projectDir, chatId, i, list[i]));
529
+ }
530
+ d.prepare(`
531
+ UPDATE chat_store SET total_cost = ?, cost_known_count = ?
532
+ WHERE project_dir = ? AND id = ?
533
+ `).run(nextTotal, nextKnownCount, projectDir, chatId);
534
+ if (totalDelta || knownCountDelta) {
535
+ require('./projects.js').adjustProjectTotalCost(projectDir, totalDelta, knownCountDelta);
536
+ }
537
+ });
538
+ tx();
539
+ return list.map((m, i) => rowToMessage(
540
+ d.prepare('SELECT * FROM message_store WHERE project_dir = ? AND chat_id = ? AND seq = ?').get(projectDir, chatId, i)
541
+ ));
542
+ }
543
+
544
+ function clearMessages(projectDir, chatId) {
545
+ ensureTables();
546
+ const d = require('./settings.js').getDb();
547
+ const previous = readChatCostRow(d, projectDir, chatId);
548
+ const tx = d.transaction(() => {
549
+ const info = d.prepare(
550
+ 'DELETE FROM message_store WHERE project_dir = ? AND chat_id = ?'
551
+ ).run(projectDir, chatId);
552
+ d.prepare(`
553
+ UPDATE chat_store SET total_cost = 0, cost_known_count = 0
554
+ WHERE project_dir = ? AND id = ?
555
+ `).run(projectDir, chatId);
556
+ if (previous.total || previous.knownCount) {
557
+ require('./projects.js').adjustProjectTotalCost(projectDir, -previous.total, -previous.knownCount);
558
+ }
559
+ return info.changes;
560
+ });
561
+ return tx();
562
+ }
563
+
564
+ function getMessageCount(projectDir, chatId) {
565
+ ensureTables();
566
+ const d = require('./settings.js').getDb();
567
+ const row = d.prepare(
568
+ 'SELECT COUNT(*) AS count FROM message_store WHERE project_dir = ? AND chat_id = ?'
569
+ ).get(projectDir, chatId);
570
+ return row ? row.count : 0;
571
+ }
572
+ // projectMessageCounts(projectDir, chatIds) -> { [chatId]: count }
573
+ //
574
+ // Bulk row count for a set of chats in one indexed GROUP BY (the chat list
575
+ // renders 30–100 rows at a time, so a per-chat COUNT would be too many
576
+ // queries). The count is what the project card uses to flag "draft-only"
577
+ // chats — those with zero persisted messages. Rides the
578
+ // (project_dir, chat_id, seq) index, so it never scans a transcript.
579
+ function projectMessageCounts(projectDir, chatIds) {
580
+ ensureTables();
581
+ const d = require('./settings.js').getDb();
582
+ const ids = Array.isArray(chatIds)
583
+ ? [...new Set(chatIds.filter((id) => typeof id === 'string' && id))]
584
+ : null;
585
+ if (ids && ids.length === 0) return {};
586
+ const idFilter = ids ? ' AND chat_id IN (' + ids.map(() => '?').join(',') + ')' : '';
587
+ const rows = d.prepare(
588
+ 'SELECT chat_id, COUNT(*) AS count FROM message_store WHERE project_dir = ?' + idFilter + ' GROUP BY chat_id'
589
+ ).all(projectDir, ...(ids || []));
590
+ const out = {};
591
+ for (const r of rows) out[r.chat_id] = r.count;
592
+ return out;
593
+ }
594
+
595
+ // messageCursorDb(projectDir, chatId) -> { nextSeq }
596
+ //
597
+ // Cheap append-only recovery cursor: the next persisted seq for this
598
+ // chat. Derive it from MAX(seq)+1 rather than COUNT(*) so the cursor
599
+ // stays monotonic even if seq ever becomes non-contiguous (e.g. a
600
+ // future delete/reinsert path); the client treats `nextSeq` as a seq
601
+ // cursor, not a row count.
602
+ function messageCursorDb(projectDir, chatId) {
603
+ ensureTables();
604
+ const d = require('./settings.js').getDb();
605
+ const row = d.prepare(
606
+ 'SELECT COALESCE(MAX(seq), -1) + 1 AS nextSeq FROM message_store WHERE project_dir = ? AND chat_id = ?'
607
+ ).get(projectDir, chatId);
608
+ return { nextSeq: row && typeof row.nextSeq === 'number' ? row.nextSeq : 0 };
609
+ }
610
+
611
+ // ---- Cost aggregation (SQL, no full-transcript reads) ---------------------
612
+
613
+ // One aggregate row per selected chat (or every project chat when no IDs
614
+ // are supplied): the known assistant-turn cost total and whether any known
615
+ // cost exists. Matches the JS loop in chats.chatTotalCost exactly
616
+ // (cost.known === true && total >= 0), but uses one indexed aggregate instead
617
+ // of one full listMessages call per chat.
618
+ // json_extract is a SQLite JSON1 builtin — present in every Node.js
619
+ // better-sqlite3 build (verified).
620
+ function projectCostTotals(projectDir, chatIds) {
621
+ ensureTables();
622
+ const d = require('./settings.js').getDb();
623
+ const ids = Array.isArray(chatIds)
624
+ ? [...new Set(chatIds.filter((id) => typeof id === 'string' && id))]
625
+ : null;
626
+ if (ids && ids.length === 0) return {};
627
+ const idFilter = ids ? ` AND chat_id IN (${ids.map(() => '?').join(',')})` : '';
628
+ const rows = d.prepare(`
629
+ SELECT chat_id,
630
+ SUM(CASE WHEN json_extract(cost, '$.known') = 1
631
+ AND CAST(json_extract(cost, '$.total') AS REAL) >= 0
632
+ THEN CAST(json_extract(cost, '$.total') AS REAL) ELSE 0 END) AS total,
633
+ MAX(CASE WHEN json_extract(cost, '$.known') = 1
634
+ AND CAST(json_extract(cost, '$.total') AS REAL) >= 0
635
+ THEN 1 ELSE 0 END) AS known,
636
+ SUM(CASE WHEN json_extract(cost, '$.known') = 1
637
+ AND CAST(json_extract(cost, '$.total') AS REAL) >= 0
638
+ THEN 1 ELSE 0 END) AS known_count
639
+ FROM message_store
640
+ WHERE project_dir = ?
641
+ AND role = 'assistant'
642
+ AND cost IS NOT NULL${idFilter}
643
+ GROUP BY chat_id
644
+ `).all(projectDir, ...(ids || []));
645
+ const out = {};
646
+ for (const r of rows) {
647
+ out[r.chat_id] = {
648
+ total: typeof r.total === 'number' ? r.total : 0,
649
+ known: r.known === 1,
650
+ knownCount: typeof r.known_count === 'number' ? r.known_count : 0
651
+ };
652
+ }
653
+ return out;
654
+ }
655
+
656
+ function chatTotalCostDb(projectDir, chatId) {
657
+ ensureTables();
658
+ const d = require('./settings.js').getDb();
659
+ const row = d.prepare(`
660
+ SELECT COALESCE(SUM(CASE WHEN role = 'assistant'
661
+ AND cost IS NOT NULL
662
+ AND json_extract(cost, '$.known') = 1
663
+ AND CAST(json_extract(cost, '$.total') AS REAL) >= 0
664
+ THEN CAST(json_extract(cost, '$.total') AS REAL) ELSE 0 END), 0) AS total,
665
+ MAX(CASE WHEN role = 'assistant'
666
+ AND cost IS NOT NULL
667
+ AND json_extract(cost, '$.known') = 1
668
+ AND CAST(json_extract(cost, '$.total') AS REAL) >= 0
669
+ THEN 1 ELSE 0 END) AS known
670
+ FROM message_store
671
+ WHERE project_dir = ? AND chat_id = ?
672
+ `).get(projectDir, chatId);
673
+ return {
674
+ total: typeof row.total === 'number' ? row.total : 0,
675
+ known: row.known === 1
676
+ };
677
+ }
678
+
679
+ // addChatCost(projectDir, chatId, amount) -> { total, known, currency, knownCount } | null
680
+ //
681
+ // Attribute one *known* priced run to a chat's persisted total without writing
682
+ // a message row. This is the second writer of `total_cost` /
683
+ // `cost_known_count` (appendMessage is the first) and it exists because
684
+ // dictation is billed work that is not a chat turn: the transcription's cost
685
+ // belongs in the chat and project totals, but no assistant message carries it
686
+ // (docs/features/dictation.md).
687
+ //
688
+ // The chat row and the registered project's total move together, in one
689
+ // transaction, exactly like the message path — the two must never disagree.
690
+ // Returns null when the chat does not exist, so a caller that has already
691
+ // performed (and billed) the upstream call can ignore attribution instead of
692
+ // failing the run.
693
+ function addChatCost(projectDir, chatId, amount) {
694
+ const delta = Number(amount);
695
+ if (!projectDir || !chatId || !Number.isFinite(delta) || delta < 0) return null;
696
+ ensureTables();
697
+ const d = require('./settings.js').getDb();
698
+ let updated = false;
699
+ const tx = d.transaction(() => {
700
+ const info = d.prepare(`
701
+ UPDATE chat_store
702
+ SET total_cost = total_cost + ?, cost_known_count = cost_known_count + 1
703
+ WHERE project_dir = ? AND id = ?
704
+ `).run(delta, projectDir, chatId);
705
+ if (info.changes !== 1) return;
706
+ require('./projects.js').adjustProjectTotalCost(projectDir, delta, 1);
707
+ updated = true;
708
+ });
709
+ tx();
710
+ if (!updated) return null;
711
+ const row = readChatCostRow(d, projectDir, chatId);
712
+ return {
713
+ total: row.total,
714
+ known: row.knownCount > 0,
715
+ currency: 'USD',
716
+ knownCount: row.knownCount
717
+ };
718
+ }
719
+
720
+ // ---- Import from JSON files ------------------------------------------------
721
+ //
722
+ // Legacy one-shot import from `.mouaif.messages.*.json` transcripts and the
723
+ // old `<projectDir>/.mouaif.json` `chats` array. Kept so users who ran the
724
+ // earlier file-based storage can migrate that history into the DB; it is
725
+ // no longer an active backend and the import migration is retired.
726
+
727
+ function importFromJson(projectDir, opts) {
728
+ ensureTables();
729
+ const fs = require('fs');
730
+ const settings = require('./settings.js');
731
+ const chatsFile = settings.getProjectPath(projectDir);
732
+ const imported = { chats: 0, messages: 0, errors: [] };
733
+
734
+ let project;
735
+ try {
736
+ project = settings.readProjectJson(chatsFile);
737
+ } catch (e) {
738
+ imported.errors.push('Failed to read ' + chatsFile + ': ' + e.message);
739
+ return imported;
740
+ }
741
+
742
+ const rawChats = Array.isArray(project.chats) ? project.chats : [];
743
+ for (const chat of rawChats) {
744
+ if (!chat || !chat.id || !CHAT_ID_RE.test(chat.id)) continue;
745
+ try {
746
+ const skipExisting = opts && opts.skipExisting;
747
+ if (skipExisting && getChat(projectDir, chat.id)) {
748
+ // Chat already in DB; still import messages if missing.
749
+ if (getMessageCount(projectDir, chat.id) === 0) {
750
+ importMessagesFromJson(projectDir, chat.id, imported);
751
+ }
752
+ imported.chats++;
753
+ continue;
754
+ }
755
+ createChat(projectDir, chat);
756
+ imported.chats++;
757
+ importMessagesFromJson(projectDir, chat.id, imported);
758
+ } catch (e) {
759
+ imported.errors.push('Chat ' + chat.id + ': ' + e.message);
760
+ }
761
+ }
762
+
763
+ return imported;
764
+ }
765
+
766
+ function importMessagesFromJson(projectDir, chatId, imported) {
767
+ const fs = require('fs');
768
+ const settings = require('./settings.js');
769
+ const messagesPath = path.join(projectDir, '.mouaif.messages.' + chatId + '.json');
770
+ if (!fs.existsSync(messagesPath)) return;
771
+ try {
772
+ const raw = settings.readProjectJson(messagesPath);
773
+ const list = Array.isArray(raw.messages) ? raw.messages : [];
774
+ const normalized = [];
775
+ for (const m of list) {
776
+ const n = normalizeMessage(m);
777
+ if (n) normalized.push(n);
778
+ }
779
+ if (normalized.length === 0) return;
780
+ if (getMessageCount(projectDir, chatId) >= normalized.length) {
781
+ imported.messages += normalized.length;
782
+ return;
783
+ }
784
+ replaceMessages(projectDir, chatId, normalized);
785
+ imported.messages += normalized.length;
786
+ } catch (e) {
787
+ imported.errors.push('Messages ' + chatId + ': ' + e.message);
788
+ }
789
+ }
790
+
791
+ module.exports = {
792
+ // Chat CRUD
793
+ listChats,
794
+ countChats,
795
+ getChat,
796
+ createChat,
797
+ updateChat,
798
+ deleteChat,
799
+ // Message CRUD
800
+ listMessages,
801
+ listMessagesWindow,
802
+ appendMessage,
803
+ replaceMessages,
804
+ clearMessages,
805
+ getMessageCount,
806
+ projectMessageCounts,
807
+ messageCursorDb,
808
+ // Cost aggregation
809
+ projectCostTotals,
810
+ chatTotalCostDb,
811
+ addChatCost,
812
+ // Legacy import
813
+ importFromJson,
814
+ // Exported so settings migrations can create the tables before their ALTERs.
815
+ ensureChatTables: ensureTables
816
+ };