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,238 @@
1
+ 'use strict';
2
+ // Messages — per-chat transcript.
3
+ //
4
+ // Each chat (identified by its project directory + chat id) owns a
5
+ // transcript of user / assistant / system / tool messages. Messages are
6
+ // persisted in the app-level SQLite store (~/.mouaif/store.sqlite) via
7
+ // src/chatdb.js. The legacy file backend (`.mouaif.messages.<chatId>.json`)
8
+ // was removed; opt-in commit history is available through the per-chat
9
+ // trace-to-file export (docs/decisions.md §5).
10
+ //
11
+ // Schema (per message):
12
+ // { role, content, ts, attachments?, reasoning?, usage?, cost?,
13
+ // streamingMs?, modelId?, toolCallId?, name?, args?, ok?, phase? }
14
+ const path = require('path');
15
+ const toolFeedback = require('./toolFeedback.js');
16
+ const CHAT_ID_RE = /^[a-f0-9]{8}$/i;
17
+ function assertChatId(chatId) {
18
+ if (!chatId || typeof chatId !== 'string' || !CHAT_ID_RE.test(chatId)) {
19
+ const e = new Error('chatId must be an 8-character hexadecimal id');
20
+ e.code = 'EBADINPUT';
21
+ throw e;
22
+ }
23
+ return chatId;
24
+ }
25
+ function messagesFilePath(projectDir, chatId) {
26
+ if (!projectDir || typeof projectDir !== 'string') {
27
+ throw new TypeError('projectDir must be a non-empty string');
28
+ }
29
+ assertChatId(chatId);
30
+ return path.join(projectDir, '.mouaif.messages.' + chatId + '.json');
31
+ }
32
+ const VALID_ROLES = new Set(['user', 'assistant', 'system', 'tool']);
33
+ const MAX_IMAGE_ATTACHMENTS = 8;
34
+ const MAX_IMAGE_DATA_URL_CHARS = 12 * 1024 * 1024;
35
+ function normalizeAttachments(list) {
36
+ if (!Array.isArray(list)) return [];
37
+ const out = [];
38
+ for (const a of list) {
39
+ if (!a || typeof a !== 'object') continue;
40
+ if (a.type !== 'image') continue;
41
+ const mimeType = typeof a.mimeType === 'string' ? a.mimeType : '';
42
+ const dataUrl = typeof a.dataUrl === 'string' ? a.dataUrl : '';
43
+ if (!/^image\/(png|jpe?g|webp|gif)$/i.test(mimeType)) continue;
44
+ if (!dataUrl.startsWith('data:' + mimeType + ';base64,')) continue;
45
+ if (dataUrl.length > MAX_IMAGE_DATA_URL_CHARS) continue;
46
+ const item = { type: 'image', mimeType, dataUrl };
47
+ if (typeof a.name === 'string' && a.name) item.name = a.name.slice(0, 160);
48
+ out.push(item);
49
+ if (out.length >= MAX_IMAGE_ATTACHMENTS) break;
50
+ }
51
+ return out;
52
+ }
53
+ function normalizeMessage(m) {
54
+ if (!m || typeof m !== 'object') return null;
55
+ if (!VALID_ROLES.has(m.role)) return null;
56
+ if (typeof m.content !== 'string') return null;
57
+ const out = {
58
+ role: m.role,
59
+ content: m.content,
60
+ ts: typeof m.ts === 'string' ? m.ts : new Date().toISOString()
61
+ };
62
+ // Preserve a previously-assigned seq (appended rows carry one).
63
+ if (typeof m.seq === 'number' && Number.isFinite(m.seq)) out.seq = m.seq;
64
+ if (m.role === 'user') {
65
+ const attachments = normalizeAttachments(m.attachments);
66
+ if (attachments.length) out.attachments = attachments;
67
+ }
68
+ if (m.role === 'assistant') {
69
+ if (typeof m.reasoning === 'string') out.reasoning = m.reasoning;
70
+ if (m.usage && typeof m.usage === 'object') out.usage = m.usage;
71
+ if (m.cost && typeof m.cost === 'object') out.cost = m.cost;
72
+ if (typeof m.streamingMs === 'number') out.streamingMs = m.streamingMs;
73
+ if (typeof m.modelId === 'string') out.modelId = m.modelId;
74
+ }
75
+ if (m.role === 'tool') {
76
+ if (typeof m.toolCallId === 'string') out.toolCallId = m.toolCallId;
77
+ if (typeof m.name === 'string') out.name = m.name;
78
+ if (m.args && typeof m.args === 'object') out.args = m.args;
79
+ if (typeof m.ok === 'boolean') out.ok = m.ok;
80
+ if (typeof m.phase === 'string') out.phase = m.phase;
81
+ }
82
+ return out;
83
+ }
84
+ function getChatDb() {
85
+ return require('./chatdb.js');
86
+ }
87
+ // ---- Public surface -------------------------------------------------------
88
+ function listMessages(projectDir, chatId) {
89
+ return getChatDb().listMessages(projectDir, chatId);
90
+ }
91
+ function listMessagesWindow(projectDir, chatId, opts) {
92
+ return getChatDb().listMessagesWindow(projectDir, chatId, opts);
93
+ }
94
+ function getMessageCount(projectDir, chatId) {
95
+ return getChatDb().getMessageCount(projectDir, chatId);
96
+ }
97
+ function projectMessageCounts(projectDir, chatIds) {
98
+ return getChatDb().projectMessageCounts(projectDir, chatIds);
99
+ }
100
+ function getMessage(projectDir, chatId, index) {
101
+ if (typeof index !== 'number' || index < 0) return null;
102
+ return listMessages(projectDir, chatId)[index] || null;
103
+ }
104
+ // messageCursor(projectDir, chatId) -> { nextSeq }
105
+ //
106
+ // Cheap append-only recovery cursor for a chat transcript. `nextSeq` is
107
+ // the first persisted row the client may not have yet; if the client
108
+ // already knows the same value, there is no tail to fetch. This keeps
109
+ // streaming recovery keyed on stable message seq instead of a fuzzy
110
+ // count/timestamp revision marker.
111
+ function messageCursor(projectDir, chatId) {
112
+ return getChatDb().messageCursorDb(projectDir, chatId);
113
+ }
114
+ function appendMessage(projectDir, chatId, msg) {
115
+ if (!msg || typeof msg !== 'object') {
116
+ throw new TypeError('msg must be an object');
117
+ }
118
+ if (!VALID_ROLES.has(msg.role)) {
119
+ throw new TypeError('msg.role must be one of user | assistant | system | tool');
120
+ }
121
+ if (typeof msg.content !== 'string') {
122
+ throw new TypeError('msg.content must be a string');
123
+ }
124
+ const normalized = normalizeMessage(msg);
125
+ if (!normalized) {
126
+ throw new TypeError('msg could not be normalized');
127
+ }
128
+ return getChatDb().appendMessage(projectDir, chatId, normalized);
129
+ }
130
+ function replaceMessages(projectDir, chatId, list) {
131
+ if (!Array.isArray(list)) throw new TypeError('list must be an array');
132
+ const out = [];
133
+ for (const m of list) {
134
+ const n = normalizeMessage(m);
135
+ if (n) out.push(n);
136
+ }
137
+ return getChatDb().replaceMessages(projectDir, chatId, out);
138
+ }
139
+ function clearMessages(projectDir, chatId) {
140
+ return getChatDb().clearMessages(projectDir, chatId);
141
+ }
142
+ // addChatCost(projectDir, chatId, amount) -> { total, known, currency, knownCount } | null
143
+ //
144
+ // Attribute a priced run that is not a chat turn to a chat's persisted total
145
+ // (dictation — see docs/features/dictation.md). No message row is written: the
146
+ // run produced a draft, not a turn, and the chat and project totals are the
147
+ // only places it belongs.
148
+ function addChatCost(projectDir, chatId, amount) {
149
+ return getChatDb().addChatCost(projectDir, chatId, amount);
150
+ }
151
+ // ---- Reconstruct upstream history (unchanged) -----------------------------
152
+ function reconstructUpstreamHistory(list, contentForMessage, options) {
153
+ const source = Array.isArray(list) ? list : [];
154
+ const contentOf = typeof contentForMessage === 'function'
155
+ ? contentForMessage
156
+ : (m) => m && m.content;
157
+ const includeTools = !options || options.includeTools !== false;
158
+ const toolFeedbackMaxBytes = options && options.toolFeedbackMaxBytes;
159
+ const toolOutput = options && options.toolOutput;
160
+ const out = [];
161
+ const usedIds = new Set();
162
+ let pairNumber = 0;
163
+ for (let i = 0; i < source.length; i++) {
164
+ const m = source[i];
165
+ if (!m || typeof m !== 'object') continue;
166
+ if (m.role === 'tool') {
167
+ if (!includeTools) continue;
168
+ if (m.phase !== 'call') continue;
169
+ const result = source[i + 1];
170
+ if (!result || result.role !== 'tool' || result.phase !== 'result') continue;
171
+ const callId = typeof m.toolCallId === 'string' ? m.toolCallId : '';
172
+ const resultId = typeof result.toolCallId === 'string' ? result.toolCallId : '';
173
+ if (callId !== resultId) continue;
174
+ pairNumber++;
175
+ let wireId = callId;
176
+ if (!/^[A-Za-z0-9_-]{1,40}$/.test(wireId) || usedIds.has(wireId)) {
177
+ wireId = 'call_history_' + pairNumber.toString(36);
178
+ }
179
+ while (usedIds.has(wireId)) {
180
+ pairNumber++;
181
+ wireId = 'call_history_' + pairNumber.toString(36);
182
+ }
183
+ usedIds.add(wireId);
184
+ let args = '{}';
185
+ try { args = JSON.stringify(m.args || {}); } catch { /* keep empty object */ }
186
+ const name = m.name || result.name || 'tool';
187
+ const storedContent = typeof result.content === 'string' ? result.content : JSON.stringify(result.content || {});
188
+ let storedResult = null;
189
+ try { storedResult = JSON.parse(storedContent); } catch { /* plain-text result */ }
190
+ // During the live tool loop, explanatory text and tool_calls share
191
+ // one assistant message. Persistence keeps the text as a separate UI
192
+ // segment immediately before the call card; merge that segment back
193
+ // on replay so resumed chats serialize the same provider-cache prefix.
194
+ const previous = out[out.length - 1];
195
+ const mergePreviousAssistant = previous && previous.role === 'assistant'
196
+ && !previous.tool_calls && typeof previous.content === 'string' && previous.content.trim();
197
+ if (mergePreviousAssistant) out.pop();
198
+ out.push({
199
+ role: 'assistant',
200
+ content: mergePreviousAssistant ? previous.content : null,
201
+ tool_calls: [{ id: wireId, type: 'function', function: { name, arguments: args } }]
202
+ });
203
+ out.push({
204
+ role: 'tool', tool_call_id: wireId, name,
205
+ content: toolFeedback.compactToolFeedback({
206
+ name, content: storedContent, result: storedResult, maxBytes: toolFeedbackMaxBytes, toolOutput
207
+ })
208
+ });
209
+ i++;
210
+ continue;
211
+ }
212
+ const content = contentOf(m);
213
+ if (m.role === 'assistant' && (content == null || (typeof content === 'string' && !content.trim()))) {
214
+ continue;
215
+ }
216
+ out.push({ role: m.role, content });
217
+ }
218
+ return out;
219
+ }
220
+ module.exports = {
221
+ VALID_ROLES,
222
+ CHAT_ID_RE,
223
+ normalizeAttachments,
224
+ normalizeMessage,
225
+ assertChatId,
226
+ messagesFilePath,
227
+ listMessages,
228
+ listMessagesWindow,
229
+ getMessage,
230
+ getMessageCount,
231
+ projectMessageCounts,
232
+ messageCursor,
233
+ appendMessage,
234
+ replaceMessages,
235
+ clearMessages,
236
+ addChatCost,
237
+ reconstructUpstreamHistory
238
+ };
@@ -0,0 +1,137 @@
1
+ 'use strict';
2
+
3
+ // The per-provider live model list, shared by the two endpoints that need it.
4
+ //
5
+ // `/api/ai/models/live` (the chat picker's refresh) and
6
+ // `/api/ai/transcribe/models` (the Dictate tab) ask the same question of the
7
+ // same upstream — "what models does this provider offer right now?" — and an
8
+ // hour-long cache keyed by provider + credential hash already existed for the
9
+ // first. Dictation needs a *filtered* slice of the same answer, so the fetch
10
+ // and the cache moved here rather than being duplicated.
11
+ //
12
+ // Errors are typed and propagated; callers decide what a failure means.
13
+ // `/api/ai/models/live` maps them onto 4xx/5xx statuses, while the dictation
14
+ // catalog treats a provider that cannot answer as "no extra models" — one
15
+ // unreachable provider must not empty the whole picker.
16
+
17
+ const { ai, credentialForProvider, hashShort, modelListCacheKey, MODEL_LIST_CACHE, MODEL_LIST_TTL_MS, MODEL_LIST_TIMEOUT_MS } = require('./server-shared.js');
18
+
19
+ // liveModelsFor(provider, opts) -> { models, fetchedAt, cached }
20
+ //
21
+ // opts.force — bypass the cache (the chat picker's explicit refresh).
22
+ //
23
+ // opts.purpose — which slice of the provider's catalog to read:
24
+ // 'chat' (default) the list the model picker offers;
25
+ // 'transcription' the speech-to-text slice, read through the provider's
26
+ // `listTranscriptionModels` adapter when it has one
27
+ // (OpenRouter's /models is sliced by output modality and
28
+ // defaults to `text`, so none of its 21 speech-to-text
29
+ // models are in the chat list at all), and otherwise the
30
+ // chat list, for the caller to filter;
31
+ // 'image' the image-generation slice, read through the provider's
32
+ // `listImageModels` adapter when it has one (OpenRouter's
33
+ // /images/models, and Gemini's /v1beta/models filtered to
34
+ // the Imagen rows). A provider without a separate slice
35
+ // returns its chat list, for the caller to filter.
36
+ //
37
+ // The slices live under different cache keys. They are different upstream
38
+ // questions, and answering one with the other is exactly how dictation came
39
+ // to offer chat models that /audio/transcriptions rejects — and how the
40
+ // image picker came to offer only the eleven chat models that happen to
41
+ // report an image modality.
42
+ //
43
+ // `cached` reports whether the returned list came from the in-memory cache,
44
+ // which the HTTP layer echoes to the client; it must reflect the actual hit,
45
+ // not merely that no bypass was requested.
46
+ //
47
+ // Typed errors: `EUNKNOWN_PROVIDER`, `ENO_APIKEY`, `EUNREACHABLE`, `EUPSTREAM`
48
+ // (with `.status`), `ETIMEOUT`. The http-server maps them the same way it
49
+ // always did.
50
+ async function liveModelsFor(provider, opts) {
51
+ if (!provider || !ai.ENDPOINTS[provider]) {
52
+ const e = new Error('unknown provider');
53
+ e.code = 'EUNKNOWN_PROVIDER';
54
+ throw e;
55
+ }
56
+ const purpose = opts && opts.purpose === 'transcription' ? 'transcription'
57
+ : (opts && opts.purpose === 'image' ? 'image' : 'chat');
58
+ let cred = null;
59
+ try { cred = credentialForProvider(provider); }
60
+ catch { /* the adapter surfaces ENO_APIKEY when a credential is required */ }
61
+
62
+ // Keyed by provider + credential hash + slice so rotating a key cannot serve
63
+ // the previous account's list, and so the two slices cannot cross over.
64
+ const cacheKey = modelListCacheKey(provider, cred ? hashShort(cred) : '-', purpose);
65
+ const now = Date.now();
66
+ if (opts && opts.force) MODEL_LIST_CACHE.delete(cacheKey);
67
+ const cached = MODEL_LIST_CACHE.get(cacheKey);
68
+ if (cached && (now - cached.fetchedAt) < MODEL_LIST_TTL_MS) {
69
+ return { models: cached.models, fetchedAt: cached.fetchedAt, cached: true };
70
+ }
71
+
72
+ // Bound the call so a slow upstream cannot hang the request.
73
+ const ac = new AbortController();
74
+ let timedOut = false;
75
+ const timer = setTimeout(() => { timedOut = true; ac.abort(); }, MODEL_LIST_TIMEOUT_MS);
76
+ try {
77
+ // A provider with a separate catalogue answers this slice; every other
78
+ // provider returns null here and the chat list is filtered by the caller
79
+ // instead.
80
+ const sliced = purpose === 'transcription'
81
+ ? await ai.listTranscriptionModels(provider, cred || null, ac.signal)
82
+ : (purpose === 'image' ? await ai.listImageModels(provider, cred || null, ac.signal) : null);
83
+ const models = sliced || await ai.listModels(provider, cred || null, ac.signal);
84
+ clearTimeout(timer);
85
+ // Discard the late result: the caller already saw the timeout.
86
+ if (timedOut) {
87
+ const e = new Error('Timed out after ' + Math.round(MODEL_LIST_TIMEOUT_MS / 1000) + 's waiting for ' + provider + ' upstream');
88
+ e.code = 'ETIMEOUT';
89
+ e.status = 504;
90
+ throw e;
91
+ }
92
+ const fetchedAt = Date.now();
93
+ MODEL_LIST_CACHE.set(cacheKey, { models, fetchedAt });
94
+ return { models, fetchedAt, cached: false };
95
+ } catch (err) {
96
+ clearTimeout(timer);
97
+ if (timedOut || (err && err.code === 'ETIMEOUT')) {
98
+ const e = new Error('Timed out after ' + Math.round(MODEL_LIST_TIMEOUT_MS / 1000) + 's waiting for ' + provider + ' upstream');
99
+ e.code = 'ETIMEOUT';
100
+ e.status = 504;
101
+ throw e;
102
+ }
103
+ throw err;
104
+ }
105
+ }
106
+
107
+ // liveModelsForMany(providers, opts) -> { list, failures }
108
+ //
109
+ // Best-effort across providers, for the dictation catalog: a provider that
110
+ // cannot answer contributes nothing and is reported in `failures` instead of
111
+ // failing the whole request. Rows are tagged with their provider so a caller
112
+ // that merges several catalogs can tell them apart.
113
+ async function liveModelsForMany(providers, opts) {
114
+ const list = [];
115
+ const failures = [];
116
+ const results = await Promise.all((Array.isArray(providers) ? providers : []).map(async (provider) => {
117
+ try {
118
+ const result = await liveModelsFor(provider, opts);
119
+ return { provider, models: result.models };
120
+ } catch (e) {
121
+ return { provider, error: e };
122
+ }
123
+ }));
124
+ for (const result of results) {
125
+ if (result.error) {
126
+ failures.push({ provider: result.provider, code: result.error.code || 'EUPSTREAM', error: result.error.message || String(result.error) });
127
+ continue;
128
+ }
129
+ for (const m of (result.models || [])) {
130
+ if (!m || !m.id) continue;
131
+ list.push(Object.assign({}, m, { provider: result.provider }));
132
+ }
133
+ }
134
+ return { list, failures };
135
+ }
136
+
137
+ module.exports = { liveModelsFor, liveModelsForMany };
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+ // App-level browser notification preferences.
3
+ //
4
+ // The persisted `notifications` object has grown over time. Older stores
5
+ // held five booleans (progress/completion/errors and
6
+ // askUser/toolAuthorization); the current shape is a flat set of named
7
+ // channels. This module is the single server-side normalizer so every
8
+ // sender (chat streaming, access sign-in) agrees on the defaults.
9
+ //
10
+ // Kept in sync with normalizePreferences() in
11
+ // frontend/src/components/SettingsNotifications.jsx.
12
+ const DEFAULT_NOTIFICATION_PREFS = Object.freeze({
13
+ // One replaceable ASCII status per chat: progress, completion, errors.
14
+ status: true,
15
+ // Questions and tool-authorization requests (the "attention" slot).
16
+ authorization: true,
17
+ // Answer/allow/deny directly from the notification when supported.
18
+ quickActions: true,
19
+ // A sign-in to this server (password or passkey). On by default: a new
20
+ // sign-in is exactly the event a user wants to know about, and it can be
21
+ // turned off from Settings → Notifications.
22
+ login: true
23
+ });
24
+
25
+ // resolveNotificationPrefs(saved) — collapse the persisted settings into
26
+ // the current key shape. New stores hold these keys directly; older
27
+ // stores only had the legacy five booleans, which are derived here.
28
+ function resolveNotificationPrefs(saved) {
29
+ const prefs = saved || {};
30
+ return {
31
+ status: prefs.status !== undefined
32
+ ? prefs.status === true
33
+ : prefs.progress !== false && prefs.completion !== false && prefs.errors !== false,
34
+ authorization: prefs.authorization !== undefined
35
+ ? prefs.authorization === true
36
+ : prefs.askUser !== false && prefs.toolAuthorization !== false,
37
+ quickActions: prefs.quickActions !== false,
38
+ login: prefs.login !== undefined ? prefs.login === true : DEFAULT_NOTIFICATION_PREFS.login
39
+ };
40
+ }
41
+
42
+ // notificationEnabled(saved, key) — resolve + read one channel in a single
43
+ // step for callers that only care about a boolean gate.
44
+ function notificationEnabled(saved, key) {
45
+ return resolveNotificationPrefs(saved)[key] === true;
46
+ }
47
+
48
+ module.exports = {
49
+ DEFAULT_NOTIFICATION_PREFS,
50
+ resolveNotificationPrefs,
51
+ notificationEnabled
52
+ };