clementine-agent 1.18.61 → 1.18.63

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/.env.example CHANGED
@@ -36,6 +36,11 @@ WEBHOOK_SECRET=
36
36
  GROQ_API_KEY=
37
37
  ELEVENLABS_API_KEY=
38
38
  ELEVENLABS_VOICE_ID=
39
+ VOICE_ENABLED=false
40
+ VOICE_PROVIDER=gemini-live
41
+ GEMINI_API_KEY=
42
+ GEMINI_LIVE_MODEL=gemini-3.1-flash-live-preview
43
+ GEMINI_LIVE_VOICE_NAME=Kore
39
44
 
40
45
  # ── Video (optional) ─────────────────────────────────────────────────
41
46
  GOOGLE_API_KEY=
Binary file
Binary file
Binary file
Binary file
@@ -181,9 +181,28 @@ async function getGateway() {
181
181
  dispatcher.register('dashboard', async (text, context) => {
182
182
  if (!dashboardSseBroadcast)
183
183
  return;
184
+ // Forward agent identity so the chat panel can render the
185
+ // originating hired agent's name + initial — same UX guarantee
186
+ // Discord/Slack already give via per-agent bot identities.
187
+ // agentSlug arrives populated by either the explicit caller
188
+ // (cron-scheduler.dispatchContextForJob etc.) or by the
189
+ // dispatcher's session-key inference safety net.
190
+ let agentName = null;
191
+ if (context?.agentSlug && context.agentSlug !== 'clementine') {
192
+ try {
193
+ const profile = gatewayInstance?.getAgentManager().get(context.agentSlug);
194
+ agentName = profile?.name ?? null;
195
+ }
196
+ catch { /* best-effort — fall back to slug */ }
197
+ }
184
198
  dashboardSseBroadcast({
185
199
  type: 'deep_result',
186
- data: { sessionKey: context?.sessionKey ?? null, text },
200
+ data: {
201
+ sessionKey: context?.sessionKey ?? null,
202
+ agentSlug: context?.agentSlug ?? null,
203
+ agentName,
204
+ text,
205
+ },
187
206
  });
188
207
  });
189
208
  gatewayInstance.setDispatcher(dispatcher);
@@ -19551,6 +19570,51 @@ let currentPage = 'home';
19551
19570
  var currentAgentSlug = null;
19552
19571
  var prevAgentSlugs = null;
19553
19572
 
19573
+ // Browser-side cache of /api/agents — feeds chat-bubble identity rendering
19574
+ // so a hired agent's response shows their name/initial instead of
19575
+ // Clementine's. Refreshed on profile switch + on deep_result events
19576
+ // where the cache is missing the slug. Same UX guarantee Discord/Slack
19577
+ // give via per-agent bot identities.
19578
+ var __teamAgentsByslug = {};
19579
+ var __teamAgentsLoadedAt = 0;
19580
+ async function ensureTeamAgentCache(force) {
19581
+ if (!force && __teamAgentsLoadedAt && (Date.now() - __teamAgentsLoadedAt < 60_000)) return;
19582
+ try {
19583
+ var r = await apiFetch('/api/agents');
19584
+ if (!r.ok) return;
19585
+ var agents = await r.json();
19586
+ var next = {};
19587
+ if (Array.isArray(agents)) {
19588
+ for (var i = 0; i < agents.length; i++) {
19589
+ var a = agents[i];
19590
+ if (a && a.slug) next[a.slug] = { slug: a.slug, name: a.name || a.slug, avatar: a.avatar || null };
19591
+ }
19592
+ }
19593
+ __teamAgentsByslug = next;
19594
+ __teamAgentsLoadedAt = Date.now();
19595
+ } catch (e) { /* non-fatal — fall back to Clementine */ }
19596
+ }
19597
+
19598
+ /**
19599
+ * Resolve the display identity for an assistant chat bubble.
19600
+ *
19601
+ * getAssistantIdentity() → active chat profile (or Clementine)
19602
+ * getAssistantIdentity(slugFromSseEvent) → that specific agent
19603
+ *
19604
+ * Returns { slug, name, initial }. Always safe — falls back to
19605
+ * Clementine when nothing matches. Initial is uppercase first char,
19606
+ * suitable for a .chat-avatar-sm bubble.
19607
+ */
19608
+ function getAssistantIdentity(explicitSlug) {
19609
+ var slug = explicitSlug || currentAgentSlug || '';
19610
+ if (slug && slug !== 'clementine' && __teamAgentsByslug[slug]) {
19611
+ var a = __teamAgentsByslug[slug];
19612
+ return { slug: a.slug, name: a.name, initial: (a.name || 'A').charAt(0).toUpperCase() };
19613
+ }
19614
+ var clemName = (lastStatusData && lastStatusData.name) ? lastStatusData.name : 'Clementine';
19615
+ return { slug: 'clementine', name: clemName, initial: clemName.charAt(0).toUpperCase() };
19616
+ }
19617
+
19554
19618
  // ── Routing ────────────────────────────────────────────────────
19555
19619
  //
19556
19620
  // Five top-level destinations: home, build, team, brain, settings.
@@ -24359,18 +24423,23 @@ async function sendChat() {
24359
24423
  sendBtn.textContent = 'Thinking...';
24360
24424
 
24361
24425
  try {
24426
+ // Identity follows the active chat profile so a hired agent's
24427
+ // reply renders with their initial — same as Discord/Slack.
24428
+ await ensureTeamAgentCache();
24429
+ var asstIdentity = getAssistantIdentity();
24362
24430
  var asstRow = document.createElement('div');
24363
24431
  asstRow.className = 'chat-assistant-row';
24364
24432
  var chatAv = document.createElement('div');
24365
24433
  chatAv.className = 'chat-avatar-sm';
24366
- chatAv.innerHTML = (lastStatusData.name || 'C').charAt(0).toUpperCase();
24434
+ chatAv.title = asstIdentity.name;
24435
+ chatAv.innerHTML = asstIdentity.initial;
24367
24436
  asstRow.appendChild(chatAv);
24368
24437
  var asstBubble = document.createElement('div');
24369
24438
  asstBubble.className = 'chat-bubble assistant';
24370
24439
  asstBubble.innerHTML = '<span style="color:var(--text-muted);font-style:italic">connecting...</span>';
24371
24440
  var asstMeta = document.createElement('div');
24372
24441
  asstMeta.className = 'chat-meta';
24373
- asstMeta.textContent = new Date().toLocaleTimeString();
24442
+ asstMeta.textContent = new Date().toLocaleTimeString() + (asstIdentity.slug !== 'clementine' ? ' · ' + asstIdentity.name : '');
24374
24443
  asstRow.appendChild(asstBubble);
24375
24444
  container.appendChild(asstRow);
24376
24445
  typing.remove();
@@ -32436,6 +32505,21 @@ try {
32436
32505
  try {
32437
32506
  var container = document.getElementById('chat-messages');
32438
32507
  var text = (evt.data && evt.data.text) ? evt.data.text : '';
32508
+ // Identity from the SSE payload \u2014 server-side
32509
+ // dashboard sender resolves agentSlug + agentName from
32510
+ // the dispatcher context (cron jobs, hired-agent
32511
+ // workflows, heartbeats), so a Sasha-cron completion
32512
+ // renders with Sasha's initial + name in the meta line
32513
+ // instead of looking like Clementine sent it. Falls
32514
+ // back to Clementine when the event is unowned.
32515
+ var deepSlug = (evt.data && evt.data.agentSlug) ? evt.data.agentSlug : null;
32516
+ var deepName = (evt.data && evt.data.agentName) ? evt.data.agentName : null;
32517
+ // Lazy-refresh the cache if the SSE event names a slug we
32518
+ // haven't seen yet (e.g. an agent hired since page load).
32519
+ if (deepSlug && !__teamAgentsByslug[deepSlug]) { ensureTeamAgentCache(true); }
32520
+ var deepIdentity = deepName
32521
+ ? { slug: deepSlug, name: deepName, initial: deepName.charAt(0).toUpperCase() }
32522
+ : getAssistantIdentity(deepSlug);
32439
32523
  if (container && text) {
32440
32524
  var emptyState = container.querySelector('.empty-state');
32441
32525
  if (emptyState) emptyState.remove();
@@ -32443,20 +32527,24 @@ try {
32443
32527
  row.className = 'chat-assistant-row';
32444
32528
  var av = document.createElement('div');
32445
32529
  av.className = 'chat-avatar-sm';
32446
- av.innerHTML = (lastStatusData && lastStatusData.name ? lastStatusData.name : 'C').charAt(0).toUpperCase();
32530
+ av.title = deepIdentity.name;
32531
+ av.innerHTML = deepIdentity.initial;
32447
32532
  row.appendChild(av);
32448
32533
  var bubble = document.createElement('div');
32449
32534
  bubble.className = 'chat-bubble assistant';
32450
32535
  bubble.innerHTML = renderMd(text);
32451
32536
  var meta = document.createElement('div');
32452
32537
  meta.className = 'chat-meta';
32453
- meta.textContent = new Date().toLocaleTimeString() + ' \u00b7 deep task';
32538
+ meta.textContent = new Date().toLocaleTimeString()
32539
+ + (deepIdentity.slug && deepIdentity.slug !== 'clementine' ? ' \u00b7 ' + deepIdentity.name : '')
32540
+ + ' \u00b7 deep task';
32454
32541
  bubble.appendChild(meta);
32455
32542
  row.appendChild(bubble);
32456
32543
  container.appendChild(row);
32457
32544
  container.scrollTop = container.scrollHeight;
32458
32545
  } else {
32459
- toast('Deep task result ready \u2014 open chat to view.', 'info');
32546
+ var who = (deepIdentity.slug && deepIdentity.slug !== 'clementine') ? deepIdentity.name : 'Deep task';
32547
+ toast(who + ' result ready \u2014 open chat to view.', 'info');
32460
32548
  }
32461
32549
  } catch(e) { /* non-fatal */ }
32462
32550
  }
package/dist/config.d.ts CHANGED
@@ -132,6 +132,11 @@ export declare const WEBHOOK_BIND: string;
132
132
  export declare const GROQ_API_KEY: string;
133
133
  export declare const ELEVENLABS_API_KEY: string;
134
134
  export declare const ELEVENLABS_VOICE_ID: string;
135
+ export declare const VOICE_ENABLED: boolean;
136
+ export declare const VOICE_PROVIDER: string;
137
+ export declare const GEMINI_API_KEY: string;
138
+ export declare const GEMINI_LIVE_MODEL: string;
139
+ export declare const GEMINI_LIVE_VOICE_NAME: string;
135
140
  export declare const GOOGLE_API_KEY: string;
136
141
  export declare const MS_TENANT_ID: string;
137
142
  export declare const MS_CLIENT_ID: string;
package/dist/config.js CHANGED
@@ -467,6 +467,11 @@ export const WEBHOOK_BIND = getEnv('WEBHOOK_BIND', '127.0.0.1');
467
467
  export const GROQ_API_KEY = getSecret('GROQ_API_KEY');
468
468
  export const ELEVENLABS_API_KEY = getSecret('ELEVENLABS_API_KEY');
469
469
  export const ELEVENLABS_VOICE_ID = getEnv('ELEVENLABS_VOICE_ID');
470
+ export const VOICE_ENABLED = getEnv('VOICE_ENABLED', 'false').toLowerCase() === 'true';
471
+ export const VOICE_PROVIDER = getEnv('VOICE_PROVIDER', 'gemini-live');
472
+ export const GEMINI_API_KEY = getSecret('GEMINI_API_KEY') || getSecret('GOOGLE_API_KEY');
473
+ export const GEMINI_LIVE_MODEL = getEnv('GEMINI_LIVE_MODEL', 'gemini-3.1-flash-live-preview');
474
+ export const GEMINI_LIVE_VOICE_NAME = getEnv('GEMINI_LIVE_VOICE_NAME', 'Kore');
470
475
  // ── Video ────────────────────────────────────────────────────────────
471
476
  export const GOOGLE_API_KEY = getSecret('GOOGLE_API_KEY');
472
477
  // ── Outlook (Microsoft Graph) ───────────────────────────────────────
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Clementine desktop shell.
3
+ *
4
+ * This process owns a native Electron window and supervises the existing
5
+ * dashboard server. The dashboard remains the single UI source of truth.
6
+ */
7
+ export {};
8
+ //# sourceMappingURL=main.d.ts.map
@@ -0,0 +1,330 @@
1
+ /**
2
+ * Clementine desktop shell.
3
+ *
4
+ * This process owns a native Electron window and supervises the existing
5
+ * dashboard server. The dashboard remains the single UI source of truth.
6
+ */
7
+ import { spawn } from 'node:child_process';
8
+ import { existsSync } from 'node:fs';
9
+ import http from 'node:http';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { createRequire } from 'node:module';
13
+ import { fileURLToPath } from 'node:url';
14
+ const require = createRequire(import.meta.url);
15
+ function loadElectron() {
16
+ try {
17
+ return require('electron');
18
+ }
19
+ catch {
20
+ console.error('Electron is not installed. Run `npm install`, then `npm run desktop`.');
21
+ process.exit(1);
22
+ }
23
+ }
24
+ const electron = loadElectron();
25
+ const { app, BrowserWindow, Menu, Tray, nativeImage, shell, dialog, session } = electron;
26
+ app.setName('Clementine');
27
+ if (app.setAppUserModelId)
28
+ app.setAppUserModelId('com.clementine.assistant');
29
+ const __filename = fileURLToPath(import.meta.url);
30
+ const __dirname = path.dirname(__filename);
31
+ const PACKAGE_ROOT = path.resolve(__dirname, '..', '..');
32
+ const CLI_ENTRY = path.join(PACKAGE_ROOT, 'dist', 'cli', 'index.js');
33
+ const ICONS_DIR = path.join(PACKAGE_ROOT, 'build', 'icons');
34
+ const BASE_DIR = process.env.CLEMENTINE_HOME || path.join(os.homedir(), '.clementine');
35
+ const DEFAULT_PORT = Number(process.env.CLEMENTINE_DESKTOP_PORT || process.env.DASHBOARD_PORT || 3030);
36
+ const NODE_BINARY = process.env.CLEMENTINE_NODE_PATH || 'node';
37
+ let mainWindow = null;
38
+ let tray = null;
39
+ let dashboardProcess = null;
40
+ let dashboardUrl = `http://127.0.0.1:${DEFAULT_PORT}`;
41
+ let observedPort = null;
42
+ let isQuitting = false;
43
+ let restartTimer = null;
44
+ let suppressNextDashboardExitRestart = false;
45
+ function sleep(ms) {
46
+ return new Promise((resolve) => setTimeout(resolve, ms));
47
+ }
48
+ function statusHtml(title, detail) {
49
+ const safeTitle = title.replace(/[<&>]/g, '');
50
+ const safeDetail = detail.replace(/[<&>]/g, '');
51
+ return 'data:text/html;charset=utf-8,' + encodeURIComponent('<!doctype html><html><head><meta charset="utf-8">' +
52
+ '<style>body{margin:0;height:100vh;display:flex;align-items:center;justify-content:center;' +
53
+ 'font-family:-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;background:#111827;color:#f9fafb}' +
54
+ '.box{max-width:520px;padding:28px;text-align:center}.title{font-size:22px;font-weight:700;margin-bottom:10px}' +
55
+ '.detail{font-size:13px;line-height:1.5;color:#cbd5e1}</style></head><body><div class="box">' +
56
+ '<div class="title">' + safeTitle + '</div><div class="detail">' + safeDetail + '</div>' +
57
+ '</div></body></html>');
58
+ }
59
+ function loadStatus(title, detail) {
60
+ if (!mainWindow)
61
+ return;
62
+ mainWindow.loadURL(statusHtml(title, detail)).catch(() => undefined);
63
+ }
64
+ function resolveIconPath(name) {
65
+ const electronProcess = process;
66
+ const packagedRoot = typeof electronProcess.resourcesPath === 'string'
67
+ ? path.join(electronProcess.resourcesPath, 'build', 'icons')
68
+ : ICONS_DIR;
69
+ const candidates = [
70
+ path.join(ICONS_DIR, name),
71
+ path.join(packagedRoot, name),
72
+ ];
73
+ return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
74
+ }
75
+ function loadIcon(name) {
76
+ const icon = nativeImage.createFromPath(resolveIconPath(name));
77
+ return icon.isEmpty() ? nativeImage.createEmpty() : icon;
78
+ }
79
+ function probeDashboard(port) {
80
+ return new Promise((resolve) => {
81
+ const req = http.get({ hostname: '127.0.0.1', port, path: '/', timeout: 600 }, (res) => {
82
+ res.resume();
83
+ resolve(Boolean(res.statusCode && res.statusCode >= 200 && res.statusCode < 500));
84
+ });
85
+ req.on('timeout', () => {
86
+ req.destroy();
87
+ resolve(false);
88
+ });
89
+ req.on('error', () => resolve(false));
90
+ });
91
+ }
92
+ async function waitForDashboard(startPort, timeoutMs = 25_000) {
93
+ const deadline = Date.now() + timeoutMs;
94
+ while (Date.now() < deadline) {
95
+ if (observedPort && await probeDashboard(observedPort))
96
+ return observedPort;
97
+ for (let port = startPort; port < startPort + 10; port++) {
98
+ if (await probeDashboard(port))
99
+ return port;
100
+ }
101
+ await sleep(400);
102
+ }
103
+ throw new Error(`Dashboard did not become ready on ports ${startPort}-${startPort + 9}`);
104
+ }
105
+ async function findRunningDashboard(startPort) {
106
+ if (observedPort && await probeDashboard(observedPort))
107
+ return observedPort;
108
+ for (let port = startPort; port < startPort + 10; port++) {
109
+ if (await probeDashboard(port))
110
+ return port;
111
+ }
112
+ return null;
113
+ }
114
+ function attachDashboardLogs(child) {
115
+ const onChunk = (chunk) => {
116
+ const text = chunk.toString();
117
+ const match = text.match(/http:\/\/localhost:(\d+)/);
118
+ if (match)
119
+ observedPort = Number(match[1]);
120
+ if (process.env.CLEMENTINE_DESKTOP_DEBUG === '1')
121
+ process.stdout.write(text);
122
+ };
123
+ child.stdout?.on('data', onChunk);
124
+ child.stderr?.on('data', (chunk) => {
125
+ if (process.env.CLEMENTINE_DESKTOP_DEBUG === '1')
126
+ process.stderr.write(chunk);
127
+ });
128
+ }
129
+ function startDashboardProcess(port) {
130
+ if (!existsSync(CLI_ENTRY)) {
131
+ throw new Error(`Missing ${CLI_ENTRY}. Run npm run build before starting the desktop app.`);
132
+ }
133
+ observedPort = null;
134
+ const child = spawn(NODE_BINARY, [CLI_ENTRY, 'dashboard', '-p', String(port)], {
135
+ cwd: PACKAGE_ROOT,
136
+ env: {
137
+ ...process.env,
138
+ CLEMENTINE_HOME: BASE_DIR,
139
+ CLEMENTINE_NO_OPEN: '1',
140
+ __CLEM_DASHBOARD_CHILD: '1',
141
+ },
142
+ stdio: ['ignore', 'pipe', 'pipe'],
143
+ });
144
+ attachDashboardLogs(child);
145
+ child.on('exit', () => {
146
+ if (dashboardProcess === child)
147
+ dashboardProcess = null;
148
+ if (suppressNextDashboardExitRestart) {
149
+ suppressNextDashboardExitRestart = false;
150
+ return;
151
+ }
152
+ if (!isQuitting)
153
+ scheduleDashboardRestart();
154
+ });
155
+ return child;
156
+ }
157
+ async function startDashboardAndLoad() {
158
+ if (restartTimer) {
159
+ clearTimeout(restartTimer);
160
+ restartTimer = null;
161
+ }
162
+ loadStatus('Opening Clementine', 'Looking for the local dashboard...');
163
+ try {
164
+ const existingPort = await findRunningDashboard(DEFAULT_PORT);
165
+ if (existingPort) {
166
+ dashboardUrl = `http://127.0.0.1:${existingPort}`;
167
+ await mainWindow.loadURL(dashboardUrl);
168
+ return;
169
+ }
170
+ loadStatus('Starting Clementine', 'Opening the local dashboard server...');
171
+ if (!dashboardProcess)
172
+ dashboardProcess = startDashboardProcess(DEFAULT_PORT);
173
+ const actualPort = await waitForDashboard(DEFAULT_PORT);
174
+ dashboardUrl = `http://127.0.0.1:${actualPort}`;
175
+ await mainWindow.loadURL(dashboardUrl);
176
+ }
177
+ catch (err) {
178
+ const message = err instanceof Error ? err.message : String(err);
179
+ loadStatus('Clementine is offline', message);
180
+ dialog.showErrorBox('Clementine desktop failed to start', message);
181
+ }
182
+ }
183
+ function scheduleDashboardRestart() {
184
+ loadStatus('Reconnecting', 'The dashboard process stopped. Restarting it now...');
185
+ if (restartTimer)
186
+ clearTimeout(restartTimer);
187
+ restartTimer = setTimeout(() => {
188
+ restartTimer = null;
189
+ startDashboardAndLoad().catch(() => undefined);
190
+ }, 1200);
191
+ }
192
+ function stopDashboard(suppressRestart = true) {
193
+ if (!dashboardProcess)
194
+ return;
195
+ const child = dashboardProcess;
196
+ dashboardProcess = null;
197
+ suppressNextDashboardExitRestart = suppressRestart;
198
+ try {
199
+ child.kill('SIGTERM');
200
+ }
201
+ catch { /* ignore */ }
202
+ }
203
+ function restartDashboard() {
204
+ stopDashboard(true);
205
+ setTimeout(() => {
206
+ startDashboardAndLoad().catch(() => undefined);
207
+ }, 700);
208
+ }
209
+ function createWindow() {
210
+ mainWindow = new BrowserWindow({
211
+ width: 1280,
212
+ height: 860,
213
+ minWidth: 980,
214
+ minHeight: 680,
215
+ title: 'Clementine',
216
+ backgroundColor: '#111827',
217
+ icon: resolveIconPath('clementine.png'),
218
+ webPreferences: {
219
+ contextIsolation: true,
220
+ nodeIntegration: false,
221
+ sandbox: true,
222
+ },
223
+ });
224
+ mainWindow.on('close', (event) => {
225
+ if (isQuitting)
226
+ return;
227
+ event.preventDefault();
228
+ mainWindow.hide();
229
+ });
230
+ mainWindow.webContents.setWindowOpenHandler(({ url }) => {
231
+ shell.openExternal(url).catch(() => undefined);
232
+ return { action: 'deny' };
233
+ });
234
+ }
235
+ function showWindow() {
236
+ if (!mainWindow)
237
+ createWindow();
238
+ mainWindow.show();
239
+ mainWindow.focus();
240
+ }
241
+ function installMenu() {
242
+ const template = [
243
+ {
244
+ label: 'Clementine',
245
+ submenu: [
246
+ { label: 'Show Clementine', click: showWindow },
247
+ { label: 'Open in Browser', click: () => shell.openExternal(dashboardUrl).catch(() => undefined) },
248
+ {
249
+ label: 'Restart Dashboard',
250
+ click: restartDashboard,
251
+ },
252
+ { type: 'separator' },
253
+ {
254
+ label: 'Quit Clementine',
255
+ accelerator: 'CmdOrCtrl+Q',
256
+ click: () => {
257
+ isQuitting = true;
258
+ app.quit();
259
+ },
260
+ },
261
+ ],
262
+ },
263
+ {
264
+ label: 'View',
265
+ submenu: [
266
+ { role: 'reload' },
267
+ { role: 'toggleDevTools' },
268
+ { type: 'separator' },
269
+ { role: 'resetZoom' },
270
+ { role: 'zoomIn' },
271
+ { role: 'zoomOut' },
272
+ ],
273
+ },
274
+ ];
275
+ Menu.setApplicationMenu(Menu.buildFromTemplate(template));
276
+ }
277
+ function installTray() {
278
+ const trayIcon = loadIcon(process.platform === 'darwin' ? 'trayTemplate.png' : 'tray.png');
279
+ tray = new Tray(trayIcon.isEmpty() ? loadIcon('tray.png') : trayIcon);
280
+ tray.setToolTip('Clementine');
281
+ tray.setContextMenu(Menu.buildFromTemplate([
282
+ { label: 'Show Clementine', click: showWindow },
283
+ { label: 'Restart Dashboard', click: restartDashboard },
284
+ { type: 'separator' },
285
+ { label: 'Quit', click: () => { isQuitting = true; app.quit(); } },
286
+ ]));
287
+ tray.on('click', showWindow);
288
+ }
289
+ function installPermissions() {
290
+ session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
291
+ const url = webContents.getURL?.() || '';
292
+ const local = url.startsWith('http://127.0.0.1:') || url.startsWith('http://localhost:');
293
+ callback(local && (permission === 'media' || permission === 'microphone'));
294
+ });
295
+ }
296
+ if (!app.requestSingleInstanceLock()) {
297
+ app.quit();
298
+ }
299
+ else {
300
+ app.on('second-instance', () => showWindow());
301
+ app.whenReady().then(() => {
302
+ const appIcon = loadIcon('clementine.png');
303
+ if (!appIcon.isEmpty() && app.dock)
304
+ app.dock.setIcon(appIcon);
305
+ if (app.setAboutPanelOptions) {
306
+ app.setAboutPanelOptions({
307
+ applicationName: 'Clementine',
308
+ applicationVersion: app.getVersion(),
309
+ iconPath: resolveIconPath('clementine.png'),
310
+ });
311
+ }
312
+ installPermissions();
313
+ installMenu();
314
+ installTray();
315
+ createWindow();
316
+ startDashboardAndLoad().catch(() => undefined);
317
+ });
318
+ }
319
+ app.on('activate', () => showWindow());
320
+ app.on('before-quit', () => {
321
+ isQuitting = true;
322
+ if (tray) {
323
+ try {
324
+ tray.destroy();
325
+ }
326
+ catch { /* ignore */ }
327
+ }
328
+ stopDashboard();
329
+ });
330
+ //# sourceMappingURL=main.js.map
@@ -128,6 +128,27 @@ export declare class CronScheduler {
128
128
  * verification is pending and DMs the verdict if so.
129
129
  */
130
130
  private _logRun;
131
+ /**
132
+ * Single source of truth for the NotificationContext attached to any
133
+ * dispatch that originated from a cron job. Threads `agentSlug` (so
134
+ * the channel layer routes via the owning hired agent's bot when one
135
+ * exists) and a deterministic `sessionKey` (so the inference safety
136
+ * net at the dispatcher can recover routing if a future caller drops
137
+ * `agentSlug`). Use this — never construct contexts inline — so new
138
+ * dispatch sites in this file can't silently leak hired-agent results
139
+ * into Clementine's main DM.
140
+ */
141
+ private dispatchContextForJob;
142
+ /**
143
+ * Variant for dispatch sites that only have the job name in scope
144
+ * (MCP triggers, fix-verification, anywhere downstream of the run
145
+ * loop). Looks up the owning agentSlug from the live job table —
146
+ * falling back to a name-only context when the job is unknown
147
+ * (e.g. one-shot triggers without a CRON.md entry).
148
+ */
149
+ private dispatchContextForJobName;
150
+ /** Same idea for workflows. Workflows can be agent-scoped via WorkflowDefinition.agentSlug. */
151
+ private dispatchContextForWorkflow;
131
152
  private runJob;
132
153
  /**
133
154
  * Log an advisor event to the events JSONL file for dashboard surfacing.
@@ -729,10 +729,48 @@ export class CronScheduler {
729
729
  _logRun(entry) {
730
730
  this.runLog.append(entry);
731
731
  import('./fix-verification.js').then(({ checkAndDeliverVerification }) => {
732
- checkAndDeliverVerification(entry, (text) => this.dispatcher.send(text, {}))
732
+ const ctx = this.dispatchContextForJobName(entry.jobName);
733
+ checkAndDeliverVerification(entry, (text) => this.dispatcher.send(text, ctx))
733
734
  .catch(err => logger.warn({ err, job: entry.jobName }, 'Fix verification DM failed'));
734
735
  }).catch(err => logger.warn({ err }, 'Fix-verification import failed'));
735
736
  }
737
+ /**
738
+ * Single source of truth for the NotificationContext attached to any
739
+ * dispatch that originated from a cron job. Threads `agentSlug` (so
740
+ * the channel layer routes via the owning hired agent's bot when one
741
+ * exists) and a deterministic `sessionKey` (so the inference safety
742
+ * net at the dispatcher can recover routing if a future caller drops
743
+ * `agentSlug`). Use this — never construct contexts inline — so new
744
+ * dispatch sites in this file can't silently leak hired-agent results
745
+ * into Clementine's main DM.
746
+ */
747
+ dispatchContextForJob(job) {
748
+ const ctx = { sessionKey: `cron:${job.name}` };
749
+ if (job.agentSlug)
750
+ ctx.agentSlug = job.agentSlug;
751
+ return ctx;
752
+ }
753
+ /**
754
+ * Variant for dispatch sites that only have the job name in scope
755
+ * (MCP triggers, fix-verification, anywhere downstream of the run
756
+ * loop). Looks up the owning agentSlug from the live job table —
757
+ * falling back to a name-only context when the job is unknown
758
+ * (e.g. one-shot triggers without a CRON.md entry).
759
+ */
760
+ dispatchContextForJobName(jobName) {
761
+ const job = this.jobs.find(j => j.name === jobName);
762
+ if (job)
763
+ return this.dispatchContextForJob(job);
764
+ return { sessionKey: `cron:${jobName}` };
765
+ }
766
+ /** Same idea for workflows. Workflows can be agent-scoped via WorkflowDefinition.agentSlug. */
767
+ dispatchContextForWorkflow(name) {
768
+ const wf = this.workflowDefs.find(w => w.name === name);
769
+ const ctx = { sessionKey: `workflow:${name}` };
770
+ if (wf?.agentSlug)
771
+ ctx.agentSlug = wf.agentSlug;
772
+ return ctx;
773
+ }
736
774
  async runJob(job) {
737
775
  const creditBlock = getBackgroundCreditBlock();
738
776
  if (creditBlock) {
@@ -836,7 +874,7 @@ export class CronScheduler {
836
874
  await this.dispatcher.send(`**Cron job ready to run:** "${job.name}"\n` +
837
875
  `Schedule: \`${job.schedule}\`\n\n` +
838
876
  `Reply \`yes\` or \`go\` to proceed, \`no\` or \`skip\` to cancel. ` +
839
- `Auto-runs in ${timeoutMin} min if no reply.`).catch(err => logger.debug({ err }, 'Failed to send cron confirmation request'));
877
+ `Auto-runs in ${timeoutMin} min if no reply.`, this.dispatchContextForJob(job)).catch(err => logger.debug({ err }, 'Failed to send cron confirmation request'));
840
878
  // Override gateway's default 5-min timeout with the job's configured timeout
841
879
  const approved = await new Promise((resolve) => {
842
880
  const timer = setTimeout(() => {
@@ -1054,7 +1092,7 @@ export class CronScheduler {
1054
1092
  if (response && !CronScheduler.isCronNoise(response)) {
1055
1093
  // Strip internal thinking/process narration from Discord output
1056
1094
  const cleanedResponse = CronScheduler.stripThinkingPrefixes(response);
1057
- const result = await this.dispatcher.send(cleanedResponse, { agentSlug: job.agentSlug });
1095
+ const result = await this.dispatcher.send(cleanedResponse, this.dispatchContextForJob(job));
1058
1096
  if (!result.delivered) {
1059
1097
  entry.deliveryFailed = true;
1060
1098
  entry.deliveryError = Object.values(result.channelErrors).join('; ').slice(0, 300);
@@ -1117,14 +1155,14 @@ export class CronScheduler {
1117
1155
  const { block, created } = markBackgroundCreditBlocked(err);
1118
1156
  logger.error({ err, job: job.name, until: block.until }, 'Cron hit Claude credit exhaustion — pausing background jobs');
1119
1157
  if (created) {
1120
- await this.dispatcher.send(`${job.name} hit Claude credit exhaustion. Background jobs are paused until ${block.until} so they stop draining/retrying. Interactive chat may also fail until credits are available.`, { agentSlug: job.agentSlug });
1158
+ await this.dispatcher.send(`${job.name} hit Claude credit exhaustion. Background jobs are paused until ${block.until} so they stop draining/retrying. Interactive chat may also fail until credits are available.`, this.dispatchContextForJob(job));
1121
1159
  }
1122
1160
  return;
1123
1161
  }
1124
1162
  // Permanent error — stop immediately
1125
1163
  if (errorType === 'permanent') {
1126
1164
  logger.error({ err, job: job.name }, `Cron job '${job.name}' permanent error — not retrying`);
1127
- await this.dispatcher.send(`${job.name} failed: ${err}`, { agentSlug: job.agentSlug });
1165
+ await this.dispatcher.send(`${job.name} failed: ${err}`, this.dispatchContextForJob(job));
1128
1166
  return;
1129
1167
  }
1130
1168
  // Transient — retry with backoff if attempts remain
@@ -1136,7 +1174,7 @@ export class CronScheduler {
1136
1174
  else {
1137
1175
  logger.error({ err, job: job.name }, `Cron job '${job.name}' failed after ${attempt} attempt(s)`);
1138
1176
  this.logAutonomy('failed', job, { errorType, attempts: attempt, error: String(err).slice(0, 500) });
1139
- await this.dispatcher.send(CronScheduler.formatCronError(job.name, err), { agentSlug: job.agentSlug });
1177
+ await this.dispatcher.send(CronScheduler.formatCronError(job.name, err), this.dispatchContextForJob(job));
1140
1178
  }
1141
1179
  }
1142
1180
  }
@@ -1173,14 +1211,14 @@ export class CronScheduler {
1173
1211
  // Circuit breaker just engaged — notify
1174
1212
  this.logAutonomy('circuit_breaker', job, { consecutiveErrors: consErrors });
1175
1213
  this.logAdvisorEvent('circuit-breaker', job.name, `Circuit breaker engaged after ${consErrors} consecutive errors`);
1176
- this.dispatcher.send(`⚡ **Circuit breaker engaged** for \`${job.name}\` — ${consErrors} consecutive errors. Will retry in 1 hour.`, { agentSlug: job.agentSlug }).catch(err => logger.debug({ err }, 'Failed to send circuit breaker notification'));
1214
+ this.dispatcher.send(`⚡ **Circuit breaker engaged** for \`${job.name}\` — ${consErrors} consecutive errors. Will retry in 1 hour.`, this.dispatchContextForJob(job)).catch(err => logger.debug({ err }, 'Failed to send circuit breaker notification'));
1177
1215
  }
1178
1216
  else if (consErrors >= 5) {
1179
1217
  // Check if recovery probe just succeeded
1180
1218
  const lastRun = this.runLog.readRecent(job.name, 1)[0];
1181
1219
  if (lastRun && !isRunHealthFailure(lastRun)) {
1182
1220
  this.logAdvisorEvent('circuit-recovery', job.name, `Circuit breaker recovered after ${consErrors} errors`);
1183
- this.dispatcher.send(`✅ **Circuit breaker recovered** — \`${job.name}\` succeeded after ${consErrors} prior errors.`, { agentSlug: job.agentSlug }).catch(err => logger.debug({ err }, 'Failed to send circuit recovery notification'));
1221
+ this.dispatcher.send(`✅ **Circuit breaker recovered** — \`${job.name}\` succeeded after ${consErrors} prior errors.`, this.dispatchContextForJob(job)).catch(err => logger.debug({ err }, 'Failed to send circuit recovery notification'));
1184
1222
  }
1185
1223
  }
1186
1224
  if (advice.shouldEscalate) {
@@ -1223,7 +1261,7 @@ export class CronScheduler {
1223
1261
  logger.error({ job: job.name, consErrors }, `Auto-disabled cron after ${consErrors} consecutive failures`);
1224
1262
  this.logAutonomy('auto_disabled', job, { consecutiveErrors: consErrors });
1225
1263
  this.logAdvisorEvent('auto-disabled', job.name, `Auto-disabled after ${consErrors} consecutive failures`);
1226
- this.dispatcher.send(`🛑 **Cron auto-disabled** — \`${job.name}\` failed ${consErrors} times in a row. Fix the job and re-enable it from the dashboard.`, { agentSlug: job.agentSlug }).catch(err => logger.debug({ err }, 'Failed to send auto-disable notification'));
1264
+ this.dispatcher.send(`🛑 **Cron auto-disabled** — \`${job.name}\` failed ${consErrors} times in a row. Fix the job and re-enable it from the dashboard.`, this.dispatchContextForJob(job)).catch(err => logger.debug({ err }, 'Failed to send auto-disable notification'));
1227
1265
  }
1228
1266
  }
1229
1267
  }
@@ -1687,7 +1725,10 @@ export class CronScheduler {
1687
1725
  logger.info({ jobName }, 'Processing MCP trigger for cron job');
1688
1726
  this.runManual(jobName).then((result) => {
1689
1727
  if (result && !result.includes('not found')) {
1690
- this.dispatcher.send(`🔧 **${jobName}** (triggered):\n\n${result.slice(0, 1500)}`).catch(err => logger.debug({ err }, 'Failed to send trigger result notification'));
1728
+ // Route via the owning hired agent's bot when one exists
1729
+ // otherwise this triggered-result message was leaking out
1730
+ // through Clementine's main DM (the bug from 1.18.60 → 1.18.61).
1731
+ this.dispatcher.send(`🔧 **${jobName}** (triggered):\n\n${result.slice(0, 1500)}`, this.dispatchContextForJobName(jobName)).catch(err => logger.debug({ err }, 'Failed to send trigger result notification'));
1691
1732
  }
1692
1733
  }).catch((err) => {
1693
1734
  logger.error({ err, jobName }, 'Trigger-initiated job failed');
@@ -2040,7 +2081,7 @@ export class CronScheduler {
2040
2081
  logger.info({ workflow: name, inputs }, `Running workflow: ${name}`);
2041
2082
  const response = await this.gateway.handleWorkflow(wf, inputs ?? {});
2042
2083
  if (response && response !== '*(workflow completed — no output)*') {
2043
- await this.dispatcher.send(`**[Workflow: ${name}]**\n\n${response.slice(0, 1500)}`);
2084
+ await this.dispatcher.send(`**[Workflow: ${name}]**\n\n${response.slice(0, 1500)}`, this.dispatchContextForWorkflow(name));
2044
2085
  // Inject into owner's DM session
2045
2086
  if (DISCORD_OWNER_ID && DISCORD_OWNER_ID !== '0') {
2046
2087
  this.gateway.injectContext(`discord:user:${DISCORD_OWNER_ID}`, `[Workflow: ${name}]`, response);
@@ -2053,7 +2094,7 @@ export class CronScheduler {
2053
2094
  catch (err) {
2054
2095
  logger.error({ err, workflow: name }, `Workflow '${name}' failed`);
2055
2096
  const errMsg = `Workflow '${name}' failed: ${String(err).slice(0, 300)}`;
2056
- await this.dispatcher.send(errMsg);
2097
+ await this.dispatcher.send(errMsg, this.dispatchContextForWorkflow(name));
2057
2098
  return errMsg;
2058
2099
  }
2059
2100
  finally {
@@ -6,6 +6,30 @@
6
6
  * the dispatcher fans out notifications to all registered channels.
7
7
  */
8
8
  import type { NotificationSender, NotificationContext } from '../types.js';
9
+ /**
10
+ * Recover the owning hired-agent slug from a session key when the
11
+ * caller forgot to set `context.agentSlug` explicitly. Pure pattern
12
+ * matching — no I/O, no registry lookups — so it's cheap to run on
13
+ * every send. Catches the most common autonomous-path key shapes:
14
+ *
15
+ * heartbeat:<slug> → <slug>
16
+ * agent-heartbeat:<slug> → <slug>
17
+ * team-task:<from>-><to> → <to> (the receiving agent owns the result)
18
+ * discord:agent:<slug>:* → <slug>
19
+ * discord:member:*:<slug>:* → <slug>
20
+ * discord:member-dm:<slug>:* → <slug>
21
+ *
22
+ * The cron path uses `cron:<jobName>` which deliberately does NOT
23
+ * encode the agent slug — cron-scheduler.ts threads `agentSlug`
24
+ * explicitly via `dispatchContextForJob()` instead.
25
+ *
26
+ * Returns undefined for keys that look like a Clementine-owned path
27
+ * (slug === 'clementine', no slug encoded, etc.) — the channel layer
28
+ * then routes through Clementine's main bot, which is correct.
29
+ *
30
+ * Exported so tests can pin the contract.
31
+ */
32
+ export declare function inferAgentSlugFromSessionKey(sessionKey: string): string | undefined;
9
33
  export interface SendResult {
10
34
  delivered: boolean;
11
35
  channelErrors: Record<string, string>;
@@ -25,12 +25,81 @@ function channelForSessionKey(sessionKey) {
25
25
  return 'dashboard';
26
26
  return null;
27
27
  }
28
+ /**
29
+ * Recover the owning hired-agent slug from a session key when the
30
+ * caller forgot to set `context.agentSlug` explicitly. Pure pattern
31
+ * matching — no I/O, no registry lookups — so it's cheap to run on
32
+ * every send. Catches the most common autonomous-path key shapes:
33
+ *
34
+ * heartbeat:<slug> → <slug>
35
+ * agent-heartbeat:<slug> → <slug>
36
+ * team-task:<from>-><to> → <to> (the receiving agent owns the result)
37
+ * discord:agent:<slug>:* → <slug>
38
+ * discord:member:*:<slug>:* → <slug>
39
+ * discord:member-dm:<slug>:* → <slug>
40
+ *
41
+ * The cron path uses `cron:<jobName>` which deliberately does NOT
42
+ * encode the agent slug — cron-scheduler.ts threads `agentSlug`
43
+ * explicitly via `dispatchContextForJob()` instead.
44
+ *
45
+ * Returns undefined for keys that look like a Clementine-owned path
46
+ * (slug === 'clementine', no slug encoded, etc.) — the channel layer
47
+ * then routes through Clementine's main bot, which is correct.
48
+ *
49
+ * Exported so tests can pin the contract.
50
+ */
51
+ export function inferAgentSlugFromSessionKey(sessionKey) {
52
+ const heartbeatMatch = /^(?:agent-)?heartbeat:([^:]+)$/.exec(sessionKey);
53
+ if (heartbeatMatch && heartbeatMatch[1] !== 'clementine') {
54
+ return heartbeatMatch[1];
55
+ }
56
+ const teamTaskMatch = /^team-task:[^:]+->([^:]+)$/.exec(sessionKey);
57
+ if (teamTaskMatch && teamTaskMatch[1] !== 'clementine') {
58
+ return teamTaskMatch[1];
59
+ }
60
+ const discordAgentMatch = /^discord:agent:([^:]+):/.exec(sessionKey);
61
+ if (discordAgentMatch && discordAgentMatch[1] !== 'clementine') {
62
+ return discordAgentMatch[1];
63
+ }
64
+ const memberDmMatch = /^discord:member-dm:([^:]+):/.exec(sessionKey);
65
+ if (memberDmMatch && memberDmMatch[1] !== 'clementine') {
66
+ return memberDmMatch[1];
67
+ }
68
+ // discord:member:<channelId>:<slug>:<userId>
69
+ const memberMatch = /^discord:member:[^:]+:([^:]+):/.exec(sessionKey);
70
+ if (memberMatch && memberMatch[1] !== 'clementine') {
71
+ return memberMatch[1];
72
+ }
73
+ return undefined;
74
+ }
75
+ /**
76
+ * Fill in `context.agentSlug` from `context.sessionKey` when the
77
+ * caller didn't set it. Pure, side-effect-free; returns the original
78
+ * context unchanged when nothing can be inferred.
79
+ *
80
+ * Centralised so both the public `send()` entrypoint AND the retry
81
+ * queue's `sendDirect` go through it — a request that falls into the
82
+ * retry queue without an explicit agentSlug must not lose its routing
83
+ * the second time around either.
84
+ */
85
+ function enrichContext(context) {
86
+ if (!context)
87
+ return context;
88
+ if (context.agentSlug)
89
+ return context;
90
+ if (!context.sessionKey)
91
+ return context;
92
+ const inferred = inferAgentSlugFromSessionKey(context.sessionKey);
93
+ if (!inferred)
94
+ return context;
95
+ return { ...context, agentSlug: inferred };
96
+ }
28
97
  export class NotificationDispatcher {
29
98
  senders = new Map();
30
99
  _retryQueue;
31
100
  constructor() {
32
101
  this._retryQueue = new DeliveryQueue();
33
- this._retryQueue.setSender((text, ctx) => this.sendDirect(text, ctx));
102
+ this._retryQueue.setSender((text, ctx) => this.sendDirect(text, enrichContext(ctx)));
34
103
  this._retryQueue.start();
35
104
  }
36
105
  register(channelName, senderFn) {
@@ -59,7 +128,16 @@ export class NotificationDispatcher {
59
128
  if (redactionStats.redactionCount > 0) {
60
129
  logger.warn({ count: redactionStats.redactionCount, labels: redactionStats.labelsHit, sessionKey: context?.sessionKey }, `Redacted ${redactionStats.redactionCount} credential-shaped value(s) before delivery`);
61
130
  }
62
- const result = await this.sendDirect(redacted, context);
131
+ // Defense-in-depth: if a caller forgot `agentSlug` but their
132
+ // sessionKey encodes a hired agent (heartbeat/team-task/discord
133
+ // member sessions), recover it here so the channel layer routes
134
+ // through that agent's bot instead of leaking out via Clementine's
135
+ // main DM. Explicit `agentSlug` always wins.
136
+ const enriched = enrichContext(context);
137
+ if (!context?.agentSlug && enriched?.agentSlug) {
138
+ logger.debug({ sessionKey: context?.sessionKey, inferredAgentSlug: enriched.agentSlug }, 'Inferred agentSlug from sessionKey for routing');
139
+ }
140
+ const result = await this.sendDirect(redacted, enriched);
63
141
  // If delivery failed and there were actual senders (not "no channels"), queue for retry.
64
142
  // Stored text is already-redacted so disk persistence never holds a credential.
65
143
  if (!result.delivered && this.senders.size > 0) {
@@ -474,6 +474,19 @@ export class MemoryStore {
474
474
  CREATE INDEX IF NOT EXISTS idx_memory_events_created
475
475
  ON memory_events(created_at DESC);
476
476
  `);
477
+ try {
478
+ this.conn.exec('ALTER TABLE memory_events ADD COLUMN indexed_at TEXT');
479
+ }
480
+ catch {
481
+ // Column already exists. Older installs created memory_events before
482
+ // indexed_at was added; keep the health dashboard schema-compatible.
483
+ }
484
+ try {
485
+ this.conn.exec('ALTER TABLE memory_events ADD COLUMN created_at TEXT');
486
+ }
487
+ catch {
488
+ // Column already exists
489
+ }
477
490
  this.conn.exec(`
478
491
  CREATE TABLE IF NOT EXISTS memory_promotion_candidates (
479
492
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -3888,7 +3901,14 @@ export class MemoryStore {
3888
3901
  }
3889
3902
  getMemoryEventStats() {
3890
3903
  const total = this.conn.prepare('SELECT COUNT(*) AS c FROM memory_events').get().c;
3891
- const indexed = this.conn.prepare('SELECT COUNT(*) AS c FROM memory_events WHERE indexed_at IS NOT NULL').get().c;
3904
+ let indexed = total;
3905
+ try {
3906
+ indexed = this.conn.prepare('SELECT COUNT(*) AS c FROM memory_events WHERE indexed_at IS NOT NULL').get().c;
3907
+ }
3908
+ catch {
3909
+ // Pre-indexed_at ledgers were all written as proof of indexing.
3910
+ indexed = total;
3911
+ }
3892
3912
  const bySourceType = this.conn
3893
3913
  .prepare(`SELECT source_type AS sourceType, COUNT(*) AS count
3894
3914
  FROM memory_events GROUP BY source_type ORDER BY count DESC`)
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Gemini Live voice relay.
3
+ *
4
+ * The browser/Electron renderer connects to this local WebSocket. The relay
5
+ * owns the Google API key and forwards raw PCM audio to Gemini Live over the
6
+ * upstream WebSocket API.
7
+ */
8
+ import type http from 'node:http';
9
+ export type GeminiLiveStatus = {
10
+ enabled: boolean;
11
+ provider: string;
12
+ configured: boolean;
13
+ available: boolean;
14
+ model: string;
15
+ voiceName: string;
16
+ reason?: string;
17
+ };
18
+ export type GeminiLiveRelayHandle = {
19
+ available: boolean;
20
+ close: () => void;
21
+ };
22
+ export type GeminiLiveRelayOptions = {
23
+ isTokenValid: (token: string) => boolean;
24
+ };
25
+ type GeminiSetupOptions = {
26
+ model?: string;
27
+ voiceName?: string;
28
+ systemInstruction?: string;
29
+ };
30
+ export declare function buildGeminiLiveSetup(opts?: GeminiSetupOptions): Record<string, unknown>;
31
+ export declare function getGeminiLiveStatus(): GeminiLiveStatus;
32
+ export declare function installGeminiLiveRelay(server: http.Server, opts: GeminiLiveRelayOptions): GeminiLiveRelayHandle;
33
+ export {};
34
+ //# sourceMappingURL=gemini-live.d.ts.map
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Gemini Live voice relay.
3
+ *
4
+ * The browser/Electron renderer connects to this local WebSocket. The relay
5
+ * owns the Google API key and forwards raw PCM audio to Gemini Live over the
6
+ * upstream WebSocket API.
7
+ */
8
+ import { createRequire } from 'node:module';
9
+ import { URL } from 'node:url';
10
+ import pino from 'pino';
11
+ import { GEMINI_API_KEY, GEMINI_LIVE_MODEL, GEMINI_LIVE_VOICE_NAME, VOICE_ENABLED, VOICE_PROVIDER, } from '../config.js';
12
+ const logger = pino({ name: 'clementine.gemini-live' });
13
+ const require = createRequire(import.meta.url);
14
+ const GEMINI_WS_URL = 'wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent';
15
+ const WS_OPEN = 1;
16
+ function runtimeValue(key, fallback = '') {
17
+ return String(process.env[key] ?? fallback ?? '').trim();
18
+ }
19
+ function currentApiKey() {
20
+ return runtimeValue('GEMINI_API_KEY', GEMINI_API_KEY) || runtimeValue('GOOGLE_API_KEY');
21
+ }
22
+ function currentModel() {
23
+ return runtimeValue('GEMINI_LIVE_MODEL', GEMINI_LIVE_MODEL) || 'gemini-3.1-flash-live-preview';
24
+ }
25
+ function currentVoiceName() {
26
+ return runtimeValue('GEMINI_LIVE_VOICE_NAME', GEMINI_LIVE_VOICE_NAME) || 'Kore';
27
+ }
28
+ function currentVoiceProvider() {
29
+ return runtimeValue('VOICE_PROVIDER', VOICE_PROVIDER) || 'gemini-live';
30
+ }
31
+ function currentVoiceEnabled() {
32
+ return runtimeValue('VOICE_ENABLED', String(VOICE_ENABLED)).toLowerCase() !== 'false';
33
+ }
34
+ function loadWs() {
35
+ try {
36
+ return require('ws');
37
+ }
38
+ catch (err) {
39
+ logger.warn({ err }, 'ws package is not available; Gemini Live relay disabled');
40
+ return null;
41
+ }
42
+ }
43
+ function stripModelsPrefix(model) {
44
+ return model.replace(/^models\//, '').trim();
45
+ }
46
+ export function buildGeminiLiveSetup(opts = {}) {
47
+ const model = stripModelsPrefix(opts.model || currentModel());
48
+ const voiceName = opts.voiceName || currentVoiceName();
49
+ const systemInstruction = opts.systemInstruction
50
+ || 'You are Clementine, a concise voice assistant. Keep spoken replies brief and conversational unless the user asks for detail.';
51
+ const config = {
52
+ model: `models/${model}`,
53
+ responseModalities: ['AUDIO'],
54
+ inputAudioTranscription: {},
55
+ outputAudioTranscription: {},
56
+ systemInstruction: {
57
+ parts: [{ text: systemInstruction }],
58
+ },
59
+ };
60
+ if (voiceName) {
61
+ config.speechConfig = {
62
+ voiceConfig: {
63
+ prebuiltVoiceConfig: { voiceName },
64
+ },
65
+ };
66
+ }
67
+ return { config };
68
+ }
69
+ export function getGeminiLiveStatus() {
70
+ const enabled = currentVoiceEnabled();
71
+ const provider = currentVoiceProvider();
72
+ const configured = Boolean(currentApiKey());
73
+ const wsAvailable = Boolean(loadWs());
74
+ const available = enabled && provider === 'gemini-live' && configured && wsAvailable;
75
+ const reason = !enabled
76
+ ? 'VOICE_ENABLED=false'
77
+ : provider !== 'gemini-live'
78
+ ? `VOICE_PROVIDER=${provider}`
79
+ : !configured
80
+ ? 'Set GEMINI_API_KEY or GOOGLE_API_KEY'
81
+ : !wsAvailable
82
+ ? 'Install ws'
83
+ : undefined;
84
+ return {
85
+ enabled,
86
+ provider,
87
+ configured,
88
+ available,
89
+ model: currentModel(),
90
+ voiceName: currentVoiceName(),
91
+ ...(reason ? { reason } : {}),
92
+ };
93
+ }
94
+ function sendJson(ws, payload) {
95
+ if (ws.readyState !== WS_OPEN)
96
+ return;
97
+ try {
98
+ ws.send(JSON.stringify(payload));
99
+ }
100
+ catch (err) {
101
+ logger.debug({ err }, 'Gemini Live client send failed');
102
+ }
103
+ }
104
+ function closeSocket(ws, code = 1000, reason = 'closed') {
105
+ if (!ws)
106
+ return;
107
+ try {
108
+ ws.close(code, reason);
109
+ }
110
+ catch {
111
+ try {
112
+ ws.terminate?.();
113
+ }
114
+ catch { /* ignore */ }
115
+ }
116
+ }
117
+ function connectGemini(wsMod, client, query) {
118
+ const apiKey = currentApiKey();
119
+ if (!apiKey) {
120
+ sendJson(client, { type: 'error', error: 'Gemini Live is not configured. Set GEMINI_API_KEY or GOOGLE_API_KEY.' });
121
+ closeSocket(client, 1011, 'Gemini API key missing');
122
+ return null;
123
+ }
124
+ const upstreamUrl = `${GEMINI_WS_URL}?key=${encodeURIComponent(apiKey)}`;
125
+ const upstream = new wsMod.WebSocket(upstreamUrl);
126
+ const pending = [];
127
+ let upstreamOpen = false;
128
+ const flush = () => {
129
+ if (!upstreamOpen || upstream.readyState !== WS_OPEN)
130
+ return;
131
+ while (pending.length > 0)
132
+ upstream.send(pending.shift());
133
+ };
134
+ const sendUpstream = (payload) => {
135
+ const text = JSON.stringify(payload);
136
+ if (upstreamOpen && upstream.readyState === WS_OPEN)
137
+ upstream.send(text);
138
+ else
139
+ pending.push(text);
140
+ };
141
+ upstream.on('open', () => {
142
+ upstreamOpen = true;
143
+ const setup = buildGeminiLiveSetup({
144
+ model: query.get('model') || undefined,
145
+ voiceName: query.get('voice') || undefined,
146
+ });
147
+ upstream.send(JSON.stringify(setup));
148
+ flush();
149
+ sendJson(client, { type: 'ready', model: currentModel(), voiceName: currentVoiceName() });
150
+ });
151
+ upstream.on('message', (data) => {
152
+ let response;
153
+ try {
154
+ response = JSON.parse(String(data));
155
+ }
156
+ catch {
157
+ sendJson(client, { type: 'raw', data: String(data) });
158
+ return;
159
+ }
160
+ const content = response.serverContent;
161
+ if (content?.modelTurn?.parts) {
162
+ for (const part of content.modelTurn.parts) {
163
+ const inline = part.inlineData;
164
+ if (inline?.data) {
165
+ sendJson(client, {
166
+ type: 'audio',
167
+ data: inline.data,
168
+ mimeType: inline.mimeType || 'audio/pcm;rate=24000',
169
+ });
170
+ }
171
+ }
172
+ }
173
+ if (content?.inputTranscription?.text) {
174
+ sendJson(client, { type: 'transcript', role: 'user', text: content.inputTranscription.text });
175
+ }
176
+ if (content?.outputTranscription?.text) {
177
+ sendJson(client, { type: 'transcript', role: 'assistant', text: content.outputTranscription.text });
178
+ }
179
+ if (content?.interrupted) {
180
+ sendJson(client, { type: 'interrupted' });
181
+ }
182
+ if (content?.turnComplete) {
183
+ sendJson(client, { type: 'turn_complete' });
184
+ }
185
+ if (response.toolCall) {
186
+ sendJson(client, { type: 'tool_call', toolCall: response.toolCall });
187
+ }
188
+ sendJson(client, { type: 'event', event: response });
189
+ });
190
+ upstream.on('error', (err) => {
191
+ logger.warn({ err }, 'Gemini Live upstream error');
192
+ sendJson(client, { type: 'error', error: err.message || String(err) });
193
+ });
194
+ upstream.on('close', (code, reason) => {
195
+ sendJson(client, { type: 'closed', code, reason: reason?.toString?.() || '' });
196
+ closeSocket(client, 1000, 'Gemini Live upstream closed');
197
+ });
198
+ client.on('message', (data) => {
199
+ let message;
200
+ try {
201
+ message = JSON.parse(String(data));
202
+ }
203
+ catch {
204
+ sendJson(client, { type: 'error', error: 'Invalid voice message JSON' });
205
+ return;
206
+ }
207
+ if (message.type === 'audio' && message.data) {
208
+ sendUpstream({
209
+ realtimeInput: {
210
+ audio: {
211
+ data: String(message.data),
212
+ mimeType: String(message.mimeType || 'audio/pcm;rate=16000'),
213
+ },
214
+ },
215
+ });
216
+ }
217
+ else if (message.type === 'text' && message.text) {
218
+ sendUpstream({ realtimeInput: { text: String(message.text) } });
219
+ }
220
+ else if (message.type === 'audio_stream_end' || message.type === 'stop') {
221
+ sendUpstream({ realtimeInput: { audioStreamEnd: true } });
222
+ }
223
+ });
224
+ client.on('close', () => closeSocket(upstream));
225
+ client.on('error', () => closeSocket(upstream));
226
+ return upstream;
227
+ }
228
+ export function installGeminiLiveRelay(server, opts) {
229
+ const wsMod = loadWs();
230
+ if (!wsMod)
231
+ return { available: false, close: () => undefined };
232
+ const wss = new wsMod.WebSocketServer({ noServer: true, maxPayload: 2 * 1024 * 1024 });
233
+ wss.on('connection', (client, req) => {
234
+ const url = new URL(req.url || '/', 'http://127.0.0.1');
235
+ connectGemini(wsMod, client, url.searchParams);
236
+ });
237
+ server.on('upgrade', (req, socket, head) => {
238
+ const url = new URL(req.url || '/', 'http://127.0.0.1');
239
+ if (url.pathname !== '/api/voice/live/ws')
240
+ return;
241
+ const token = url.searchParams.get('token') || '';
242
+ const status = getGeminiLiveStatus();
243
+ if (!opts.isTokenValid(token)) {
244
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
245
+ socket.destroy();
246
+ return;
247
+ }
248
+ if (!status.available) {
249
+ socket.write(`HTTP/1.1 503 Service Unavailable\r\n\r\n${status.reason || 'Gemini Live unavailable'}`);
250
+ socket.destroy();
251
+ return;
252
+ }
253
+ wss.handleUpgrade(req, socket, head, (ws) => {
254
+ wss.emit('connection', ws, req);
255
+ });
256
+ });
257
+ logger.info('Gemini Live relay attached to dashboard server');
258
+ return {
259
+ available: true,
260
+ close: () => wss.close(),
261
+ };
262
+ }
263
+ //# sourceMappingURL=gemini-live.js.map
@@ -0,0 +1,34 @@
1
+ appId: com.clementine.assistant
2
+ productName: Clementine
3
+ asar: false
4
+ npmRebuild: false
5
+ directories:
6
+ buildResources: build
7
+ output: release
8
+ extraMetadata:
9
+ main: dist/desktop/main.js
10
+ files:
11
+ - dist/**/*
12
+ - build/icons/*.png
13
+ - build/icons/*.icns
14
+ - package.json
15
+ - README.md
16
+ - scripts/**/*
17
+ - vendor/**/*
18
+ extraResources:
19
+ - from: build/icons
20
+ to: build/icons
21
+ filter:
22
+ - '*.png'
23
+ - '*.icns'
24
+ mac:
25
+ category: public.app-category.productivity
26
+ icon: build/icons/icon.icns
27
+ target:
28
+ - dmg
29
+ - zip
30
+ artifactName: Clementine-${version}-mac-${arch}.${ext}
31
+ publish:
32
+ provider: github
33
+ owner: Natebreynolds
34
+ repo: Clementine-AI-Assistant
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.61",
3
+ "version": "1.18.63",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -13,6 +13,12 @@
13
13
  "prepublishOnly": "npm run build && find dist -name '*.map' -delete",
14
14
  "dev": "tsx src/index.ts",
15
15
  "start": "node dist/index.js",
16
+ "dashboard": "npm run build && node dist/cli/index.js dashboard",
17
+ "desktop": "npm run build && electron dist/desktop/main.js",
18
+ "desktop:debug": "npm run build && CLEMENTINE_DESKTOP_DEBUG=1 electron dist/desktop/main.js",
19
+ "desktop:prepare": "npm run build && npm rebuild better-sqlite3",
20
+ "desktop:pack": "npm run desktop:prepare && electron-builder --mac --dir",
21
+ "desktop:dist": "npm run desktop:prepare && electron-builder --mac",
16
22
  "mcp": "tsx src/tools/mcp-server.ts",
17
23
  "cli": "tsx src/cli/index.ts",
18
24
  "typecheck": "tsc --noEmit",
@@ -47,6 +53,7 @@
47
53
  "pdf-parse": "^1.1.1",
48
54
  "pino": "^9.6.0",
49
55
  "twilio": "^5.5.0",
56
+ "ws": "^8.19.0",
50
57
  "zod": "^4.3.6"
51
58
  },
52
59
  "devDependencies": {
@@ -57,6 +64,8 @@
57
64
  "@types/node": "^22.12.0",
58
65
  "@types/node-cron": "^3.0.11",
59
66
  "@types/pdf-parse": "^1.1.4",
67
+ "electron": "^42.0.0",
68
+ "electron-builder": "^26.8.1",
60
69
  "tsx": "^4.19.0",
61
70
  "typescript": "^5.7.0",
62
71
  "vitest": "^4.1.1"
@@ -89,6 +98,8 @@
89
98
  "vault/",
90
99
  "scripts/",
91
100
  "vendor/",
101
+ "build/icons/",
102
+ "electron-builder.yml",
92
103
  "install.sh",
93
104
  "README.md",
94
105
  ".env.example"