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
@@ -0,0 +1,898 @@
1
+ 'use strict';
2
+
3
+ // App + project settings store.
4
+ //
5
+ // Decisions implemented here:
6
+ // docs/decisions.md §1 — app-level settings live in ~/.mouaif/store.sqlite
7
+ // (managed by better-sqlite3); project-level settings
8
+ // live in <projectDir>/.mouaif.json.
9
+ // docs/decisions.md §2 — resolution order: defaults -> app -> project.
10
+ // Project wins on conflict.
11
+ //
12
+ // The store is intentionally minimal. It exposes:
13
+ // - getDefaults() : built-in defaults (in-code, not stored).
14
+ // - getApp() / setApp() : the whole app-level settings object.
15
+ // - getProject(dir) / set() : one project, resolved (merged) or raw.
16
+ // - getProjectPath() : the canonical <projectDir>/.mouaif.json path.
17
+ //
18
+ // Concurrency: better-sqlite3 is synchronous and single-process; we do not
19
+ // need transactions beyond what a single prepared statement gives us. The
20
+ // in-process write queue is the caller's problem (Node single-thread).
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const os = require('os');
25
+ const crypto = require('crypto');
26
+ const Database = require('better-sqlite3');
27
+
28
+ const MOUAIF_HOME = process.env.MOUAIF_HOME || path.join(os.homedir(), '.mouaif');
29
+ const PROJECT_FILE = '.mouaif.json';
30
+ const APP_DB = 'store.sqlite';
31
+ const APP_KV_TABLE = 'app_kv';
32
+ const APP_KEY = 'settings';
33
+ // Row key prefix for an unreadable app-settings blob that was moved aside
34
+ // instead of being silently replaced by defaults. See getAppRaw().
35
+ const APP_QUARANTINE_PREFIX = 'settings.corrupt-';
36
+ // Per-project MCP tool caches. Keyed by `${projectDir}::${serverId}` so the
37
+ // bulky last-known tool schemas live in the app store instead of the
38
+ // hand-editable, project-committed <projectDir>/.mcp.json.
39
+ const MCP_TOOL_CACHE_TABLE = 'mcp_tool_cache';
40
+ const MIGRATIONS_TABLE = '_migrations';
41
+ // Per-project settings stored in the app DB instead of <projectDir>/.mouaif.json.
42
+ // Keyed by canonical project directory so a project can opt out of writing the
43
+ // JSON file (keeps the working tree untouched). The row shape mirrors the
44
+ // project file: the value is the full raw project object.
45
+ const PROJECT_SETTINGS_TABLE = 'project_settings';
46
+ const PROJECT_SETTINGS_KEY = '__project_settings__';
47
+
48
+ // Built-in defaults. These are the floor: anything not set in app or project
49
+ // falls back to these. They are intentionally tiny for the first commit; later
50
+ // features (models, prompts, prompt-size profile, trace default, ...) extend
51
+ // this object.
52
+ const DEFAULTS = Object.freeze({
53
+ // App-level provider connections. Shape:
54
+ // { id, baseUrl, apiKey, auth, oauthAccount }
55
+ // Models reference one of these provider ids from project settings.
56
+ providers: [],
57
+ // User-defined project models. Shape: { id, provider, label, contextWindow }.
58
+ // Kept in defaults so projects without a models key resolve to an empty list.
59
+ models: [],
60
+ // Registered projects. Shape: { id, path, name, createdAt }. Filled in by
61
+ // src/projects.js when the user picks a folder. Empty by default.
62
+ projects: [],
63
+ // Non-secret account index for OAuth sign-ins. Shape:
64
+ // { openai: ['me@example.com'], anthropic: [], google: [], 'github-copilot': [] }
65
+ // The actual tokens live in the OS keychain via src/auth.js.
66
+ authAccounts: {},
67
+ // Default prompt-size profile for new chats. One of 'very-small' | 'average' | 'extensive'.
68
+ promptSize: 'average',
69
+ // Composer keyboard default: when true, Enter inserts a newline and the
70
+ // send button / Cmd+Ctrl+Enter sends. When false, Enter sends and
71
+ // Shift+Enter inserts a newline. App-level; a project may override it.
72
+ enterForNewline: true,
73
+ // Auto-retry failed turns. When true, the web client transparently
74
+ // re-sends a user message once if the request fails before a stream
75
+ // starts (network error or an HTTP rejection other than 409).
76
+ autoRetry: true,
77
+ // Composer file button ("File tools") style. When false the trigger is the
78
+ // flat circle that matches the rest of the composer; when true it renders as
79
+ // the animated glass "orb" — a shaded sphere with the git counts on a 3D
80
+ // folder glyph inside. App-level: a display preference, so it applies to
81
+ // every project. See docs/features/file-button-orb.md.
82
+ fileOrbButton: false,
83
+ // Which optional tools the composer draws. Both are display preferences
84
+ // (app-level, like `fileOrbButton`) and both default to `true`: the
85
+ // microphone and the image button are the two controls the composer has
86
+ // always shown, and turning one *off* is how a user keeps a button they
87
+ // never use out of the way. Hiding is not disabling — the routes behind
88
+ // them (`/api/ai/transcribe`, image attachments) are untouched. See
89
+ // docs/features/composer-tool-buttons.md.
90
+ dictationButton: true,
91
+ imageButton: true,
92
+ // App-level custom prompts. Empty by default.
93
+ prompts: [],
94
+ // Tool output profile for file/result text fed back to the model. `size`
95
+ // controls the byte cap (`very-small`, `average`, `full`, `extensive`);
96
+ // `structure` controls the file-listing layout (`tree` default, `json`).
97
+ // Lives in the same default floor so projects without a key resolve to a
98
+ // sane value.
99
+ toolOutput: { size: 'average', structure: 'tree' },
100
+ // Maximum UTF-8 bytes of one tool result copied into model context.
101
+ // The complete result remains available to the UI and transcript.
102
+ toolFeedbackMaxBytes: 64 * 1024,
103
+ // OS-level browser notifications have two user-facing channels: one
104
+ // replaceable ASCII status per chat, plus authorization prompts. Quick
105
+ // actions let the user answer or approve without opening the app.
106
+ // `login` alerts on a new sign-in and is on by default. See
107
+ // src/notifications.js for the authoritative defaults.
108
+ notifications: {
109
+ status: true,
110
+ authorization: true,
111
+ quickActions: true,
112
+ login: true
113
+ },
114
+
115
+ // Server-side flags. Reserved for future toggles (e.g. enableInspector, port...).
116
+ flags: {}
117
+ });
118
+
119
+ function ensureDir(dir) {
120
+ fs.mkdirSync(dir, { recursive: true });
121
+ }
122
+
123
+ // ---- Project-dir canonicalization --------------------------------------
124
+ //
125
+ // Every project-scoped table in the app store (project_settings,
126
+ // mcp_tool_cache, model_recent) is keyed by the project directory. Callers
127
+ // hand us whatever the client sent — `/home/me/app`, `/home/me/app/` or
128
+ // `/home/me/app/../app` are three spellings of one directory, and used to be
129
+ // three rows. The damage was not only duplicated data: a project could be
130
+ // opted into DB-backed settings under a key that no other code path ever
131
+ // looked up, so it kept reading `.mouaif.json` and the opt-in appeared to do
132
+ // nothing. Keys are therefore normalized to an absolute path at every
133
+ // boundary in this module, reads included (a read under a non-canonical key
134
+ // must not find a row the write would not have created).
135
+ function canonicalProjectDir(projectDir) {
136
+ if (!projectDir || typeof projectDir !== 'string') return null;
137
+ if (!path.isAbsolute(projectDir)) return null;
138
+ return path.resolve(projectDir);
139
+ }
140
+
141
+ // Same as canonicalProjectDir(), but for write paths: an unusable directory
142
+ // is a programming error there, not a miss.
143
+ function requireCanonicalProjectDir(projectDir) {
144
+ const canonical = canonicalProjectDir(projectDir);
145
+ if (!canonical) {
146
+ const e = new TypeError('projectDir must be an absolute path');
147
+ e.code = 'EBADPROJECTDIR';
148
+ throw e;
149
+ }
150
+ return canonical;
151
+ }
152
+
153
+ function openDb(home) {
154
+ ensureDir(home);
155
+ const dbPath = path.join(home, APP_DB);
156
+ const db = new Database(dbPath);
157
+ db.pragma('journal_mode = WAL');
158
+ db.exec(
159
+ `CREATE TABLE IF NOT EXISTS ${APP_KV_TABLE} (
160
+ key TEXT PRIMARY KEY,
161
+ value TEXT NOT NULL
162
+ );`
163
+ );
164
+ db.exec(
165
+ `CREATE TABLE IF NOT EXISTS ${MCP_TOOL_CACHE_TABLE} (
166
+ project_dir TEXT NOT NULL,
167
+ server_id TEXT NOT NULL,
168
+ tools TEXT NOT NULL,
169
+ updated_at TEXT NOT NULL,
170
+ PRIMARY KEY (project_dir, server_id)
171
+ );`
172
+ );
173
+ db.exec(
174
+ `CREATE TABLE IF NOT EXISTS ${PROJECT_SETTINGS_TABLE} (
175
+ project_dir TEXT PRIMARY KEY,
176
+ value TEXT NOT NULL
177
+ );`
178
+ );
179
+ db.exec(
180
+ `CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (
181
+ name TEXT PRIMARY KEY,
182
+ run_at TEXT NOT NULL
183
+ );`
184
+ );
185
+ db.exec(
186
+ `CREATE TABLE IF NOT EXISTS push_subscriptions (
187
+ id TEXT PRIMARY KEY,
188
+ session_id TEXT NOT NULL,
189
+ endpoint TEXT NOT NULL UNIQUE,
190
+ p256dh TEXT NOT NULL,
191
+ auth TEXT NOT NULL,
192
+ origin TEXT,
193
+ created_at TEXT NOT NULL,
194
+ updated_at TEXT NOT NULL
195
+ );`
196
+ );
197
+ db.exec(
198
+ `CREATE TABLE IF NOT EXISTS push_vapid (
199
+ key TEXT PRIMARY KEY,
200
+ value TEXT NOT NULL,
201
+ created_at TEXT NOT NULL
202
+ );`
203
+ );
204
+ return db;
205
+ }
206
+
207
+ // ---- Model recent store (app DB) -----------------------------------------
208
+ //
209
+ // Per-project list of recently used models, capped at 20 entries per project.
210
+ // Stored in a dedicated table so the web UI can fetch and update it without
211
+ // reading/writing the whole app-level settings object.
212
+
213
+ const MODEL_RECENT_TABLE = 'model_recent';
214
+ const MODEL_RECENT_CAP = 20;
215
+
216
+ function ensureModelRecentTable() {
217
+ db().exec(
218
+ `CREATE TABLE IF NOT EXISTS ${MODEL_RECENT_TABLE} (
219
+ project_dir TEXT NOT NULL,
220
+ provider TEXT NOT NULL,
221
+ model_id TEXT NOT NULL,
222
+ ts INTEGER NOT NULL,
223
+ PRIMARY KEY (project_dir, provider, model_id)
224
+ );`
225
+ );
226
+ }
227
+
228
+ function getRecentModels(projectDir) {
229
+ const dir = canonicalProjectDir(projectDir);
230
+ if (!dir) return [];
231
+ ensureModelRecentTable();
232
+ const rows = db()
233
+ .prepare(
234
+ `SELECT provider, model_id AS id, ts
235
+ FROM ${MODEL_RECENT_TABLE}
236
+ WHERE project_dir = ?
237
+ ORDER BY ts DESC
238
+ LIMIT ?`
239
+ )
240
+ .all(dir, MODEL_RECENT_CAP);
241
+ return rows;
242
+ }
243
+
244
+ function touchRecentModel(projectDir, provider, modelId) {
245
+ if (!provider || !modelId) return;
246
+ const dir = requireCanonicalProjectDir(projectDir);
247
+ ensureModelRecentTable();
248
+ const now = Date.now();
249
+ db()
250
+ .prepare(
251
+ `INSERT INTO ${MODEL_RECENT_TABLE} (project_dir, provider, model_id, ts) VALUES (?, ?, ?, ?)
252
+ ON CONFLICT(project_dir, provider, model_id) DO UPDATE SET ts = excluded.ts`
253
+ )
254
+ .run(dir, provider, modelId, now);
255
+ // Reap any entries beyond the cap (oldest first)
256
+ db()
257
+ .prepare(
258
+ `DELETE FROM ${MODEL_RECENT_TABLE} WHERE project_dir = ? AND rowid NOT IN (
259
+ SELECT rowid FROM ${MODEL_RECENT_TABLE} WHERE project_dir = ? ORDER BY ts DESC LIMIT ?
260
+ )`
261
+ )
262
+ .run(dir, dir, MODEL_RECENT_CAP);
263
+ }
264
+
265
+ function clearRecentModels(projectDir) {
266
+ const dir = canonicalProjectDir(projectDir);
267
+ if (!dir) return;
268
+ ensureModelRecentTable();
269
+ db()
270
+ .prepare(`DELETE FROM ${MODEL_RECENT_TABLE} WHERE project_dir = ?`)
271
+ .run(dir);
272
+ }
273
+
274
+ // ---- Legacy key normalization -------------------------------------------
275
+ //
276
+ // Rows written before the canonicalization fix can hold `/a/b/`, `/a/./b`
277
+ // and `/a/c/../b` as three distinct keys for one directory. This rewrites
278
+ // each project-scoped table once, merging duplicates deterministically:
279
+ // - project_settings: a row whose value carries `__dbBacked: true` wins
280
+ // (that is the opt-in the user actually made, seeded from their file);
281
+ // otherwise the earliest row wins.
282
+ // - mcp_tool_cache: the most recently updated row wins per server.
283
+ // - model_recent: the newest timestamp wins per (provider, model).
284
+ // Keys that are not absolute paths cannot be canonicalized; they are carried
285
+ // over verbatim rather than dropped.
286
+
287
+ function projectKeysNeedRewrite(rows) {
288
+ return rows.some((row) => {
289
+ const dir = canonicalProjectDir(row.project_dir);
290
+ return dir && dir !== row.project_dir;
291
+ });
292
+ }
293
+
294
+ function canonicalizeProjectKeys() {
295
+ const d = db();
296
+ ensureModelRecentTable();
297
+
298
+ // --- project_settings -------------------------------------------------
299
+ const projRows = d.prepare(`SELECT rowid, project_dir, value FROM ${PROJECT_SETTINGS_TABLE}`).all();
300
+ if (projectKeysNeedRewrite(projRows)) {
301
+ const groups = new Map();
302
+ for (const row of projRows) {
303
+ const dir = canonicalProjectDir(row.project_dir) || row.project_dir;
304
+ const group = groups.get(dir);
305
+ if (!group) groups.set(dir, [row]);
306
+ else group.push(row);
307
+ }
308
+ const replace = d.transaction(() => {
309
+ d.prepare(`DELETE FROM ${PROJECT_SETTINGS_TABLE}`).run();
310
+ const insert = d.prepare(`INSERT INTO ${PROJECT_SETTINGS_TABLE} (project_dir, value) VALUES (?, ?)`);
311
+ for (const [dir, group] of groups) {
312
+ const winner = group.find((r) => {
313
+ try { return JSON.parse(r.value).__dbBacked === true; } catch { return false; }
314
+ }) || group[0];
315
+ insert.run(dir, winner.value);
316
+ }
317
+ });
318
+ replace();
319
+ }
320
+
321
+ // --- mcp_tool_cache ---------------------------------------------------
322
+ const cacheRows = d.prepare(`SELECT project_dir, server_id, tools, updated_at FROM ${MCP_TOOL_CACHE_TABLE}`).all();
323
+ if (projectKeysNeedRewrite(cacheRows)) {
324
+ const groups = new Map();
325
+ for (const row of cacheRows) {
326
+ const dir = canonicalProjectDir(row.project_dir) || row.project_dir;
327
+ const key = dir + '\0' + row.server_id;
328
+ const prev = groups.get(key);
329
+ if (!prev || String(row.updated_at) > String(prev.updated_at)) {
330
+ groups.set(key, { ...row, project_dir: dir });
331
+ }
332
+ }
333
+ const replace = d.transaction(() => {
334
+ d.prepare(`DELETE FROM ${MCP_TOOL_CACHE_TABLE}`).run();
335
+ const insert = d.prepare(
336
+ `INSERT INTO ${MCP_TOOL_CACHE_TABLE} (project_dir, server_id, tools, updated_at) VALUES (?, ?, ?, ?)`
337
+ );
338
+ for (const row of groups.values()) {
339
+ insert.run(row.project_dir, row.server_id, row.tools, row.updated_at);
340
+ }
341
+ });
342
+ replace();
343
+ }
344
+
345
+ // --- model_recent -----------------------------------------------------
346
+ const recentRows = d.prepare(`SELECT project_dir, provider, model_id, ts FROM ${MODEL_RECENT_TABLE}`).all();
347
+ if (projectKeysNeedRewrite(recentRows)) {
348
+ const groups = new Map();
349
+ for (const row of recentRows) {
350
+ const dir = canonicalProjectDir(row.project_dir) || row.project_dir;
351
+ const key = dir + '\0' + row.provider + '\0' + row.model_id;
352
+ const prev = groups.get(key);
353
+ if (!prev || row.ts > prev.ts) groups.set(key, { ...row, project_dir: dir });
354
+ }
355
+ const replace = d.transaction(() => {
356
+ d.prepare(`DELETE FROM ${MODEL_RECENT_TABLE}`).run();
357
+ const insert = d.prepare(
358
+ `INSERT INTO ${MODEL_RECENT_TABLE} (project_dir, provider, model_id, ts) VALUES (?, ?, ?, ?)`
359
+ );
360
+ for (const row of groups.values()) {
361
+ insert.run(row.project_dir, row.provider, row.model_id, row.ts);
362
+ }
363
+ });
364
+ replace();
365
+ }
366
+ }
367
+
368
+ // ---- Migrations ----------------------------------------------------------
369
+ //
370
+ // Each migration is an idempotent function keyed by name. The `_migrations`
371
+ // table tracks which have run. New migrations are appended to the list;
372
+ // never modify or remove an existing entry.
373
+
374
+ const MIGRATIONS = [
375
+ {
376
+ name: '2025-07-17-persist-project-total-cost',
377
+ description: 'Persist totalCost field on every registered project\'s .mouaif.json',
378
+ async run() {
379
+ const projects = require('./projects.js').listProjects();
380
+ const chats = require('./chats.js');
381
+ for (const p of projects) {
382
+ if (!p || !p.path) continue;
383
+ try {
384
+ chats.recomputeProjectTotalCost(p.path);
385
+ } catch (e) {
386
+ // Non-fatal — one inaccessible project should not block startup.
387
+ // Registering/importing it later seeds the totals again.
388
+ console.error(' [migration] cost total failed for ' + p.path + ': ' + e.message);
389
+ }
390
+ }
391
+ }
392
+ },
393
+ {
394
+ name: '2026-07-23-drop-unused-client-domains',
395
+ description: 'Remove the unused client domains table',
396
+ run() {
397
+ db().exec('DROP TABLE IF EXISTS client_domains');
398
+ }
399
+ },
400
+ {
401
+ name: '2026-07-27-add-thinking-level',
402
+ description: 'Add thinking_level column to chat_store for existing databases',
403
+ run() {
404
+ // Ensure the chat tables exist first. Fresh installs create them lazily
405
+ // on first chat access, but migrations run at startup before any chat is
406
+ // touched, so a brand-new DB has no chat_store yet (the retired import
407
+ // migration used to create it). Guard so the column ALTER doesn't 404.
408
+ require('./chatdb.js').ensureChatTables();
409
+ const d = db();
410
+ const cols = d.prepare("PRAGMA table_info('chat_store')").all();
411
+ const hasCol = cols.some((c) => c.name === 'thinking_level');
412
+ if (!hasCol) {
413
+ d.exec("ALTER TABLE chat_store ADD COLUMN thinking_level TEXT DEFAULT ''");
414
+ }
415
+ }
416
+ },
417
+ {
418
+ name: '2026-07-28-add-max-output-tokens',
419
+ description: 'Add max_output_tokens column to chat_store for existing databases',
420
+ run() {
421
+ require('./chatdb.js').ensureChatTables();
422
+ const d = db();
423
+ const cols = d.prepare("PRAGMA table_info('chat_store')").all();
424
+ const hasCol = cols.some((c) => c.name === 'max_output_tokens');
425
+ if (!hasCol) {
426
+ d.exec("ALTER TABLE chat_store ADD COLUMN max_output_tokens TEXT DEFAULT ''");
427
+ }
428
+ }
429
+ },
430
+ {
431
+ name: '2026-08-16-add-draft-attachments',
432
+ description: 'Add draft_attachments column to chat_store for pending composer image drafts',
433
+ run() {
434
+ require('./chatdb.js').ensureChatTables();
435
+ const d = db();
436
+ const cols = d.prepare("PRAGMA table_info('chat_store')").all();
437
+ const hasCol = cols.some((c) => c.name === 'draft_attachments');
438
+ if (!hasCol) {
439
+ d.exec("ALTER TABLE chat_store ADD COLUMN draft_attachments TEXT");
440
+ }
441
+ }
442
+ },
443
+ {
444
+ name: '2026-08-26-persist-chat-cost-totals',
445
+ description: 'Persist chat and registered-project cost totals',
446
+ run() {
447
+ const projects = require('./projects.js').listProjects();
448
+ const chats = require('./chats.js');
449
+ for (const project of projects) {
450
+ if (!project || !project.path) continue;
451
+ try { chats.recomputeProjectTotalCost(project.path); }
452
+ catch (e) { console.error(' [migration] cost total failed for ' + project.path + ': ' + e.message); }
453
+ }
454
+ }
455
+ },
456
+ {
457
+ name: '2026-09-12-canonicalize-project-keys',
458
+ description: 'Normalize project directory keys in the project-scoped app tables',
459
+ run() {
460
+ canonicalizeProjectKeys();
461
+ }
462
+ }
463
+ ];
464
+
465
+ function runMigrations() {
466
+ const d = db();
467
+ const ran = new Set();
468
+ for (const row of d.prepare(`SELECT name FROM ${MIGRATIONS_TABLE}`).iterate()) {
469
+ ran.add(row.name);
470
+ }
471
+ const insert = d.prepare(`INSERT OR IGNORE INTO ${MIGRATIONS_TABLE} (name, run_at) VALUES (?, ?)`);
472
+ for (const m of MIGRATIONS) {
473
+ if (ran.has(m.name)) continue;
474
+ console.log('[migration] ' + m.name + ' — ' + m.description);
475
+ m.run();
476
+ insert.run(m.name, new Date().toISOString());
477
+ console.log('[migration] ' + m.name + ' done');
478
+ }
479
+ }
480
+
481
+ function readJsonFile(filePath) {
482
+ const raw = fs.readFileSync(filePath, 'utf8');
483
+ return JSON.parse(raw);
484
+ }
485
+
486
+ function writeJsonFile(filePath, obj) {
487
+ ensureDir(path.dirname(filePath));
488
+ // 2-space indent so the file is hand-editable and diff-friendly.
489
+ const body = JSON.stringify(obj, null, 2) + '\n';
490
+ // Stage next to the target, fsync, then rename over it. A bare
491
+ // writeFileSync that dies mid-write (crash, full disk, killed process)
492
+ // leaves a truncated `.mouaif.json` behind, and a truncated project file is
493
+ // not a small failure: readProjectJson() reports
494
+ // MOUAIF_PROJECT_PARSE_ERROR, so settings AND every chat route for that
495
+ // project answer 422 until the user repairs the file by hand. rename(2) is
496
+ // atomic within a directory, so a reader sees either the old file or the
497
+ // complete new one. The staging name is unique per write so two processes
498
+ // cannot clobber each other's, and it is removed on failure.
499
+ const tmpPath = filePath + '.tmp-' + process.pid + '-' + crypto.randomBytes(4).toString('hex');
500
+ let fd = null;
501
+ try {
502
+ fd = fs.openSync(tmpPath, 'w');
503
+ fs.writeFileSync(fd, body, 'utf8');
504
+ fs.fsyncSync(fd);
505
+ fs.closeSync(fd);
506
+ fd = null;
507
+ fs.renameSync(tmpPath, filePath);
508
+ } catch (e) {
509
+ if (fd !== null) { try { fs.closeSync(fd); } catch { /* already closed */ } }
510
+ try { fs.unlinkSync(tmpPath); } catch { /* nothing to clean up */ }
511
+ throw e;
512
+ }
513
+ }
514
+
515
+ // ---- Shared project-file I/O -------------------------------------------
516
+ //
517
+ // Single source of truth for the on-disk project-file contract (see
518
+ // docs/features/app-and-project-settings.md): 2-space indented JSON,
519
+ // trailing LF, missing file treated as {}, corrupt file surfaced as a
520
+ // MOUAIF_PROJECT_PARSE_ERROR. Both src/chats.js and src/messages.js
521
+ // read/write JSON files next to the project (`.mouaif.json` and the
522
+ // per-chat `.mouaif.messages.<id>.json`); they go through these helpers
523
+ // so the format and error contract live in one place.
524
+
525
+ // Read and JSON-parse an arbitrary project-relative JSON file. Missing
526
+ // file returns `fallback` (default {}). A corrupt file throws with
527
+ // code 'MOUAIF_PROJECT_PARSE_ERROR' so the HTTP layer can map it to 422.
528
+ function readProjectJson(filePath, fallback) {
529
+ if (!filePath || typeof filePath !== 'string') {
530
+ throw new TypeError('filePath must be a non-empty string');
531
+ }
532
+ if (!fs.existsSync(filePath)) return fallback === undefined ? {} : fallback;
533
+ try { return readJsonFile(filePath); }
534
+ catch (e) {
535
+ const err = new Error('Failed to parse ' + filePath + ': ' + e.message);
536
+ err.code = 'MOUAIF_PROJECT_PARSE_ERROR';
537
+ err.cause = e;
538
+ throw err;
539
+ }
540
+ }
541
+
542
+ // Write an object to a project-relative JSON file in the canonical
543
+ // format (2-space indent, trailing LF), creating parent dirs as needed.
544
+ function writeProjectJson(filePath, obj) {
545
+ if (!filePath || typeof filePath !== 'string') {
546
+ throw new TypeError('filePath must be a non-empty string');
547
+ }
548
+ writeJsonFile(filePath, obj);
549
+ }
550
+
551
+ // ---- App-level store ----------------------------------------------------
552
+
553
+ let _appDb = null;
554
+ function db() {
555
+ if (!_appDb) _appDb = openDb(MOUAIF_HOME);
556
+ return _appDb;
557
+ }
558
+
559
+ // Expose the shared DB handle so chatdb.js can reuse the same connection.
560
+ // Tables are created lazily by both modules; the IF NOT EXISTS clause
561
+ // makes the second CREATE a no-op. The chat/message tables are created
562
+ // in chatdb.js so this module doesn't need to know about them.
563
+ function getDb() {
564
+ return db();
565
+ }
566
+
567
+ function getAppRaw() {
568
+ const d = db();
569
+ const row = d.prepare(`SELECT value FROM ${APP_KV_TABLE} WHERE key = ?`).get(APP_KEY);
570
+ if (!row) return {};
571
+ try {
572
+ const parsed = JSON.parse(row.value);
573
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
574
+ throw new Error('app settings must be a JSON object');
575
+ }
576
+ return parsed;
577
+ } catch (e) {
578
+ // A corrupt app-settings blob used to resolve to {}: every provider,
579
+ // registered project, prompt and pricing entry vanished from the UI, and
580
+ // the next write persisted that emptiness over the only copy. Move the
581
+ // unreadable value aside instead — it stays in the store under a
582
+ // quarantine key, the live row is reset, and the failure is reported.
583
+ const quarantineKey = APP_QUARANTINE_PREFIX + new Date().toISOString().replace(/[:.]/g, '-')
584
+ + '-' + crypto.randomBytes(3).toString('hex');
585
+ try {
586
+ d.transaction(() => {
587
+ d.prepare(`INSERT OR REPLACE INTO ${APP_KV_TABLE} (key, value) VALUES (?, ?)`)
588
+ .run(quarantineKey, row.value);
589
+ d.prepare(`DELETE FROM ${APP_KV_TABLE} WHERE key = ?`).run(APP_KEY);
590
+ })();
591
+ console.error('[settings] app settings were unreadable (' + e.message
592
+ + '); the stored value was preserved as ' + quarantineKey
593
+ + ' and settings start from defaults. See listQuarantinedAppSettings().');
594
+ } catch (quarantineError) {
595
+ console.error('[settings] app settings were unreadable (' + e.message
596
+ + ') and could not be quarantined: ' + quarantineError.message);
597
+ }
598
+ return {};
599
+ }
600
+ }
601
+
602
+ // Every unreadable app-settings value the store has moved aside, oldest key
603
+ // first. Each entry is the raw stored text, so a user can recover entries by
604
+ // hand (`sqlite3 ~/.mouaif/store.sqlite "SELECT value FROM app_kv WHERE key='...'"`)
605
+ // instead of losing them to a silent reset.
606
+ function listQuarantinedAppSettings() {
607
+ return db()
608
+ .prepare(`SELECT key, value FROM ${APP_KV_TABLE} WHERE key LIKE ? ORDER BY key`)
609
+ .all(APP_QUARANTINE_PREFIX + '%');
610
+ }
611
+
612
+ function getApp() {
613
+ // Returns the full app-level object as stored. Defaults are NOT merged in
614
+ // here; getResolved() does that.
615
+ return getAppRaw();
616
+ }
617
+
618
+ function setApp(patch) {
619
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
620
+ throw new TypeError('setApp() expects an object patch');
621
+ }
622
+ const current = getAppRaw();
623
+ const next = { ...current, ...patch };
624
+ const json = JSON.stringify(next);
625
+ db()
626
+ .prepare(
627
+ `INSERT INTO ${APP_KV_TABLE} (key, value) VALUES (?, ?)
628
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
629
+ )
630
+ .run(APP_KEY, json);
631
+ return next;
632
+ }
633
+
634
+ // Replace the whole app-level settings object (no merge). Used by
635
+ // /api/settings/app/reset where the caller wants to drop specific keys
636
+ // rather than merge into them. The caller is responsible for shape:
637
+ // the new object is stored verbatim.
638
+ function setAppReplace(next) {
639
+ if (!next || typeof next !== 'object' || Array.isArray(next)) {
640
+ throw new TypeError('setAppReplace() expects an object');
641
+ }
642
+ const json = JSON.stringify(next);
643
+ db()
644
+ .prepare(
645
+ `INSERT INTO ${APP_KV_TABLE} (key, value) VALUES (?, ?)
646
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
647
+ )
648
+ .run(APP_KEY, json);
649
+ return next;
650
+ }
651
+
652
+ // ---- Project-level store ------------------------------------------------
653
+
654
+ function getProjectPath(projectDir) {
655
+ if (!projectDir || typeof projectDir !== 'string') {
656
+ throw new TypeError('projectDir must be a non-empty string');
657
+ }
658
+ return path.join(projectDir, PROJECT_FILE);
659
+ }
660
+
661
+ function getProjectRaw(projectDir) {
662
+ // A corrupt project file is a user error. readProjectJson surfaces it as
663
+ // MOUAIF_PROJECT_PARSE_ERROR; a missing file resolves to {}.
664
+ return readProjectJson(getProjectPath(projectDir));
665
+ }
666
+
667
+ // ---- DB-backed project settings ----------------------------------------
668
+ // When a project opts out of the JSON file, its settings live here instead
669
+ // of <projectDir>/.mouaif.json so the working tree is never touched. The
670
+ // value is the same raw project object the file would have carried.
671
+ function projectSettingsRow(projectDir) {
672
+ const dir = canonicalProjectDir(projectDir);
673
+ if (!dir) return null;
674
+ return db()
675
+ .prepare(`SELECT value FROM ${PROJECT_SETTINGS_TABLE} WHERE project_dir = ?`)
676
+ .get(dir) || null;
677
+ }
678
+ function getDbProjectRaw(projectDir) {
679
+ const row = projectSettingsRow(projectDir);
680
+ if (!row) return {};
681
+ try { return JSON.parse(row.value); } catch { return {}; }
682
+ }
683
+ function isDbBacked(projectDir) {
684
+ const row = projectSettingsRow(projectDir);
685
+ if (!row) return false;
686
+ try { return JSON.parse(row.value).__dbBacked === true; } catch { return false; }
687
+ }
688
+ function setDbProject(projectDir, next) {
689
+ if (!next || typeof next !== 'object' || Array.isArray(next)) {
690
+ throw new TypeError('setDbProject() expects an object');
691
+ }
692
+ const dir = requireCanonicalProjectDir(projectDir);
693
+ db()
694
+ .prepare(
695
+ `INSERT INTO ${PROJECT_SETTINGS_TABLE} (project_dir, value) VALUES (?, ?)
696
+ ON CONFLICT(project_dir) DO UPDATE SET value = excluded.value`
697
+ )
698
+ .run(dir, JSON.stringify(next));
699
+ return next;
700
+ }
701
+ // Remove a project's DB-backed settings row entirely. Canonicalizes the key
702
+ // like every other project-scoped write so a non-canonical spelling (trailing
703
+ // slash, `..` segment) still targets the row the opt-in created. Toggling a
704
+ // project back to file-backed storage deletes the row through here; a bare
705
+ // `DELETE ... WHERE project_dir = ?` on the raw request path would miss it and
706
+ // leave the project reading stale DB settings.
707
+ function deleteDbProject(projectDir) {
708
+ const dir = canonicalProjectDir(projectDir);
709
+ if (!dir) return;
710
+ db()
711
+ .prepare(`DELETE FROM ${PROJECT_SETTINGS_TABLE} WHERE project_dir = ?`)
712
+ .run(dir);
713
+ }
714
+ function setDbBacked(projectDir, dbBacked) {
715
+ // Opting in seeds from the project's existing `.mouaif.json` (when there is
716
+ // no DB row yet) so no hand-written setting is silently ignored. This is the
717
+ // same guarantee the storage toggle documents; the register-time opt-in used
718
+ // to write a bare `{ __dbBacked: true }` and leave every project setting
719
+ // behind in the file it had just stopped reading.
720
+ const hasRow = !!projectSettingsRow(projectDir);
721
+ const next = dbBacked
722
+ ? { ...(hasRow ? getDbProjectRaw(projectDir) : getProjectRaw(projectDir)), __dbBacked: true }
723
+ : { ...getDbProjectRaw(projectDir) };
724
+ if (!dbBacked) delete next.__dbBacked;
725
+ setDbProject(projectDir, next);
726
+ return next;
727
+ }
728
+
729
+ function getProject(projectDir) {
730
+ // Raw project object (no defaults, no app merge). A DB-backed project
731
+ // returns its store row verbatim; otherwise the .mouaif.json file.
732
+ if (isDbBacked(projectDir)) return getDbProjectRaw(projectDir);
733
+ return getProjectRaw(projectDir);
734
+ }
735
+
736
+ function setProject(projectDir, patch) {
737
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
738
+ throw new TypeError('setProject() expects an object patch');
739
+ }
740
+ if (isDbBacked(projectDir)) {
741
+ return setDbProject(projectDir, { ...getDbProjectRaw(projectDir), ...patch });
742
+ }
743
+ const current = getProjectRaw(projectDir);
744
+ const next = { ...current, ...patch };
745
+ writeProjectJson(getProjectPath(projectDir), next);
746
+ return next;
747
+ }
748
+
749
+ function unsetProjectKeys(projectDir, keys) {
750
+ if (!Array.isArray(keys) || keys.some((key) => typeof key !== 'string' || !key)) {
751
+ throw new TypeError('unsetProjectKeys() expects an array of key names');
752
+ }
753
+ if (isDbBacked(projectDir)) {
754
+ const next = getDbProjectRaw(projectDir);
755
+ for (const key of keys) delete next[key];
756
+ return setDbProject(projectDir, next);
757
+ }
758
+ const next = getProjectRaw(projectDir);
759
+ for (const key of keys) delete next[key];
760
+ writeProjectJson(getProjectPath(projectDir), next);
761
+ return next;
762
+ }
763
+
764
+ // ---- Resolution ---------------------------------------------------------
765
+
766
+ function isPlainObject(v) {
767
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
768
+ }
769
+
770
+ // Deep copy of a settings value. Used by deepMerge so the resolved object is a
771
+ // fresh tree.
772
+ function cloneSettingValue(value) {
773
+ if (Array.isArray(value)) return value.map(cloneSettingValue);
774
+ if (isPlainObject(value)) {
775
+ const out = {};
776
+ for (const key of Object.keys(value)) out[key] = cloneSettingValue(value[key]);
777
+ return out;
778
+ }
779
+ return value;
780
+ }
781
+
782
+ // Deep-merge for plain objects only. Arrays and primitives are replaced, not
783
+ // concatenated. Project values win on conflict.
784
+ //
785
+ // The result never aliases `base` or `override`. That matters because
786
+ // `getResolved()` merges the frozen DEFAULTS object as its base: keys absent
787
+ // from the override used to be carried over by reference, so
788
+ // `getResolved(dir).toolOutput` WAS `DEFAULTS.toolOutput` and a single
789
+ // mutation downstream (the resolve a caller is allowed to edit before use)
790
+ // poisoned the in-code defaults for every project until restart.
791
+ function deepMerge(base, override) {
792
+ if (!isPlainObject(base)) return cloneSettingValue(override);
793
+ if (!isPlainObject(override)) return cloneSettingValue(override);
794
+ const out = {};
795
+ for (const key of Object.keys(base)) out[key] = cloneSettingValue(base[key]);
796
+ for (const key of Object.keys(override)) {
797
+ out[key] = isPlainObject(base[key]) && isPlainObject(override[key])
798
+ ? deepMerge(base[key], override[key])
799
+ : cloneSettingValue(override[key]);
800
+ }
801
+ return out;
802
+ }
803
+
804
+ function getResolved(projectDir) {
805
+ // Order: defaults -> app -> project. Project wins.
806
+ const app = getAppRaw();
807
+ const project = projectDir ? getProject(projectDir) : {};
808
+ return deepMerge(deepMerge(DEFAULTS, app), project);
809
+ }
810
+
811
+ // ---- MCP tool cache (app DB) -------------------------------------------
812
+ //
813
+ // The last-known tool list of each MCP server, persisted so a stopped
814
+ // server still advertises its surface. Runtime data, not config — that's
815
+ // why it lives here and not in <projectDir>/.mcp.json.
816
+
817
+ function getMcpToolCache(projectDir, serverId) {
818
+ const dir = canonicalProjectDir(projectDir);
819
+ if (!dir) return null;
820
+ const row = db()
821
+ .prepare(`SELECT tools FROM ${MCP_TOOL_CACHE_TABLE} WHERE project_dir = ? AND server_id = ?`)
822
+ .get(dir, serverId);
823
+ if (!row) return null;
824
+ try { return JSON.parse(row.tools); } catch { return null; }
825
+ }
826
+
827
+ function setMcpToolCache(projectDir, serverId, tools) {
828
+ if (!Array.isArray(tools)) throw new TypeError('tools must be an array');
829
+ const dir = requireCanonicalProjectDir(projectDir);
830
+ db()
831
+ .prepare(
832
+ `INSERT INTO ${MCP_TOOL_CACHE_TABLE} (project_dir, server_id, tools, updated_at)
833
+ VALUES (?, ?, ?, ?)
834
+ ON CONFLICT(project_dir, server_id)
835
+ DO UPDATE SET tools = excluded.tools, updated_at = excluded.updated_at`
836
+ )
837
+ .run(dir, serverId, JSON.stringify(tools), new Date().toISOString());
838
+ }
839
+
840
+ function deleteMcpToolCache(projectDir, serverId) {
841
+ const dir = canonicalProjectDir(projectDir);
842
+ if (!dir) return;
843
+ db()
844
+ .prepare(`DELETE FROM ${MCP_TOOL_CACHE_TABLE} WHERE project_dir = ? AND server_id = ?`)
845
+ .run(dir, serverId);
846
+ }
847
+
848
+ function close() {
849
+ if (_appDb) {
850
+ _appDb.close();
851
+ _appDb = null;
852
+ }
853
+ }
854
+
855
+ module.exports = {
856
+ // introspection
857
+ MOUAIF_HOME,
858
+ PROJECT_FILE,
859
+ PROJECT_SETTINGS_TABLE,
860
+ DEFAULTS,
861
+ // app
862
+ getApp,
863
+ setApp,
864
+ setAppReplace,
865
+ listQuarantinedAppSettings,
866
+ // project
867
+ getProjectPath,
868
+ getProject,
869
+ getProjectRaw,
870
+ setProject,
871
+ setDbProject,
872
+ deleteDbProject,
873
+ unsetProjectKeys,
874
+ getDbProjectRaw,
875
+ isDbBacked,
876
+ setDbBacked,
877
+ // shared project-file I/O (used by chats.js / messages.js)
878
+ readProjectJson,
879
+ writeProjectJson,
880
+ // resolution
881
+ getResolved,
882
+ // shared SQLite DB handle (used by chatdb.js)
883
+ getDb,
884
+ // MCP tool cache (app DB)
885
+ getMcpToolCache,
886
+ setMcpToolCache,
887
+ deleteMcpToolCache,
888
+ // model recent (app DB)
889
+ getRecentModels,
890
+ touchRecentModel,
891
+ clearRecentModels,
892
+ // migrations
893
+ runMigrations,
894
+ // project-dir canonicalization (exported for the migration + tests)
895
+ canonicalProjectDir,
896
+ // lifecycle (mostly for tests)
897
+ close
898
+ };