mouaif 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/bin/mouaif.js +281 -0
  4. package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
  5. package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
  6. package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
  7. package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
  8. package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
  9. package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
  10. package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
  11. package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
  12. package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
  13. package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
  14. package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
  15. package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
  16. package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
  17. package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
  18. package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
  19. package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
  20. package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
  21. package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
  22. package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
  23. package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
  24. package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
  25. package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
  26. package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
  27. package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
  28. package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
  29. package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
  30. package/frontend/dist/assets/index-CANPYzQg.css +1 -0
  31. package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
  32. package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
  33. package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
  34. package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
  35. package/frontend/dist/icons/favicon-32.png +0 -0
  36. package/frontend/dist/icons/icon-180-apple.png +0 -0
  37. package/frontend/dist/icons/icon-192.png +0 -0
  38. package/frontend/dist/icons/icon-512.png +0 -0
  39. package/frontend/dist/icons/icon-maskable-512.png +0 -0
  40. package/frontend/dist/index.html +83 -0
  41. package/frontend/dist/manifest.webmanifest +33 -0
  42. package/frontend/dist/sw.js +482 -0
  43. package/package.json +98 -0
  44. package/scripts/patch-zimmerframe.js +58 -0
  45. package/src/access-auth.js +515 -0
  46. package/src/agentFeatures.js +294 -0
  47. package/src/agentFiles.js +164 -0
  48. package/src/agentSkills.js +147 -0
  49. package/src/agents.js +230 -0
  50. package/src/ai-chat.js +21 -0
  51. package/src/ai-endpoints.js +1880 -0
  52. package/src/ai-stream.js +2048 -0
  53. package/src/ai.js +68 -0
  54. package/src/auth.js +391 -0
  55. package/src/chatdb.js +816 -0
  56. package/src/chats.js +275 -0
  57. package/src/custom-actions.js +65 -0
  58. package/src/files.js +431 -0
  59. package/src/hideFileContent.js +327 -0
  60. package/src/http-server.js +535 -0
  61. package/src/index.js +15 -0
  62. package/src/inspector.js +731 -0
  63. package/src/inspectorProfiles.js +503 -0
  64. package/src/live-chat.js +107 -0
  65. package/src/mcp.js +1517 -0
  66. package/src/messages.js +238 -0
  67. package/src/modelList.js +137 -0
  68. package/src/notifications.js +52 -0
  69. package/src/oauth-anthropic.js +280 -0
  70. package/src/oauth-github-copilot.js +417 -0
  71. package/src/oauth-mcp.js +216 -0
  72. package/src/oauth-openrouter.js +285 -0
  73. package/src/package-version.js +20 -0
  74. package/src/projects.js +285 -0
  75. package/src/promptProfiles.js +256 -0
  76. package/src/prompts.js +384 -0
  77. package/src/providerShapes.js +44 -0
  78. package/src/providers/base.js +41 -0
  79. package/src/providers/index.js +25 -0
  80. package/src/push.js +315 -0
  81. package/src/qr.js +192 -0
  82. package/src/restart.js +47 -0
  83. package/src/server-handlers-access.js +306 -0
  84. package/src/server-handlers-actions.js +100 -0
  85. package/src/server-handlers-ai.js +248 -0
  86. package/src/server-handlers-auth.js +273 -0
  87. package/src/server-handlers-chats.js +1436 -0
  88. package/src/server-handlers-git.js +467 -0
  89. package/src/server-handlers-mcp-oauth.js +56 -0
  90. package/src/server-handlers-misc.js +783 -0
  91. package/src/server-handlers-projects.js +289 -0
  92. package/src/server-handlers-prompts.js +259 -0
  93. package/src/server-handlers-push.js +102 -0
  94. package/src/server-handlers-settings.js +406 -0
  95. package/src/server-handlers-tools.js +654 -0
  96. package/src/server-handlers-transcribe.js +399 -0
  97. package/src/server-shared.js +780 -0
  98. package/src/server-web-static.js +191 -0
  99. package/src/settings.js +898 -0
  100. package/src/statusBar.js +541 -0
  101. package/src/tags.js +414 -0
  102. package/src/toolFeedback.js +225 -0
  103. package/src/tools/ask.js +154 -0
  104. package/src/tools/authorization.js +932 -0
  105. package/src/tools/files.js +1150 -0
  106. package/src/tools/progress.js +71 -0
  107. package/src/tools/restart.js +32 -0
  108. package/src/tools/searchEngine.js +957 -0
  109. package/src/tools/shell.js +341 -0
  110. package/src/tools/subagent.js +47 -0
  111. package/src/tools/task.js +234 -0
  112. package/src/tools/webpreview.js +448 -0
  113. package/src/trace.js +103 -0
  114. package/src/transcribe.js +683 -0
  115. package/src/usage.js +389 -0
  116. package/src/util.js +151 -0
