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 +5 -0
- package/build/icons/Clementine.iconset/icon_128x128.png +0 -0
- package/build/icons/Clementine.iconset/icon_128x128@2x.png +0 -0
- package/build/icons/Clementine.iconset/icon_16x16.png +0 -0
- package/build/icons/Clementine.iconset/icon_16x16@2x.png +0 -0
- package/build/icons/Clementine.iconset/icon_256x256.png +0 -0
- package/build/icons/Clementine.iconset/icon_256x256@2x.png +0 -0
- package/build/icons/Clementine.iconset/icon_32x32.png +0 -0
- package/build/icons/Clementine.iconset/icon_32x32@2x.png +0 -0
- package/build/icons/Clementine.iconset/icon_512x512.png +0 -0
- package/build/icons/Clementine.iconset/icon_512x512@2x.png +0 -0
- package/build/icons/clementine.png +0 -0
- package/build/icons/icon.icns +0 -0
- package/build/icons/tray.png +0 -0
- package/build/icons/tray@2x.png +0 -0
- package/dist/cli/dashboard.js +94 -6
- package/dist/config.d.ts +5 -0
- package/dist/config.js +5 -0
- package/dist/desktop/main.d.ts +8 -0
- package/dist/desktop/main.js +330 -0
- package/dist/gateway/cron-scheduler.d.ts +21 -0
- package/dist/gateway/cron-scheduler.js +53 -12
- package/dist/gateway/notifications.d.ts +24 -0
- package/dist/gateway/notifications.js +80 -2
- package/dist/memory/store.js +21 -1
- package/dist/voice/gemini-live.d.ts +34 -0
- package/dist/voice/gemini-live.js +263 -0
- package/electron-builder.yml +34 -0
- package/package.json +12 -1
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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -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: {
|
|
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.
|
|
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.
|
|
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()
|
|
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
|
-
|
|
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,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
|
-
|
|
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
|
|
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,
|
|
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.`,
|
|
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}`,
|
|
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),
|
|
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.`,
|
|
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.`,
|
|
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.`,
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/memory/store.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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"
|