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.
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/bin/mouaif.js +281 -0
- package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
- package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
- package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
- package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
- package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
- package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
- package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
- package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
- package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
- package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
- package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
- package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
- package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
- package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
- package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
- package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
- package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
- package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
- package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
- package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
- package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
- package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
- package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
- package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
- package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
- package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
- package/frontend/dist/assets/index-CANPYzQg.css +1 -0
- package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
- package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
- package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
- package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
- package/frontend/dist/icons/favicon-32.png +0 -0
- package/frontend/dist/icons/icon-180-apple.png +0 -0
- package/frontend/dist/icons/icon-192.png +0 -0
- package/frontend/dist/icons/icon-512.png +0 -0
- package/frontend/dist/icons/icon-maskable-512.png +0 -0
- package/frontend/dist/index.html +83 -0
- package/frontend/dist/manifest.webmanifest +33 -0
- package/frontend/dist/sw.js +482 -0
- package/package.json +98 -0
- package/scripts/patch-zimmerframe.js +58 -0
- package/src/access-auth.js +515 -0
- package/src/agentFeatures.js +294 -0
- package/src/agentFiles.js +164 -0
- package/src/agentSkills.js +147 -0
- package/src/agents.js +230 -0
- package/src/ai-chat.js +21 -0
- package/src/ai-endpoints.js +1880 -0
- package/src/ai-stream.js +2048 -0
- package/src/ai.js +68 -0
- package/src/auth.js +391 -0
- package/src/chatdb.js +816 -0
- package/src/chats.js +275 -0
- package/src/custom-actions.js +65 -0
- package/src/files.js +431 -0
- package/src/hideFileContent.js +327 -0
- package/src/http-server.js +535 -0
- package/src/index.js +15 -0
- package/src/inspector.js +731 -0
- package/src/inspectorProfiles.js +503 -0
- package/src/live-chat.js +107 -0
- package/src/mcp.js +1517 -0
- package/src/messages.js +238 -0
- package/src/modelList.js +137 -0
- package/src/notifications.js +52 -0
- package/src/oauth-anthropic.js +280 -0
- package/src/oauth-github-copilot.js +417 -0
- package/src/oauth-mcp.js +216 -0
- package/src/oauth-openrouter.js +285 -0
- package/src/package-version.js +20 -0
- package/src/projects.js +285 -0
- package/src/promptProfiles.js +256 -0
- package/src/prompts.js +384 -0
- package/src/providerShapes.js +44 -0
- package/src/providers/base.js +41 -0
- package/src/providers/index.js +25 -0
- package/src/push.js +315 -0
- package/src/qr.js +192 -0
- package/src/restart.js +47 -0
- package/src/server-handlers-access.js +306 -0
- package/src/server-handlers-actions.js +100 -0
- package/src/server-handlers-ai.js +248 -0
- package/src/server-handlers-auth.js +273 -0
- package/src/server-handlers-chats.js +1436 -0
- package/src/server-handlers-git.js +467 -0
- package/src/server-handlers-mcp-oauth.js +56 -0
- package/src/server-handlers-misc.js +783 -0
- package/src/server-handlers-projects.js +289 -0
- package/src/server-handlers-prompts.js +259 -0
- package/src/server-handlers-push.js +102 -0
- package/src/server-handlers-settings.js +406 -0
- package/src/server-handlers-tools.js +654 -0
- package/src/server-handlers-transcribe.js +399 -0
- package/src/server-shared.js +780 -0
- package/src/server-web-static.js +191 -0
- package/src/settings.js +898 -0
- package/src/statusBar.js +541 -0
- package/src/tags.js +414 -0
- package/src/toolFeedback.js +225 -0
- package/src/tools/ask.js +154 -0
- package/src/tools/authorization.js +932 -0
- package/src/tools/files.js +1150 -0
- package/src/tools/progress.js +71 -0
- package/src/tools/restart.js +32 -0
- package/src/tools/searchEngine.js +957 -0
- package/src/tools/shell.js +341 -0
- package/src/tools/subagent.js +47 -0
- package/src/tools/task.js +234 -0
- package/src/tools/webpreview.js +448 -0
- package/src/trace.js +103 -0
- package/src/transcribe.js +683 -0
- package/src/usage.js +389 -0
- package/src/util.js +151 -0
package/src/settings.js
ADDED
|
@@ -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
|
+
};
|