package/src/push.js ADDED
@@ -0,0 +1,315 @@
1
+ 'use strict';
2
+
3
+ // mouaif push notification subsystem
4
+ //
5
+ // Three layers:
6
+ // 1. VAPID key management (auto-generated, stored in SQLite)
7
+ // 2. Push subscription CRUD (per-session)
8
+ // 3. Push sending utility (tagged per-chat for updatable notifications)
9
+ //
10
+ // The ASCII status bar that rides every chat notification lives in
11
+ // src/statusBar.js: it decides the bar's cell count from the receiving
12
+ // device's screen size, notification style, and OS version. This module only
13
+ // stores the device's self-report and resolves it per target at send time.
14
+
15
+ const webpush = require('web-push');
16
+ const crypto = require('crypto');
17
+ const settings = require('./settings.js');
18
+ const statusBar = require('./statusBar.js');
19
+
20
+ const SUB_TABLE = 'push_subscriptions';
21
+ const VAPID_TABLE = 'push_vapid';
22
+ const VAPID_SUBJECT = 'mailto:push@mouaif.local';
23
+
24
+ // Fields a device may report about itself for status-bar sizing. Anything
25
+ // else in the subscribe body is ignored, so a malformed client cannot grow an
26
+ // unbounded blob in the subscription row.
27
+ const PROFILE_MAX_CHARS = 512;
28
+
29
+ // normalizeStatusBarProfile(value) -> JSON string | null
30
+ //
31
+ // Keep only the known sizing fields and only when they carry a usable value,
32
+ // so the stored profile is small and statusBarJs.statusBarPlan() can trust
33
+ // its types. Returns null when nothing usable was reported, so a rebind can
34
+ // preserve the previously stored profile.
35
+ function normalizeStatusBarProfile(value) {
36
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
37
+ const out = {};
38
+ const chars = Number(value.chars);
39
+ if (Number.isFinite(chars) && chars > 0) out.chars = Math.round(chars);
40
+ const viewportWidth = Number(value.viewportWidth);
41
+ if (Number.isFinite(viewportWidth) && viewportWidth > 0) out.viewportWidth = Math.round(viewportWidth);
42
+ const os = statusBar.normalizeOs(value.os);
43
+ if (os) out.os = os;
44
+ const osVersion = Number(value.osVersion);
45
+ if (Number.isFinite(osVersion) && osVersion > 0) out.osVersion = Math.round(osVersion);
46
+ const style = String(value.style || '').trim().toLowerCase();
47
+ if (style === 'collapsed' || style === 'expanded') out.style = style;
48
+ if (!Object.keys(out).length) return null;
49
+ const json = JSON.stringify(out);
50
+ return json.length <= PROFILE_MAX_CHARS ? json : null;
51
+ }
52
+
53
+ // statusBarFromLegacyChars(value) -> integer | null
54
+ //
55
+ // The original subscribe shape sent a bare character count. Keep honoring it
56
+ // so a client that predates the richer profile still sizes its bar.
57
+ function statusBarFromLegacyChars(value) {
58
+ const n = Number(value);
59
+ return Number.isFinite(n) && n > 0 ? Math.round(n) : null;
60
+ }
61
+
62
+ // ---- VAPID keys ---------------------------------------------------------
63
+
64
+ function vapidSubjectForOrigin(origin) {
65
+ if (!origin) return VAPID_SUBJECT;
66
+ try {
67
+ const parsed = new URL(origin);
68
+ // web-push accepts mailto: or HTTPS VAPID subjects. Public iOS Web Push
69
+ // origins are HTTPS, so using the served origin gives Apple a stable,
70
+ // deployment-specific contact without requiring APNs credentials.
71
+ if (parsed.protocol === 'https:') return parsed.origin;
72
+ } catch { /* use the local fallback */ }
73
+ return VAPID_SUBJECT;
74
+ }
75
+
76
+ function readVapidKeys() {
77
+ const db = settings.getDb();
78
+ const publicKey = db.prepare(`SELECT value FROM ${VAPID_TABLE} WHERE key = 'publicKey'`).pluck().get() || null;
79
+ const privateKey = db.prepare(`SELECT value FROM ${VAPID_TABLE} WHERE key = 'privateKey'`).pluck().get() || null;
80
+ return { publicKey, privateKey };
81
+ }
82
+
83
+ function ensureVapidKeys(origin) {
84
+ const db = settings.getDb();
85
+ const existing = readVapidKeys();
86
+ const subject = vapidSubjectForOrigin(origin);
87
+ if (existing.publicKey && existing.privateKey) {
88
+ webpush.setVapidDetails(subject, existing.publicKey, existing.privateKey);
89
+ return existing.publicKey;
90
+ }
91
+ const keys = webpush.generateVAPIDKeys();
92
+ const now = new Date().toISOString();
93
+ const save = db.prepare(`INSERT INTO ${VAPID_TABLE} (key, value, created_at) VALUES (?, ?, ?)
94
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, created_at = excluded.created_at`);
95
+ const saveKeys = db.transaction(() => {
96
+ save.run('publicKey', keys.publicKey, now);
97
+ save.run('privateKey', keys.privateKey, now);
98
+ });
99
+ saveKeys();
100
+ webpush.setVapidDetails(subject, keys.publicKey, keys.privateKey);
101
+ return keys.publicKey;
102
+ }
103
+
104
+ function getVapidPublicKey() {
105
+ const db = settings.getDb();
106
+ return db.prepare(`SELECT value FROM ${VAPID_TABLE} WHERE key = 'publicKey'`).pluck().get() || null;
107
+ }
108
+
109
+ function getPushConfig(origin) {
110
+ const publicKey = ensureVapidKeys(origin);
111
+ const keys = readVapidKeys();
112
+ return {
113
+ origin: origin || null,
114
+ subject: vapidSubjectForOrigin(origin),
115
+ publicKey,
116
+ privateKeyConfigured: !!keys.privateKey
117
+ };
118
+ }
119
+
120
+ // ---- Subscription CRUD --------------------------------------------------
121
+
122
+ function ensureTable() {
123
+ const db = settings.getDb();
124
+ db.exec(`CREATE TABLE IF NOT EXISTS ${SUB_TABLE} (
125
+ id TEXT PRIMARY KEY,
126
+ session_id TEXT NOT NULL,
127
+ endpoint TEXT NOT NULL UNIQUE,
128
+ p256dh TEXT NOT NULL,
129
+ auth TEXT NOT NULL,
130
+ origin TEXT,
131
+ status_bar INTEGER,
132
+ status_bar_profile TEXT,
133
+ created_at TEXT NOT NULL,
134
+ updated_at TEXT NOT NULL
135
+ )`);
136
+ db.exec(`CREATE TABLE IF NOT EXISTS ${VAPID_TABLE} (
137
+ key TEXT PRIMARY KEY,
138
+ value TEXT NOT NULL,
139
+ created_at TEXT NOT NULL
140
+ )`);
141
+ // Devices subscribed before the status bar became device-sized have no
142
+ // stored report; they keep the conservative phone plan until the page
143
+ // rebinds its endpoint (every startup sync re-registers, so this heals).
144
+ // `status_bar` is the original bare character count; `status_bar_profile`
145
+ // is the current shape (measured chars, viewport, OS, OS version, style).
146
+ const cols = db.prepare(`PRAGMA table_info('${SUB_TABLE}')`).all();
147
+ const names = new Set(cols.map((c) => c.name));
148
+ if (!names.has('status_bar')) {
149
+ db.exec(`ALTER TABLE ${SUB_TABLE} ADD COLUMN status_bar INTEGER`);
150
+ }
151
+ if (!names.has('status_bar_profile')) {
152
+ db.exec(`ALTER TABLE ${SUB_TABLE} ADD COLUMN status_bar_profile TEXT`);
153
+ }
154
+ }
155
+
156
+ function listSubscriptions(sessionId) {
157
+ if (!sessionId) return [];
158
+ const db = settings.getDb();
159
+ return db.prepare(`SELECT id, endpoint, p256dh, auth, origin, status_bar, status_bar_profile, created_at FROM ${SUB_TABLE} WHERE session_id = ? ORDER BY created_at ASC`).all(sessionId);
160
+ }
161
+ // Every subscription, across sessions — used by the sign-in alert, which
162
+ // must reach the user's other already-signed-in devices. A login mints a
163
+ // brand-new session whose own subscription list is empty until the page
164
+ // rebinds its endpoint, so a per-session send would reach nothing.
165
+ function listAllSubscriptions() {
166
+ const db = settings.getDb();
167
+ return db.prepare(`SELECT id, session_id, endpoint, p256dh, auth, origin, status_bar, status_bar_profile, created_at FROM ${SUB_TABLE} ORDER BY created_at ASC`).all();
168
+ }
169
+
170
+
171
+ function addSubscription({ sessionId, endpoint, p256dh, auth, origin, statusBarMaxChars, statusBarProfile }) {
172
+ const db = settings.getDb();
173
+ const now = new Date().toISOString();
174
+ const existing = db.prepare(`SELECT id, created_at, status_bar, status_bar_profile FROM ${SUB_TABLE} WHERE endpoint = ?`).get(endpoint);
175
+ const id = existing ? existing.id : crypto.randomUUID();
176
+ // The device reports how it presents notifications (measured body line,
177
+ // viewport, OS, OS version, style). An absent report (an older page, a curl
178
+ // client) keeps whatever this device last stored — a stale client must not
179
+ // reset a good profile, and a rebind is the normal path that refines it.
180
+ const profileJson = normalizeStatusBarProfile(statusBarProfile)
181
+ || (existing && existing.status_bar_profile)
182
+ || null;
183
+ const legacyChars = statusBarFromLegacyChars(statusBarMaxChars);
184
+ const statusBarValue = legacyChars != null
185
+ ? legacyChars
186
+ : (existing && existing.status_bar) || null;
187
+ db.prepare(`INSERT INTO ${SUB_TABLE} (id, session_id, endpoint, p256dh, auth, origin, status_bar, status_bar_profile, created_at, updated_at)
188
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
189
+ ON CONFLICT(endpoint) DO UPDATE SET
190
+ session_id = excluded.session_id,
191
+ p256dh = excluded.p256dh,
192
+ auth = excluded.auth,
193
+ origin = excluded.origin,
194
+ status_bar = excluded.status_bar,
195
+ status_bar_profile = excluded.status_bar_profile,
196
+ updated_at = excluded.updated_at`)
197
+ .run(id, sessionId, endpoint, p256dh, auth, origin || null, statusBarValue, profileJson, existing ? existing.created_at : now, now);
198
+ return { id, endpoint, origin };
199
+ }
200
+
201
+ function removeSubscription(endpoint, sessionId) {
202
+ const db = settings.getDb();
203
+ const info = sessionId
204
+ ? db.prepare(`DELETE FROM ${SUB_TABLE} WHERE endpoint = ? AND session_id = ?`).run(endpoint, sessionId)
205
+ : db.prepare(`DELETE FROM ${SUB_TABLE} WHERE endpoint = ?`).run(endpoint);
206
+ return info.changes > 0;
207
+ }
208
+
209
+ function removeAllSubscriptions(sessionId) {
210
+ const db = settings.getDb();
211
+ db.prepare(`DELETE FROM ${SUB_TABLE} WHERE session_id = ?`).run(sessionId);
212
+ }
213
+
214
+ // ---- Push sending -------------------------------------------------------
215
+
216
+ // sendPush({ ... , body, bodyFor }) — deliver one notification to every
217
+ // target subscription.
218
+ //
219
+ // `body` is the device-independent text; `bodyFor(sub)` is the status-bar
220
+ // escape hatch: a status push passes a function that builds its body from the
221
+ // *subscription's* own device plan, so each device gets a bar sized to its
222
+ // screen, notification style, and OS version (see src/statusBar.js). A push
223
+ // without `bodyFor` sends the same `body` everywhere, which is what
224
+ // authorization and sign-in alerts do.
225
+ function sendPush({ sessionId, subs, title, body, bodyFor, tag, data, chatId, projectDir, actions, requireInteraction }) {
226
+ const targets = Array.isArray(subs) ? subs : (sessionId ? listSubscriptions(sessionId) : []);
227
+ if (!targets.length) return;
228
+
229
+
230
+ const keys = readVapidKeys();
231
+
232
+ for (const sub of targets) {
233
+ let targetBody = body;
234
+ if (typeof bodyFor === 'function') {
235
+ try {
236
+ const resolved = bodyFor(sub);
237
+ if (typeof resolved === 'string') targetBody = resolved;
238
+ } catch { /* fall back to the shared body */ }
239
+ }
240
+ const payload = JSON.stringify({
241
+ title: title || 'mouaif',
242
+ body: targetBody || '',
243
+ tag: tag || (chatId ? `chat-${chatId}` : undefined),
244
+ renotify: true,
245
+ icon: '/icons/icon-192.png',
246
+ badge: '/icons/favicon-32.png',
247
+ data: data || { chatId, projectDir, url: chatId && projectDir ? `/#/chat/${chatId}?projectDir=${encodeURIComponent(projectDir)}` : '/' },
248
+ actions: Array.isArray(actions) && actions.length
249
+ ? actions
250
+ : [{ action: 'open', title: 'Open chat' }],
251
+ requireInteraction: requireInteraction === true
252
+ });
253
+
254
+ const subscription = {
255
+ endpoint: sub.endpoint,
256
+ keys: { p256dh: sub.p256dh, auth: sub.auth }
257
+ };
258
+ const options = keys.publicKey && keys.privateKey ? {
259
+ vapidDetails: {
260
+ subject: vapidSubjectForOrigin(sub.origin),
261
+ publicKey: keys.publicKey,
262
+ privateKey: keys.privateKey
263
+ }
264
+ } : undefined;
265
+ webpush.sendNotification(subscription, payload, options).catch((err) => {
266
+ // 410 Gone / 404 Not Found means the subscription is dead
267
+ if (err && (err.statusCode === 410 || err.statusCode === 404)) {
268
+ removeSubscription(sub.endpoint);
269
+ }
270
+ });
271
+ }
272
+ }
273
+
274
+ // sendPushToSession — send a push notification to a specific session.
275
+ // Called from handleChatStream for attention, completion, and error events.
276
+ function sendPushToSession(sessionId, { title, body, bodyFor, chatId, projectDir, tag, data, actions, requireInteraction }) {
277
+ sendPush({ sessionId, title, body, bodyFor, tag, chatId, projectDir, data, actions, requireInteraction });
278
+ }
279
+ // sendPushToAll — send to every subscribed endpoint regardless of session.
280
+ // Used by the sign-in alert, which must reach the user's other devices even
281
+ // though the login created a brand-new session.
282
+ function sendPushToAll({ title, body, bodyFor, chatId, projectDir, tag, data, actions, requireInteraction }) {
283
+ sendPush({ subs: listAllSubscriptions(), title, body, bodyFor, tag, chatId, projectDir, data, actions, requireInteraction });
284
+ }
285
+
286
+
287
+ // ---- Session ID helpers -------------------------------------------------
288
+
289
+ // Derive a stable session id from the session token. One-way hash so the
290
+ // token itself is never stored alongside subscriptions.
291
+ function sessionIdFromToken(token) {
292
+ if (!token) return null;
293
+ return crypto.createHash('sha256').update('push:' + token).digest('hex').slice(0, 16);
294
+ }
295
+
296
+ module.exports = {
297
+ ensureTable,
298
+ ensureVapidKeys,
299
+ getVapidPublicKey,
300
+ getPushConfig,
301
+ vapidSubjectForOrigin,
302
+ normalizeStatusBarProfile,
303
+ // Status-bar surface re-exported so senders have one import for "build a
304
+ // status body": `push.statusBar` is src/statusBar.js.
305
+ statusBar,
306
+ listSubscriptions,
307
+ listAllSubscriptions,
308
+ addSubscription,
309
+ removeSubscription,
310
+ removeAllSubscriptions,
311
+ sendPush,
312
+ sendPushToSession,
313
+ sendPushToAll,
314
+ sessionIdFromToken
315
+ };
package/src/qr.js ADDED
@@ -0,0 +1,192 @@
1
+ 'use strict';
2
+
3
+ // Small dependency-free QR encoder for setup URLs. Byte mode, EC level L,
4
+ // versions 1–10 (enough for normal mouaif origins and setup codes).
5
+
6
+ const BLOCKS_L = [
7
+ null,
8
+ [[1, 26, 19]], [[1, 44, 34]], [[1, 70, 55]], [[1, 100, 80]],
9
+ [[1, 134, 108]], [[2, 86, 68]], [[2, 98, 78]], [[2, 121, 97]],
10
+ [[2, 146, 116]], [[2, 86, 68], [2, 87, 69]]
11
+ ];
12
+ const ALIGN = [null, [], [6, 18], [6, 22], [6, 26], [6, 30], [6, 34], [6, 22, 38], [6, 24, 42], [6, 26, 46], [6, 28, 50]];
13
+ const EXP = new Array(512);
14
+ const LOG = new Array(256);
15
+ let value = 1;
16
+ for (let i = 0; i < 255; i++) { EXP[i] = value; LOG[value] = i; value <<= 1; if (value & 0x100) value ^= 0x11d; }
17
+ for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
18
+
19
+ function append(bits, value, length) {
20
+ for (let i = length - 1; i >= 0; i--) bits.push((value >>> i) & 1);
21
+ }
22
+
23
+ function multiplyPoly(a, b) {
24
+ const out = new Array(a.length + b.length - 1).fill(0);
25
+ for (let i = 0; i < a.length; i++) for (let j = 0; j < b.length; j++) {
26
+ if (a[i] && b[j]) out[i + j] ^= EXP[LOG[a[i]] + LOG[b[j]]];
27
+ }
28
+ return out;
29
+ }
30
+
31
+ function errorCodewords(data, count) {
32
+ let generator = [1];
33
+ for (let i = 0; i < count; i++) generator = multiplyPoly(generator, [1, EXP[i]]);
34
+ const work = data.concat(new Array(count).fill(0));
35
+ for (let i = 0; i < data.length; i++) {
36
+ const factor = work[i];
37
+ if (!factor) continue;
38
+ const log = LOG[factor];
39
+ for (let j = 0; j < generator.length; j++) if (generator[j]) work[i + j] ^= EXP[LOG[generator[j]] + log];
40
+ }
41
+ return work.slice(data.length);
42
+ }
43
+
44
+ function blocksFor(version) {
45
+ const out = [];
46
+ for (const [count, total, data] of BLOCKS_L[version]) for (let i = 0; i < count; i++) out.push({ total, data });
47
+ return out;
48
+ }
49
+
50
+ function codewords(text, version) {
51
+ const bytes = Buffer.from(String(text), 'utf8');
52
+ const blocks = blocksFor(version);
53
+ const dataCount = blocks.reduce((sum, block) => sum + block.data, 0);
54
+ const bits = [];
55
+ append(bits, 4, 4); // byte mode
56
+ append(bits, bytes.length, version < 10 ? 8 : 16);
57
+ for (const byte of bytes) append(bits, byte, 8);
58
+ if (bits.length + 4 <= dataCount * 8) append(bits, 0, 4);
59
+ while (bits.length % 8) bits.push(0);
60
+ const data = [];
61
+ for (let i = 0; i < bits.length; i += 8) {
62
+ let byte = 0;
63
+ for (let j = 0; j < 8; j++) byte = (byte << 1) | bits[i + j];
64
+ data.push(byte);
65
+ }
66
+ for (let pad = 0; data.length < dataCount; pad++) data.push(pad % 2 ? 0x11 : 0xec);
67
+ const chunks = [];
68
+ const errors = [];
69
+ let offset = 0;
70
+ for (const block of blocks) {
71
+ const chunk = data.slice(offset, offset + block.data);
72
+ offset += block.data;
73
+ chunks.push(chunk);
74
+ errors.push(errorCodewords(chunk, block.total - block.data));
75
+ }
76
+ const out = [];
77
+ const maxData = Math.max(...chunks.map((chunk) => chunk.length));
78
+ const maxError = Math.max(...errors.map((chunk) => chunk.length));
79
+ for (let i = 0; i < maxData; i++) for (const chunk of chunks) if (i < chunk.length) out.push(chunk[i]);
80
+ for (let i = 0; i < maxError; i++) for (const chunk of errors) if (i < chunk.length) out.push(chunk[i]);
81
+ return out;
82
+ }
83
+
84
+ function bch(value, polynomial) {
85
+ let shifted = value;
86
+ const degree = (number) => 31 - Math.clz32(number);
87
+ while (degree(shifted) >= degree(polynomial)) shifted ^= polynomial << (degree(shifted) - degree(polynomial));
88
+ return shifted;
89
+ }
90
+
91
+ function finder(matrix, row, col) {
92
+ const size = matrix.length;
93
+ for (let r = -1; r <= 7; r++) for (let c = -1; c <= 7; c++) {
94
+ if (row + r < 0 || row + r >= size || col + c < 0 || col + c >= size) continue;
95
+ matrix[row + r][col + c] = r >= 0 && r <= 6 && c >= 0 && c <= 6 && (r === 0 || r === 6 || c === 0 || c === 6 || (r >= 2 && r <= 4 && c >= 2 && c <= 4));
96
+ }
97
+ }
98
+
99
+ function makeMatrix(text) {
100
+ const bytes = Buffer.from(String(text), 'utf8');
101
+ let version = 1;
102
+ while (version <= 10) {
103
+ const dataCount = blocksFor(version).reduce((sum, block) => sum + block.data, 0);
104
+ const lengthBits = version < 10 ? 8 : 16;
105
+ if (4 + lengthBits + bytes.length * 8 <= dataCount * 8) break;
106
+ version++;
107
+ }
108
+ if (version > 10) throw new Error('QR content is too long');
109
+ const size = version * 4 + 17;
110
+ const matrix = Array.from({ length: size }, () => Array(size).fill(null));
111
+ finder(matrix, 0, 0); finder(matrix, size - 7, 0); finder(matrix, 0, size - 7);
112
+ for (let i = 8; i < size - 8; i++) {
113
+ if (matrix[i][6] === null) matrix[i][6] = i % 2 === 0;
114
+ if (matrix[6][i] === null) matrix[6][i] = i % 2 === 0;
115
+ }
116
+ for (const row of ALIGN[version]) for (const col of ALIGN[version]) {
117
+ if (matrix[row][col] !== null) continue;
118
+ for (let r = -2; r <= 2; r++) for (let c = -2; c <= 2; c++) matrix[row + r][col + c] = Math.max(Math.abs(r), Math.abs(c)) !== 1;
119
+ }
120
+ if (version >= 7) {
121
+ const bits = (version << 12) | bch(version << 12, 0x1f25);
122
+ for (let i = 0; i < 18; i++) {
123
+ const bit = ((bits >> i) & 1) === 1;
124
+ matrix[Math.floor(i / 3)][i % 3 + size - 11] = bit;
125
+ matrix[i % 3 + size - 11][Math.floor(i / 3)] = bit;
126
+ }
127
+ }
128
+ // Error correction L has format bits 01. Use mask pattern 0.
129
+ const format = ((1 << 3) | 0) << 10;
130
+ const formatBits = (format | bch(format, 0x537)) ^ 0x5412;
131
+ for (let i = 0; i < 15; i++) {
132
+ const bit = ((formatBits >> i) & 1) === 1;
133
+ if (i < 6) matrix[i][8] = bit;
134
+ else if (i < 8) matrix[i + 1][8] = bit;
135
+ else matrix[size - 15 + i][8] = bit;
136
+ if (i < 8) matrix[8][size - i - 1] = bit;
137
+ else if (i === 8) matrix[8][7] = bit;
138
+ else matrix[8][15 - i - 1] = bit;
139
+ }
140
+ matrix[size - 8][8] = true;
141
+ const data = codewords(text, version);
142
+ let bitIndex = 0;
143
+ let upward = true;
144
+ for (let right = size - 1; right > 0; right -= 2) {
145
+ if (right === 6) right--;
146
+ for (let step = 0; step < size; step++) {
147
+ const row = upward ? size - 1 - step : step;
148
+ for (let side = 0; side < 2; side++) {
149
+ const col = right - side;
150
+ if (matrix[row][col] !== null) continue;
151
+ const byte = data[Math.floor(bitIndex / 8)] || 0;
152
+ let bit = ((byte >>> (7 - (bitIndex % 8))) & 1) === 1;
153
+ if ((row + col) % 2 === 0) bit = !bit; // mask 0
154
+ matrix[row][col] = bit;
155
+ bitIndex++;
156
+ }
157
+ }
158
+ upward = !upward;
159
+ }
160
+ return matrix;
161
+ }
162
+
163
+ function svg(text, options = {}) {
164
+ const matrix = makeMatrix(text);
165
+ const quiet = 4;
166
+ const size = matrix.length + quiet * 2;
167
+ let path = '';
168
+ for (let y = 0; y < matrix.length; y++) for (let x = 0; x < matrix.length; x++) if (matrix[y][x]) path += `M${x + quiet} ${y + quiet}h1v1h-1z`;
169
+ const px = Number(options.size) || 256;
170
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${px}" height="${px}" role="img" aria-label="Setup QR code"><rect width="100%" height="100%" fill="#fff"/><path d="${path}" fill="#000" shape-rendering="crispEdges"/></svg>`;
171
+ }
172
+
173
+ function terminal(text) {
174
+ const matrix = makeMatrix(text);
175
+ const quiet = 2;
176
+ const rows = [];
177
+ const width = matrix.length + quiet * 2;
178
+ const white = new Array(width).fill(false);
179
+ const padded = [white, white, ...matrix.map((row) => [...new Array(quiet).fill(false), ...row, ...new Array(quiet).fill(false)]), white, white];
180
+ // Force a white cell background and black modules rather than relying on
181
+ // the terminal theme (a light foreground on a dark terminal inverts QR).
182
+ for (let y = 0; y < padded.length; y += 2) {
183
+ let line = '\x1b[30;47m';
184
+ const top = padded[y];
185
+ const bottom = padded[y + 1] || white;
186
+ for (let x = 0; x < width; x++) line += top[x] ? (bottom[x] ? '\x1b[40m \x1b[47m' : '▀') : (bottom[x] ? '▄' : ' ');
187
+ rows.push(line + '\x1b[0m');
188
+ }
189
+ return rows.join('\n');
190
+ }
191
+
192
+ module.exports = { makeMatrix, svg, terminal };
package/src/restart.js ADDED
@@ -0,0 +1,47 @@
1
+ 'use strict';
2
+ // Shared graceful-restart scheduler used by the HTTP API and the native
3
+ // restart_app chat tool. The caller sends its response/tool result first;
4
+ // the delayed callback then stops MCP children and hands control back to
5
+ // the serve supervisor through lifecycle.restart().
6
+ const mcp = require('./mcp.js');
7
+
8
+ function clampDelay(value, fallback = 150) {
9
+ return Number.isFinite(value) ? Math.max(0, Math.min(Math.round(value), 5000)) : fallback;
10
+ }
11
+
12
+ function requestRestart(options = {}) {
13
+ const lifecycle = options.lifecycle || {};
14
+ const reason = typeof options.reason === 'string' && options.reason.trim()
15
+ ? options.reason.trim().slice(0, 500)
16
+ : 'user-requested';
17
+ const delayMs = clampDelay(options.delayMs, Number.isFinite(options.defaultDelayMs) ? options.defaultDelayMs : 150);
18
+ if (lifecycle.restarting) {
19
+ return { ok: false, restarting: true, error: 'Restart already in progress' };
20
+ }
21
+ lifecycle.restarting = true;
22
+ const result = {
23
+ ok: true,
24
+ restarting: true,
25
+ reason,
26
+ delayMs,
27
+ mode: typeof lifecycle.restart === 'function' ? 'relaunch' : 'exit'
28
+ };
29
+ setTimeout(async () => {
30
+ try { process.stdout.write('[mouaif] restart requested: ' + reason + '\n'); } catch (_) {}
31
+ try { await mcp.stopAll(); } catch (_) { /* best-effort */ }
32
+ if (typeof lifecycle.restart === 'function') {
33
+ try {
34
+ await lifecycle.restart({ reason });
35
+ lifecycle.restarting = false;
36
+ return;
37
+ } catch (error) {
38
+ try { process.stderr.write('[mouaif] restart failed: ' + (error && error.message || error) + '\n'); } catch (_) {}
39
+ lifecycle.restarting = false;
40
+ }
41
+ }
42
+ process.exit(0);
43
+ }, delayMs).unref();
44
+ return result;
45
+ }
46
+
47
+ module.exports = { requestRestart, clampDelay };