clementine-agent 1.18.64 → 1.18.65

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/README.md CHANGED
@@ -54,6 +54,7 @@ After setup:
54
54
  clementine launch # start as background daemon
55
55
  clementine status # verify it's running
56
56
  clementine dashboard # open the web command center
57
+ clementine desktop install # optional macOS desktop app
57
58
  ```
58
59
 
59
60
  Already installed? Update in place with `clementine update`.
@@ -198,6 +199,7 @@ clementine status PID, uptime, active channels
198
199
  clementine update [--dry-run] Pull latest, rebuild, reinstall (preserves config)
199
200
  clementine doctor [--fix] Verify (and optionally repair) config and vault
200
201
  clementine dashboard Open the web command center (localhost:3030)
202
+ clementine desktop install Download/open the macOS desktop app installer
201
203
  clementine tools List available MCP tools, plugins, and channels
202
204
  ```
203
205
 
@@ -7215,6 +7215,7 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
7215
7215
  slug: p.slug,
7216
7216
  name: p.name,
7217
7217
  description: p.description,
7218
+ avatar: p.avatar ?? null,
7218
7219
  }));
7219
7220
  const activeSlug = gw.getSessionProfile('dashboard:web') ?? null;
7220
7221
  res.json({ profiles, active: activeSlug });
@@ -12230,22 +12231,137 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
12230
12231
  .home-chat-panel-header {
12231
12232
  display: flex;
12232
12233
  align-items: center;
12233
- gap: 8px;
12234
- padding: 12px 14px;
12234
+ gap: 6px;
12235
+ padding: 10px 12px;
12235
12236
  border-bottom: 1px solid var(--border);
12236
12237
  font-size: var(--text-base);
12237
12238
  font-weight: 600;
12238
12239
  background: var(--bg-secondary);
12240
+ position: relative; /* anchor for .chat-agent-popover */
12239
12241
  }
12240
12242
  .home-chat-panel-header .icn { width: 16px; height: 16px; color: var(--clementine); }
12241
- .home-chat-panel-header select {
12242
- padding: 4px 8px;
12243
- font-size: var(--text-xs);
12243
+
12244
+ /* Active-agent header button — replaces the bare <select>.
12245
+ Closed: avatar + name + role tagline + caret. Click opens popover. */
12246
+ .chat-agent-btn {
12247
+ display: flex; align-items: center; gap: 10px;
12248
+ flex: 1; min-width: 0;
12249
+ padding: 6px 8px;
12250
+ border-radius: var(--radius-sm);
12251
+ background: transparent;
12252
+ border: 1px solid transparent;
12253
+ color: var(--text-primary);
12254
+ cursor: pointer;
12255
+ text-align: left;
12256
+ font: inherit;
12257
+ transition: background var(--motion-fast), border-color var(--motion-fast);
12258
+ }
12259
+ .chat-agent-btn:hover { background: var(--bg-hover); border-color: var(--border); }
12260
+ .chat-agent-btn[aria-expanded="true"] { background: var(--bg-hover); border-color: var(--border); }
12261
+ .chat-agent-btn-avatar {
12262
+ width: 32px; height: 32px;
12263
+ border-radius: 50%;
12264
+ background: linear-gradient(135deg, var(--clementine), #ff6b00);
12265
+ background-size: cover;
12266
+ background-position: center;
12267
+ display: flex; align-items: center; justify-content: center;
12268
+ font-size: 13px; font-weight: 700; color: #fff;
12269
+ flex-shrink: 0;
12270
+ }
12271
+ .chat-agent-btn-avatar.hired {
12272
+ background: var(--bg-input);
12273
+ color: var(--text-primary);
12274
+ }
12275
+ .chat-agent-btn-avatar.has-image { color: transparent; } /* hide initial when image present */
12276
+ .chat-agent-btn-text {
12277
+ display: flex; flex-direction: column;
12278
+ flex: 1; min-width: 0;
12279
+ gap: 1px;
12280
+ }
12281
+ .chat-agent-btn-name {
12282
+ font-size: var(--text-base);
12283
+ font-weight: 600;
12284
+ line-height: 1.2;
12285
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
12286
+ }
12287
+ .chat-agent-btn-role {
12288
+ font-size: 11px;
12289
+ color: var(--text-muted);
12290
+ line-height: 1.2;
12291
+ font-weight: 400;
12292
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
12293
+ }
12294
+ .chat-agent-btn-caret {
12295
+ width: 14px; height: 14px;
12296
+ color: var(--text-muted);
12297
+ transition: transform var(--motion-fast);
12298
+ flex-shrink: 0;
12299
+ }
12300
+ .chat-agent-btn[aria-expanded="true"] .chat-agent-btn-caret { transform: rotate(180deg); }
12301
+
12302
+ /* Popover — list of agents anchored under the header button */
12303
+ .chat-agent-popover {
12304
+ position: absolute;
12305
+ top: calc(100% + 4px);
12306
+ left: 8px; right: 8px;
12307
+ z-index: 250;
12308
+ background: var(--bg-secondary);
12244
12309
  border: 1px solid var(--border);
12245
- border-radius: var(--radius-xs);
12310
+ border-radius: var(--radius-md);
12311
+ box-shadow: var(--shadow-lg);
12312
+ max-height: 320px; overflow-y: auto;
12313
+ padding: 6px;
12314
+ animation: msgFadeIn 0.18s ease;
12315
+ }
12316
+ .chat-agent-row {
12317
+ display: flex; align-items: center; gap: 10px;
12318
+ padding: 8px 10px;
12319
+ border-radius: var(--radius-sm);
12320
+ cursor: pointer;
12321
+ transition: background var(--motion-fast);
12322
+ position: relative;
12323
+ }
12324
+ .chat-agent-row:hover { background: var(--bg-hover); }
12325
+ .chat-agent-row.active { background: var(--bg-hover); }
12326
+ .chat-agent-row.active::before {
12327
+ content: '';
12328
+ position: absolute; left: 2px; top: 8px; bottom: 8px;
12329
+ width: 3px; background: var(--clementine);
12330
+ border-radius: 2px;
12331
+ }
12332
+ .chat-agent-row-avatar {
12333
+ width: 28px; height: 28px;
12334
+ border-radius: 50%;
12335
+ background: linear-gradient(135deg, var(--clementine), #ff6b00);
12336
+ background-size: cover;
12337
+ background-position: center;
12338
+ display: flex; align-items: center; justify-content: center;
12339
+ font-size: 11px; font-weight: 700; color: #fff;
12340
+ flex-shrink: 0;
12341
+ }
12342
+ .chat-agent-row-avatar.hired {
12246
12343
  background: var(--bg-input);
12247
12344
  color: var(--text-primary);
12248
12345
  }
12346
+ .chat-agent-row-avatar.has-image { color: transparent; }
12347
+ .chat-agent-row-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
12348
+ .chat-agent-row-name {
12349
+ font-size: 13px; font-weight: 600;
12350
+ color: var(--text-primary);
12351
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
12352
+ }
12353
+ .chat-agent-row-desc {
12354
+ font-size: 11px; color: var(--text-muted);
12355
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
12356
+ }
12357
+ .chat-agent-row-status {
12358
+ width: 8px; height: 8px;
12359
+ border-radius: 50%;
12360
+ background: var(--text-muted);
12361
+ flex-shrink: 0;
12362
+ }
12363
+ .chat-agent-row-status.online { background: var(--green); }
12364
+
12249
12365
  .home-chat-panel-messages {
12250
12366
  flex: 1;
12251
12367
  overflow-y: auto;
@@ -12258,9 +12374,9 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
12258
12374
  padding: 10px 12px;
12259
12375
  border-top: 1px solid var(--border);
12260
12376
  background: var(--bg-secondary);
12261
- align-items: center;
12377
+ align-items: flex-end;
12262
12378
  }
12263
- .home-chat-panel-input-row input[type="text"] {
12379
+ .home-chat-panel-input-row textarea {
12264
12380
  flex: 1;
12265
12381
  padding: 8px 12px;
12266
12382
  border: 1px solid var(--border);
@@ -12269,9 +12385,18 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
12269
12385
  color: var(--text-primary);
12270
12386
  font-size: var(--text-base);
12271
12387
  font-family: inherit;
12388
+ resize: none;
12389
+ line-height: 1.4;
12390
+ max-height: 110px;
12391
+ overflow-y: auto;
12272
12392
  }
12273
12393
  .home-chat-panel-input-row .btn-icon { padding: 6px; }
12274
12394
  .home-chat-panel-input-row .icn { width: 14px; height: 14px; }
12395
+ /* Send button busy state — toggle send/spinner instead of swapping text */
12396
+ #chat-send-btn .icon-spinner { display: none; }
12397
+ #chat-send-btn.is-busy .icon-send { display: none; }
12398
+ #chat-send-btn.is-busy .icon-spinner { display: inline-block; animation: btnSpin 0.8s linear infinite; }
12399
+ @keyframes btnSpin { to { transform: rotate(360deg); } }
12275
12400
 
12276
12401
  @media (max-width: 600px) {
12277
12402
  .home-chat-panel {
@@ -14649,6 +14774,8 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
14649
14774
  height: 28px;
14650
14775
  border-radius: 50%;
14651
14776
  background: linear-gradient(135deg, var(--clementine), #ff6b00);
14777
+ background-size: cover;
14778
+ background-position: center;
14652
14779
  display: flex;
14653
14780
  align-items: center;
14654
14781
  justify-content: center;
@@ -14658,6 +14785,11 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
14658
14785
  flex-shrink: 0;
14659
14786
  margin-top: 2px;
14660
14787
  }
14788
+ .chat-avatar-sm.hired {
14789
+ background: var(--bg-input);
14790
+ color: var(--text-primary);
14791
+ }
14792
+ .chat-avatar-sm.has-image { color: transparent; } /* hide initial when image present */
14661
14793
  /* Build page is full-height flex (canvas + chat). Home page handles its own layout. */
14662
14794
  #page-build.active {
14663
14795
  display: flex !important;
@@ -18404,28 +18536,26 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
18404
18536
  </button>
18405
18537
  <div id="home-chat-panel" class="home-chat-panel" aria-hidden="true">
18406
18538
  <div class="home-chat-panel-header">
18407
- <span><span class="icon-slot" data-icon="messageSquare"></span> Chat</span>
18408
- <span style="flex:1"></span>
18409
- <select id="chat-profile-select" onchange="switchProfile(this.value)" title="Active profile">
18410
- <option value="">Default</option>
18411
- </select>
18412
- <button class="btn-icon btn-sm" onclick="toggleHomeChat()" title="Close chat">×</button>
18413
- </div>
18414
- <div id="chat-messages" class="home-chat-panel-messages">
18415
- <div class="empty-state" style="margin-top:24px">
18416
- <p style="margin-bottom:12px;color:var(--text-muted);font-size:var(--text-base)">What can I help with?</p>
18417
- <div style="display:flex;flex-wrap:wrap;gap:6px;justify-content:center">
18418
- <button class="btn btn-sm quick-pill" onclick="quickChat(&quot;What&apos;s on my schedule?&quot;)">Schedule?</button>
18419
- <button class="btn btn-sm quick-pill" onclick="quickChat('Check my email')">Email</button>
18420
- <button class="btn btn-sm quick-pill" onclick="quickChat('Run morning briefing')">Briefing</button>
18421
- <button class="btn btn-sm quick-pill" onclick="quickChat('What did you do today?')">Today's work</button>
18422
- </div>
18423
- </div>
18539
+ <button id="chat-agent-btn" class="chat-agent-btn" onclick="toggleAgentPicker(event)" aria-haspopup="listbox" aria-expanded="false" title="Switch agent">
18540
+ <span class="chat-agent-btn-avatar" id="chat-agent-btn-avatar">C</span>
18541
+ <span class="chat-agent-btn-text">
18542
+ <span class="chat-agent-btn-name" id="chat-agent-btn-name">Clementine</span>
18543
+ <span class="chat-agent-btn-role" id="chat-agent-btn-role">Personal AI Assistant</span>
18544
+ </span>
18545
+ <span class="chat-agent-btn-caret icon-slot" data-icon="chevronDown"></span>
18546
+ </button>
18547
+ <button class="btn-icon btn-sm" onclick="toggleHomeChat()" title="Close chat" style="margin-left:8px">×</button>
18548
+ <div id="chat-agent-popover" class="chat-agent-popover" role="listbox" hidden></div>
18424
18549
  </div>
18550
+ <div id="chat-messages" class="home-chat-panel-messages"></div>
18425
18551
  <div class="home-chat-panel-input-row">
18426
- <input type="text" id="chat-input" placeholder="Ask anything..." onkeydown="if(event.key==='Enter'&amp;&amp;!event.shiftKey){event.preventDefault();sendChat()}">
18427
- <button class="btn-primary btn-sm" id="chat-send-btn" onclick="sendChat()" title="Send">
18428
- <span class="icon-slot" data-icon="send"></span>
18552
+ <textarea id="chat-input" rows="1"
18553
+ placeholder="Message Clementine…"
18554
+ onkeydown="onChatKeyDown(event)"
18555
+ oninput="onChatInputChange(this)"></textarea>
18556
+ <button class="btn-primary btn-sm" id="chat-send-btn" onclick="sendChat()" title="Send" disabled>
18557
+ <span class="icon-slot icon-send" data-icon="send"></span>
18558
+ <span class="icon-slot icon-spinner" data-icon="loader" hidden></span>
18429
18559
  </button>
18430
18560
  </div>
18431
18561
  </div>
@@ -19587,7 +19717,12 @@ async function ensureTeamAgentCache(force) {
19587
19717
  if (Array.isArray(agents)) {
19588
19718
  for (var i = 0; i < agents.length; i++) {
19589
19719
  var a = agents[i];
19590
- if (a && a.slug) next[a.slug] = { slug: a.slug, name: a.name || a.slug, avatar: a.avatar || null };
19720
+ if (a && a.slug) next[a.slug] = {
19721
+ slug: a.slug,
19722
+ name: a.name || a.slug,
19723
+ avatar: a.avatar || null,
19724
+ description: a.description || '',
19725
+ };
19591
19726
  }
19592
19727
  }
19593
19728
  __teamAgentsByslug = next;
@@ -19601,18 +19736,55 @@ async function ensureTeamAgentCache(force) {
19601
19736
  * getAssistantIdentity() → active chat profile (or Clementine)
19602
19737
  * getAssistantIdentity(slugFromSseEvent) → that specific agent
19603
19738
  *
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.
19739
+ * Returns { slug, name, initial, avatar, description }. Always safe —
19740
+ * falls back to Clementine when nothing matches. Initial is uppercase
19741
+ * first char, suitable for a .chat-avatar-sm bubble. Avatar is a URL
19742
+ * or null; the bubble renderer applies it as background-image when set.
19607
19743
  */
19608
19744
  function getAssistantIdentity(explicitSlug) {
19609
19745
  var slug = explicitSlug || currentAgentSlug || '';
19610
19746
  if (slug && slug !== 'clementine' && __teamAgentsByslug[slug]) {
19611
19747
  var a = __teamAgentsByslug[slug];
19612
- return { slug: a.slug, name: a.name, initial: (a.name || 'A').charAt(0).toUpperCase() };
19748
+ return {
19749
+ slug: a.slug,
19750
+ name: a.name,
19751
+ initial: (a.name || 'A').charAt(0).toUpperCase(),
19752
+ avatar: a.avatar || null,
19753
+ description: a.description || '',
19754
+ primary: false,
19755
+ };
19613
19756
  }
19614
19757
  var clemName = (lastStatusData && lastStatusData.name) ? lastStatusData.name : 'Clementine';
19615
- return { slug: 'clementine', name: clemName, initial: clemName.charAt(0).toUpperCase() };
19758
+ return {
19759
+ slug: 'clementine',
19760
+ name: clemName,
19761
+ initial: clemName.charAt(0).toUpperCase(),
19762
+ avatar: null,
19763
+ description: 'Personal AI Assistant',
19764
+ primary: true,
19765
+ };
19766
+ }
19767
+
19768
+ /**
19769
+ * Build the empty-state markup for the active (or specified) agent.
19770
+ * Used both at first load and after pickAgent clears the panel — so
19771
+ * a switch always lands on a clean, identity-aware slate instead of
19772
+ * "Session cleared." or a hardcoded "What can I help with?".
19773
+ */
19774
+ function renderEmptyStateFor(slug) {
19775
+ var id = getAssistantIdentity(slug || null);
19776
+ var greet = id.primary
19777
+ ? 'What can I help with?'
19778
+ : 'Chat with ' + id.name + (id.description ? ' — ' + id.description : '');
19779
+ return '<div class="empty-state" style="margin-top:24px">'
19780
+ + '<p style="margin-bottom:12px;color:var(--text-muted);font-size:var(--text-base)">' + esc(greet) + '</p>'
19781
+ + '<div style="display:flex;flex-wrap:wrap;gap:6px;justify-content:center">'
19782
+ + '<button class="btn btn-sm quick-pill" onclick="quickChat(\\'What\\\\\\'s on my schedule?\\')">Schedule?</button>'
19783
+ + '<button class="btn btn-sm quick-pill" onclick="quickChat(\\'Check my email\\')">Email</button>'
19784
+ + '<button class="btn btn-sm quick-pill" onclick="quickChat(\\'Run morning briefing\\')">Briefing</button>'
19785
+ + '<button class="btn btn-sm quick-pill" onclick="quickChat(\\'What did you do today?\\')">Today\\'s work</button>'
19786
+ + '</div>'
19787
+ + '</div>';
19616
19788
  }
19617
19789
 
19618
19790
  // ── Routing ────────────────────────────────────────────────────
@@ -24361,10 +24533,33 @@ document.getElementById('log-filter').addEventListener('input', applyLogFilter);
24361
24533
 
24362
24534
  // ── Chat ──────────────────────────────────
24363
24535
  function quickChat(msg) {
24364
- document.getElementById('chat-input').value = msg;
24536
+ var el = document.getElementById('chat-input');
24537
+ el.value = msg;
24538
+ onChatInputChange(el); // resize + enable send button
24365
24539
  sendChat();
24366
24540
  }
24367
24541
 
24542
+ // Enter sends; Shift+Enter inserts a newline. Cmd/Ctrl+Enter also sends
24543
+ // for muscle-memory parity with builder chats.
24544
+ function onChatKeyDown(e) {
24545
+ if (e.key === 'Enter') {
24546
+ if (e.shiftKey) return; // newline
24547
+ e.preventDefault();
24548
+ sendChat();
24549
+ }
24550
+ }
24551
+
24552
+ // Autosize the chat textarea up to ~5 rows; toggle send-button enabled
24553
+ // state. Called from oninput on the composer + after quickChat sets a value.
24554
+ function onChatInputChange(el) {
24555
+ if (!el) return;
24556
+ el.style.height = 'auto';
24557
+ var max = 110; // ~5 rows at 22px line-height
24558
+ el.style.height = Math.min(el.scrollHeight, max) + 'px';
24559
+ var btn = document.getElementById('chat-send-btn');
24560
+ if (btn) btn.disabled = !el.value.trim() || btn.classList.contains('is-busy');
24561
+ }
24562
+
24368
24563
  function renderMd(text) {
24369
24564
  let s = esc(text);
24370
24565
  var BT = String.fromCharCode(96);
@@ -24416,22 +24611,26 @@ async function sendChat() {
24416
24611
  container.appendChild(typing);
24417
24612
  container.scrollTop = container.scrollHeight;
24418
24613
 
24419
- // Disable input while processing
24614
+ // Swap send button into busy state — keeps the icon, shows a spinner
24615
+ // (no more "Thinking..." text). The .is-busy class is the source of
24616
+ // truth; CSS handles the icon swap.
24420
24617
  const sendBtn = document.getElementById('chat-send-btn');
24421
24618
  input.disabled = true;
24422
24619
  sendBtn.disabled = true;
24423
- sendBtn.textContent = 'Thinking...';
24620
+ sendBtn.classList.add('is-busy');
24424
24621
 
24425
24622
  try {
24426
24623
  // Identity follows the active chat profile so a hired agent's
24427
- // reply renders with their initial same as Discord/Slack.
24624
+ // reply renders with their initial (or avatar URL when set) —
24625
+ // same as Discord/Slack.
24428
24626
  await ensureTeamAgentCache();
24429
24627
  var asstIdentity = getAssistantIdentity();
24430
24628
  var asstRow = document.createElement('div');
24431
24629
  asstRow.className = 'chat-assistant-row';
24432
24630
  var chatAv = document.createElement('div');
24433
- chatAv.className = 'chat-avatar-sm';
24631
+ chatAv.className = 'chat-avatar-sm' + (asstIdentity.primary ? '' : ' hired') + (asstIdentity.avatar ? ' has-image' : '');
24434
24632
  chatAv.title = asstIdentity.name;
24633
+ if (asstIdentity.avatar) chatAv.style.backgroundImage = "url('" + asstIdentity.avatar + "')";
24435
24634
  chatAv.innerHTML = asstIdentity.initial;
24436
24635
  asstRow.appendChild(chatAv);
24437
24636
  var asstBubble = document.createElement('div');
@@ -24522,8 +24721,12 @@ async function sendChat() {
24522
24721
  }
24523
24722
 
24524
24723
  input.disabled = false;
24525
- sendBtn.disabled = false;
24526
- sendBtn.textContent = 'Send';
24724
+ sendBtn.classList.remove('is-busy');
24725
+ // Re-enable only if there's content to send (or stay disabled — the
24726
+ // user clears the textarea by sending, so it's empty here).
24727
+ sendBtn.disabled = !input.value.trim();
24728
+ // Reset the textarea autosize back to single-row.
24729
+ if (typeof onChatInputChange === 'function') onChatInputChange(input);
24527
24730
  input.focus();
24528
24731
  container.scrollTop = container.scrollHeight;
24529
24732
  }
@@ -24952,37 +25155,138 @@ async function deleteChunk(id) {
24952
25155
  } catch (e) { toast('Delete failed: ' + String(e), 'error'); }
24953
25156
  }
24954
25157
 
24955
- // ── Profile Switching ─────────────────────
24956
- async function loadProfiles() {
24957
- try {
24958
- var r = await apiFetch('/api/profiles');
24959
- var d = await r.json();
24960
- var sel = document.getElementById('chat-profile-select');
24961
- sel.innerHTML = '<option value="">Default</option>';
24962
- var customCount = 0;
24963
- for (var p of (d.profiles || [])) {
24964
- var opt = document.createElement('option');
24965
- opt.value = p.slug;
24966
- opt.textContent = p.name + (p.description ? ' — ' + p.description : '');
24967
- if (p.slug === d.active) opt.selected = true;
24968
- sel.appendChild(opt);
24969
- customCount++;
25158
+ // ── Profile Switching — chat agent picker (header button + popover) ─────
25159
+ //
25160
+ // Replaces the legacy <select> with a richer header affordance:
25161
+ // • closed: avatar + name + role tagline of the active agent
25162
+ // • open: avatar-row list of Clementine + every hired agent, with
25163
+ // status dots and descriptions
25164
+ //
25165
+ // Source of truth for "active profile" stays the gateway's session
25166
+ // profile map (set via /api/profiles/switch). The picker just makes
25167
+ // it visible and one-click switchable.
25168
+
25169
+ var _agentPickerOpen = false;
25170
+ var _chatAgentRows = [];
25171
+
25172
+ async function refreshChatAgentPicker() {
25173
+ await ensureTeamAgentCache();
25174
+ var rProf;
25175
+ try { rProf = await apiFetch('/api/profiles'); } catch { rProf = { ok: false }; }
25176
+ var d = (rProf && rProf.ok) ? await rProf.json() : { profiles: [], active: null };
25177
+
25178
+ var clemName = (lastStatusData && lastStatusData.name) ? lastStatusData.name : 'Clementine';
25179
+ var clemRole = 'Personal AI Assistant';
25180
+ var clemOnline = !!(lastStatusData && lastStatusData.alive);
25181
+ var rows = [{ slug: '', name: clemName, role: clemRole, avatar: null, online: clemOnline, primary: true }];
25182
+ for (var i = 0; i < (d.profiles || []).length; i++) {
25183
+ var p = d.profiles[i];
25184
+ var cached = __teamAgentsByslug[p.slug] || {};
25185
+ rows.push({
25186
+ slug: p.slug,
25187
+ name: p.name || p.slug,
25188
+ role: p.description || '',
25189
+ avatar: p.avatar || cached.avatar || null,
25190
+ online: cached.botStatus === 'online' || cached.status === 'active',
25191
+ primary: false,
25192
+ });
25193
+ }
25194
+ _chatAgentRows = rows;
25195
+ var activeSlug = d.active || '';
25196
+ currentAgentSlug = activeSlug || null;
25197
+
25198
+ // Header button — always visible identity affordance
25199
+ var btnAvatar = document.getElementById('chat-agent-btn-avatar');
25200
+ var btnName = document.getElementById('chat-agent-btn-name');
25201
+ var btnRole = document.getElementById('chat-agent-btn-role');
25202
+ var active = null;
25203
+ for (var j = 0; j < rows.length; j++) { if (rows[j].slug === activeSlug) { active = rows[j]; break; } }
25204
+ if (!active) active = rows[0];
25205
+ if (btnAvatar) applyAgentAvatar(btnAvatar, active);
25206
+ if (btnName) btnName.textContent = active.name;
25207
+ if (btnRole) btnRole.textContent = active.role || (active.primary ? clemRole : '');
25208
+
25209
+ // Popover rows
25210
+ var pop = document.getElementById('chat-agent-popover');
25211
+ if (pop) {
25212
+ var html = '';
25213
+ for (var k = 0; k < rows.length; k++) {
25214
+ var r = rows[k];
25215
+ var isActive = r.slug === activeSlug;
25216
+ var avatarStyle = r.avatar ? (' style="background-image:url(\\''+ esc(r.avatar) +'\\')"') : '';
25217
+ var avatarClasses = 'chat-agent-row-avatar' + (r.primary ? '' : ' hired') + (r.avatar ? ' has-image' : '');
25218
+ var initial = esc((r.name || '?').charAt(0).toUpperCase());
25219
+ html += '<div class="chat-agent-row' + (isActive ? ' active' : '') + '" role="option" aria-selected="' + (isActive ? 'true' : 'false') + '" onclick="pickAgent(\\''+ esc(r.slug) +'\\')">'
25220
+ + '<span class="' + avatarClasses + '"' + avatarStyle + '>' + initial + '</span>'
25221
+ + '<span class="chat-agent-row-text">'
25222
+ + '<span class="chat-agent-row-name">' + esc(r.name) + '</span>'
25223
+ + (r.role ? '<span class="chat-agent-row-desc">' + esc(r.role) + '</span>' : '')
25224
+ + '</span>'
25225
+ + '<span class="chat-agent-row-status ' + (r.online ? 'online' : 'offline') + '" title="' + (r.online ? 'Online' : 'Offline') + '"></span>'
25226
+ + '</div>';
24970
25227
  }
24971
- // Hide the picker entirely if there are no custom profiles — declutters the chat input row.
24972
- sel.style.display = customCount === 0 ? 'none' : '';
24973
- } catch(e) { /* profiles are optional */ }
25228
+ pop.innerHTML = html;
25229
+ }
25230
+
25231
+ // Update input placeholder to match the active agent
25232
+ var input = document.getElementById('chat-input');
25233
+ if (input) input.placeholder = 'Message ' + active.name + '…';
25234
+ }
25235
+
25236
+ function applyAgentAvatar(el, identity) {
25237
+ if (!el) return;
25238
+ // Wipe + recompute classes so we can swap between Clementine/hired
25239
+ // and toggle has-image on each refresh without leftover state.
25240
+ var base = el.classList.contains('chat-agent-row-avatar') ? 'chat-agent-row-avatar' : 'chat-agent-btn-avatar';
25241
+ el.className = base + (identity.primary ? '' : ' hired') + (identity.avatar ? ' has-image' : '');
25242
+ if (identity.avatar) {
25243
+ el.style.backgroundImage = "url('" + identity.avatar + "')";
25244
+ } else {
25245
+ el.style.backgroundImage = '';
25246
+ }
25247
+ el.textContent = (identity.name || '?').charAt(0).toUpperCase();
25248
+ }
25249
+
25250
+ function toggleAgentPicker(e) {
25251
+ if (e && e.stopPropagation) e.stopPropagation();
25252
+ var btn = document.getElementById('chat-agent-btn');
25253
+ var pop = document.getElementById('chat-agent-popover');
25254
+ _agentPickerOpen = !_agentPickerOpen;
25255
+ if (btn) btn.setAttribute('aria-expanded', _agentPickerOpen ? 'true' : 'false');
25256
+ if (pop) pop.hidden = !_agentPickerOpen;
24974
25257
  }
24975
25258
 
24976
- async function switchProfile(slug) {
25259
+ async function pickAgent(slug) {
25260
+ toggleAgentPicker();
25261
+ if ((slug || '') === (currentAgentSlug || '')) return;
24977
25262
  try {
24978
25263
  await apiJson('POST', '/api/profiles/switch', { slug: slug || null });
24979
- // Clear chat display since session was reset
25264
+ currentAgentSlug = slug || null;
25265
+ // Server-side session was cleared. Mirror that visually with the
25266
+ // new agent's empty state — replaces the bare "Session cleared." line.
24980
25267
  var container = document.getElementById('chat-messages');
24981
- container.innerHTML = '<div class="empty-state"><p style="margin-bottom:14px;color:var(--text-muted)">Profile switched' + (slug ? ' to <strong>' + esc(slug) + '</strong>' : '') + '. Session cleared.</p></div>';
24982
- toast(slug ? 'Switched to ' + slug : 'Profile cleared', 'success');
24983
- } catch(e) { toast('Failed to switch profile: ' + e, 'error'); }
25268
+ if (container) container.innerHTML = renderEmptyStateFor(currentAgentSlug);
25269
+ await refreshChatAgentPicker();
25270
+ var picked = _chatAgentRows.find ? _chatAgentRows.find(function(r){ return r.slug === (slug || ''); }) : null;
25271
+ toast(slug ? ('Now chatting with ' + (picked ? picked.name : slug)) : 'Switched to Clementine', 'success');
25272
+ } catch (e) {
25273
+ toast('Failed to switch agent: ' + e, 'error');
25274
+ }
24984
25275
  }
24985
25276
 
25277
+ // Click-outside closes the popover
25278
+ document.addEventListener('click', function(e) {
25279
+ if (!_agentPickerOpen) return;
25280
+ var btn = document.getElementById('chat-agent-btn');
25281
+ var pop = document.getElementById('chat-agent-popover');
25282
+ if (!btn || !pop) return;
25283
+ if (btn.contains(e.target) || pop.contains(e.target)) return;
25284
+ toggleAgentPicker();
25285
+ });
25286
+
25287
+ // Back-compat shim — older call sites still reference loadProfiles().
25288
+ function loadProfiles() { return refreshChatAgentPicker(); }
25289
+
24986
25290
  // ── Skill Studio — opens builder in skill-focused mode ──────────
24987
25291
 
24988
25292
  function openSkillStudio() {
@@ -28553,7 +28857,14 @@ function toggleHomeChat(forceOpen) {
28553
28857
  var input = document.getElementById('chat-input');
28554
28858
  if (input) input.focus();
28555
28859
  }, 80);
28556
- if (typeof loadProfiles === 'function') loadProfiles();
28860
+ // Refresh the agent picker (replaces the legacy loadProfiles()).
28861
+ // Also populate the empty state for the active agent if the panel
28862
+ // is still empty — first-open path.
28863
+ if (typeof refreshChatAgentPicker === 'function') refreshChatAgentPicker();
28864
+ var container = document.getElementById('chat-messages');
28865
+ if (container && !container.children.length) {
28866
+ container.innerHTML = renderEmptyStateFor(currentAgentSlug);
28867
+ }
28557
28868
  }
28558
28869
  }
28559
28870
 
@@ -32517,17 +32828,19 @@ try {
32517
32828
  // Lazy-refresh the cache if the SSE event names a slug we
32518
32829
  // haven't seen yet (e.g. an agent hired since page load).
32519
32830
  if (deepSlug && !__teamAgentsByslug[deepSlug]) { ensureTeamAgentCache(true); }
32520
- var deepIdentity = deepName
32521
- ? { slug: deepSlug, name: deepName, initial: deepName.charAt(0).toUpperCase() }
32522
- : getAssistantIdentity(deepSlug);
32831
+ var deepIdentity = getAssistantIdentity(deepSlug);
32832
+ // Server-side payload may carry a fresher name than our cache.
32833
+ if (deepName) deepIdentity.name = deepName;
32834
+ if (deepName) deepIdentity.initial = deepName.charAt(0).toUpperCase();
32523
32835
  if (container && text) {
32524
32836
  var emptyState = container.querySelector('.empty-state');
32525
32837
  if (emptyState) emptyState.remove();
32526
32838
  var row = document.createElement('div');
32527
32839
  row.className = 'chat-assistant-row';
32528
32840
  var av = document.createElement('div');
32529
- av.className = 'chat-avatar-sm';
32841
+ av.className = 'chat-avatar-sm' + (deepIdentity.primary ? '' : ' hired') + (deepIdentity.avatar ? ' has-image' : '');
32530
32842
  av.title = deepIdentity.name;
32843
+ if (deepIdentity.avatar) av.style.backgroundImage = "url('" + deepIdentity.avatar + "')";
32531
32844
  av.innerHTML = deepIdentity.initial;
32532
32845
  row.appendChild(av);
32533
32846
  var bubble = document.createElement('div');
package/dist/cli/index.js CHANGED
@@ -15,9 +15,12 @@ catch {
15
15
  */
16
16
  import { Command } from 'commander';
17
17
  import { spawn, execSync } from 'node:child_process';
18
- import { cpSync, existsSync, openSync, closeSync, readSync, readFileSync, readdirSync, writeFileSync, unlinkSync, mkdirSync, statSync, } from 'node:fs';
18
+ import http from 'node:http';
19
+ import https from 'node:https';
20
+ import { cpSync, createWriteStream, existsSync, openSync, closeSync, readSync, readFileSync, readdirSync, writeFileSync, unlinkSync, mkdirSync, renameSync, statSync, } from 'node:fs';
19
21
  import os from 'node:os';
20
22
  import path from 'node:path';
23
+ import { pipeline } from 'node:stream/promises';
21
24
  import { fileURLToPath } from 'node:url';
22
25
  import { runSetup } from './setup.js';
23
26
  import { cmdCronList, cmdCronRun, cmdCronRunDue, cmdCronRuns, cmdCronAdd, cmdCronTest, cmdHeartbeat } from './cron.js';
@@ -1963,6 +1966,186 @@ function cmdTools() {
1963
1966
  console.log();
1964
1967
  }
1965
1968
  }
1969
+ // ── Desktop installer command ───────────────────────────────────────
1970
+ const DESKTOP_RELEASE_REPO = process.env.CLEMENTINE_DESKTOP_REPO || 'Natebreynolds/Clementine-AI-Assistant';
1971
+ function githubRequestHeaders() {
1972
+ return {
1973
+ Accept: 'application/vnd.github+json',
1974
+ 'User-Agent': `clementine-cli/${pkgVersion}`,
1975
+ 'X-GitHub-Api-Version': '2022-11-28',
1976
+ };
1977
+ }
1978
+ function requestUrl(url, redirects = 0) {
1979
+ return new Promise((resolve, reject) => {
1980
+ if (redirects > 5) {
1981
+ reject(new Error('Too many redirects while contacting GitHub.'));
1982
+ return;
1983
+ }
1984
+ const parsed = new URL(url);
1985
+ const client = parsed.protocol === 'http:' ? http : https;
1986
+ const req = client.get(parsed, { headers: githubRequestHeaders() }, (res) => {
1987
+ const status = res.statusCode ?? 0;
1988
+ const location = res.headers.location;
1989
+ if (status >= 300 && status < 400 && location) {
1990
+ res.resume();
1991
+ resolve(requestUrl(new URL(location, parsed).toString(), redirects + 1));
1992
+ return;
1993
+ }
1994
+ if (status < 200 || status >= 300) {
1995
+ let body = '';
1996
+ res.setEncoding('utf-8');
1997
+ res.on('data', (chunk) => { body += chunk; });
1998
+ res.on('end', () => {
1999
+ const detail = body.trim() ? `: ${body.trim().slice(0, 240)}` : '';
2000
+ reject(new Error(`GitHub request failed (${status})${detail}`));
2001
+ });
2002
+ res.on('error', reject);
2003
+ return;
2004
+ }
2005
+ resolve(res);
2006
+ });
2007
+ req.setTimeout(30_000, () => {
2008
+ req.destroy(new Error('Timed out while contacting GitHub.'));
2009
+ });
2010
+ req.on('error', reject);
2011
+ });
2012
+ }
2013
+ async function readResponseText(res) {
2014
+ return await new Promise((resolve, reject) => {
2015
+ let body = '';
2016
+ res.setEncoding('utf-8');
2017
+ res.on('data', (chunk) => { body += chunk; });
2018
+ res.on('end', () => resolve(body));
2019
+ res.on('error', reject);
2020
+ });
2021
+ }
2022
+ async function fetchJson(url) {
2023
+ const body = await readResponseText(await requestUrl(url));
2024
+ try {
2025
+ return JSON.parse(body);
2026
+ }
2027
+ catch {
2028
+ throw new Error('GitHub returned an invalid JSON response.');
2029
+ }
2030
+ }
2031
+ function normalizeReleaseVersion(version) {
2032
+ const raw = (version || 'latest').trim();
2033
+ if (!raw || raw === 'latest')
2034
+ return 'latest';
2035
+ return raw.startsWith('v') ? raw : `v${raw}`;
2036
+ }
2037
+ function desktopReleaseApiUrl(version) {
2038
+ const normalized = normalizeReleaseVersion(version);
2039
+ const repo = encodeURIComponent(DESKTOP_RELEASE_REPO).replace('%2F', '/');
2040
+ if (normalized === 'latest') {
2041
+ return `https://api.github.com/repos/${repo}/releases/latest`;
2042
+ }
2043
+ return `https://api.github.com/repos/${repo}/releases/tags/${encodeURIComponent(normalized)}`;
2044
+ }
2045
+ function macDesktopArch() {
2046
+ if (process.arch === 'arm64')
2047
+ return 'arm64';
2048
+ if (process.arch === 'x64')
2049
+ return 'x64';
2050
+ throw new Error(`Clementine Desktop is only packaged for Apple silicon and Intel Macs. Detected: ${process.arch}`);
2051
+ }
2052
+ function selectDesktopDmgAsset(release) {
2053
+ const assets = release.assets ?? [];
2054
+ const arch = macDesktopArch();
2055
+ const archSpecific = assets.find(asset => asset.name.endsWith(`-mac-${arch}.dmg`));
2056
+ if (archSpecific)
2057
+ return archSpecific;
2058
+ const universal = assets.find(asset => /-mac-universal\.dmg$/i.test(asset.name));
2059
+ if (universal)
2060
+ return universal;
2061
+ const available = assets
2062
+ .filter(asset => /\.dmg$/i.test(asset.name))
2063
+ .map(asset => asset.name)
2064
+ .join(', ');
2065
+ throw new Error(available
2066
+ ? `No ${arch} macOS DMG found in this release. Available DMGs: ${available}`
2067
+ : 'No macOS DMG asset found in the latest Clementine release.');
2068
+ }
2069
+ function expandHomePath(rawPath) {
2070
+ if (rawPath === '~')
2071
+ return os.homedir();
2072
+ if (rawPath.startsWith('~/'))
2073
+ return path.join(os.homedir(), rawPath.slice(2));
2074
+ return rawPath;
2075
+ }
2076
+ function desktopDownloadDestination(output, assetName) {
2077
+ if (output) {
2078
+ const expanded = path.resolve(expandHomePath(output));
2079
+ if (existsSync(expanded) && statSync(expanded).isDirectory()) {
2080
+ return path.join(expanded, assetName);
2081
+ }
2082
+ return expanded;
2083
+ }
2084
+ return path.join(os.homedir(), 'Downloads', assetName);
2085
+ }
2086
+ async function downloadFile(url, destination, expectedSize) {
2087
+ mkdirSync(path.dirname(destination), { recursive: true });
2088
+ const tempPath = `${destination}.download`;
2089
+ try {
2090
+ unlinkSync(tempPath);
2091
+ }
2092
+ catch { /* no previous partial download */ }
2093
+ const res = await requestUrl(url);
2094
+ const total = Number(res.headers['content-length'] ?? expectedSize ?? 0);
2095
+ let downloaded = 0;
2096
+ let lastPrintedAt = 0;
2097
+ res.on('data', (chunk) => {
2098
+ downloaded += chunk.length;
2099
+ const now = Date.now();
2100
+ if (now - lastPrintedAt < 250 && downloaded !== total)
2101
+ return;
2102
+ lastPrintedAt = now;
2103
+ const totalLabel = total > 0 ? ` / ${formatBytes(total)}` : '';
2104
+ process.stdout.write(`\r Downloaded ${formatBytes(downloaded)}${totalLabel}`);
2105
+ });
2106
+ try {
2107
+ await pipeline(res, createWriteStream(tempPath, { mode: 0o644 }));
2108
+ process.stdout.write('\n');
2109
+ renameSync(tempPath, destination);
2110
+ }
2111
+ catch (err) {
2112
+ try {
2113
+ unlinkSync(tempPath);
2114
+ }
2115
+ catch { /* best effort */ }
2116
+ throw err;
2117
+ }
2118
+ }
2119
+ function openDesktopInstaller(destination) {
2120
+ const child = spawn('open', [destination], { detached: true, stdio: 'ignore' });
2121
+ child.unref();
2122
+ }
2123
+ async function cmdDesktopDownload(options) {
2124
+ const DIM = '\x1b[0;90m';
2125
+ const BOLD = '\x1b[1m';
2126
+ const GREEN = '\x1b[0;32m';
2127
+ const RESET = '\x1b[0m';
2128
+ if (process.platform !== 'darwin') {
2129
+ throw new Error('Clementine Desktop install is currently macOS only.');
2130
+ }
2131
+ const release = await fetchJson(desktopReleaseApiUrl(options.version));
2132
+ const asset = selectDesktopDmgAsset(release);
2133
+ const destination = desktopDownloadDestination(options.output, asset.name);
2134
+ console.log();
2135
+ console.log(` ${DIM}Clementine Desktop${RESET} ${release.tag_name ? `${DIM}(${release.tag_name})${RESET}` : ''}`);
2136
+ console.log(` Downloading ${BOLD}${asset.name}${RESET}`);
2137
+ console.log(` ${DIM}${destination}${RESET}`);
2138
+ await downloadFile(asset.browser_download_url, destination, asset.size);
2139
+ console.log(` ${GREEN}OK${RESET} Downloaded ${BOLD}${path.basename(destination)}${RESET}`);
2140
+ if (options.open !== false) {
2141
+ openDesktopInstaller(destination);
2142
+ console.log(` ${GREEN}OK${RESET} Opened installer. Drag Clementine to Applications.`);
2143
+ }
2144
+ else {
2145
+ console.log(` ${DIM}Open it later with: open "${destination}"${RESET}`);
2146
+ }
2147
+ console.log();
2148
+ }
1966
2149
  // ── Program ──────────────────────────────────────────────────────────
1967
2150
  const program = new Command();
1968
2151
  let pkgVersion = '0.0.0';
@@ -2356,6 +2539,41 @@ program
2356
2539
  process.exit(1);
2357
2540
  });
2358
2541
  });
2542
+ const desktopCmd = program
2543
+ .command('desktop')
2544
+ .description('Download and open Clementine Desktop for macOS')
2545
+ .option('-o, --output <path>', 'Download path or directory')
2546
+ .option('--version <tag>', 'GitHub release tag/version (default: latest)', 'latest')
2547
+ .option('--no-open', 'Download only; do not open the DMG')
2548
+ .action((options) => {
2549
+ cmdDesktopDownload({ ...options, open: options.open !== false }).catch((err) => {
2550
+ console.error('Desktop install failed:', err instanceof Error ? err.message : err);
2551
+ process.exit(1);
2552
+ });
2553
+ });
2554
+ desktopCmd
2555
+ .command('install')
2556
+ .description('Download and open the latest macOS DMG installer')
2557
+ .option('-o, --output <path>', 'Download path or directory')
2558
+ .option('--version <tag>', 'GitHub release tag/version (default: latest)', 'latest')
2559
+ .option('--no-open', 'Download only; do not open the DMG')
2560
+ .action((options) => {
2561
+ cmdDesktopDownload({ ...options, open: options.open !== false }).catch((err) => {
2562
+ console.error('Desktop install failed:', err instanceof Error ? err.message : err);
2563
+ process.exit(1);
2564
+ });
2565
+ });
2566
+ desktopCmd
2567
+ .command('download')
2568
+ .description('Download the latest macOS DMG without opening it')
2569
+ .option('-o, --output <path>', 'Download path or directory')
2570
+ .option('--version <tag>', 'GitHub release tag/version (default: latest)', 'latest')
2571
+ .action((options) => {
2572
+ cmdDesktopDownload({ ...options, open: false }).catch((err) => {
2573
+ console.error('Desktop download failed:', err instanceof Error ? err.message : err);
2574
+ process.exit(1);
2575
+ });
2576
+ });
2359
2577
  program
2360
2578
  .command('update')
2361
2579
  .description('Pull latest code, rebuild, and reinstall (preserves config). Pass "history" to show recent updates.')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.64",
3
+ "version": "1.18.65",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",