clementine-agent 1.18.63 → 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
 
@@ -54,10 +54,10 @@ const CRON_FIXER_PROMPT = [
54
54
  'You are the cron-fix specialist. You diagnose and apply fixes to broken cron jobs.',
55
55
  '',
56
56
  'Workflow:',
57
- '1. Call `list_broken_jobs` to see what is currently broken with their cached diagnoses.',
57
+ '1. If you already know the job name (parent named it, or notification context names it), call `cron_diagnose` first it returns the bounded recent-run summary, phase status, and inferred root cause in one shot. If you need a list of currently failing jobs, call `list_broken_jobs` instead.',
58
58
  '2. For each job the user/parent asked about, check the proposed fix:',
59
59
  ' - confidence=high + risk=low + autoApply=true → call `apply_broken_job_fix`.',
60
- ' - Otherwise → describe the diagnosis and ask the parent for explicit approval.',
60
+ ' - Otherwise → describe the diagnosis and ask the parent for explicit approval before any manual repair.',
61
61
  '3. After applying a fix, the verification system auto-rolls-back if the next 3 runs do not improve. You do NOT need to monitor manually.',
62
62
  '',
63
63
  'Return: a one-paragraph summary of what you applied (or what is blocking apply), per job.',
@@ -154,6 +154,7 @@ export function buildAgentMap(opts = {}) {
154
154
  prompt: CRON_FIXER_PROMPT,
155
155
  model: 'sonnet',
156
156
  tools: [
157
+ 'mcp__clementine-tools__cron_diagnose',
157
158
  'mcp__clementine-tools__list_broken_jobs',
158
159
  'mcp__clementine-tools__apply_broken_job_fix',
159
160
  'mcp__clementine-tools__cron_list',
@@ -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.')
@@ -20,7 +20,4 @@ export declare function detectCronDiagnosticRequest(text: string, opts?: {
20
20
  export declare function buildCronDiagnosticResponseForRequest(request: CronDiagnosticRequest, opts?: {
21
21
  baseDir: string;
22
22
  }): string | null;
23
- export declare function buildCronDiagnosticResponse(text: string, opts?: {
24
- baseDir: string;
25
- }): string | null;
26
23
  //# sourceMappingURL=cron-diagnostic-turn.d.ts.map
@@ -274,10 +274,4 @@ export function buildCronDiagnosticResponseForRequest(request, opts = { baseDir:
274
274
  }
275
275
  return lines.join('\n');
276
276
  }
277
- export function buildCronDiagnosticResponse(text, opts = { baseDir: process.env.CLEMENTINE_HOME || '' }) {
278
- const request = detectCronDiagnosticRequest(text, { baseDir: opts.baseDir });
279
- if (!request || !opts.baseDir)
280
- return null;
281
- return buildCronDiagnosticResponseForRequest(request, opts);
282
- }
283
277
  //# sourceMappingURL=cron-diagnostic-turn.js.map
@@ -8,7 +8,7 @@
8
8
  import path from 'node:path';
9
9
  import { listBackgroundTasks } from '../agent/background-tasks.js';
10
10
  import { buildNotificationContextPrompt, findRecentNotificationContext, looksLikeNotificationFollowup, } from './notification-context.js';
11
- import { buildCronDiagnosticResponseForRequest, detectCronDiagnosticRequest, isInternalSyntheticPrompt, } from './cron-diagnostic-turn.js';
11
+ import { detectCronDiagnosticRequest, isInternalSyntheticPrompt, } from './cron-diagnostic-turn.js';
12
12
  export { isInternalSyntheticPrompt } from './cron-diagnostic-turn.js';
13
13
  const RECENT_TASK_TTL_MS = 24 * 60 * 60 * 1000;
14
14
  function normalizeForMatch(text) {
@@ -22,48 +22,6 @@ function normalizeForMatch(text) {
22
22
  function compactWhitespace(text) {
23
23
  return text.replace(/\s+/g, ' ').trim();
24
24
  }
25
- function wantsFix(text) {
26
- return /\b(fix|repair|solve|handle|diagnose|debug|what broke|what happened|why did|why is|issue|problem|failure|failed|failing)\b/i.test(text);
27
- }
28
- function jobMentionedInText(jobName, text) {
29
- const normalizedText = normalizeForMatch(text);
30
- const normalizedJob = normalizeForMatch(jobName);
31
- return !!normalizedJob && normalizedText.includes(normalizedJob);
32
- }
33
- function resolveNotificationJob(event, userText, baseDir) {
34
- const explicit = detectCronDiagnosticRequest(userText, { baseDir });
35
- if (explicit?.jobName)
36
- return explicit.jobName;
37
- const jobs = event.jobNames ?? [];
38
- if (jobs.length === 1)
39
- return jobs[0] ?? null;
40
- const mentioned = jobs.find((job) => jobMentionedInText(job, userText));
41
- return mentioned ?? null;
42
- }
43
- function summarizeCronNotification(event, userText, opts) {
44
- const jobs = event.jobNames ?? [];
45
- if (jobs.length === 0)
46
- return null;
47
- const targetJob = resolveNotificationJob(event, userText, opts.baseDir);
48
- if (targetJob) {
49
- return buildCronDiagnosticResponseForRequest({ jobName: targetJob, wantsFix: wantsFix(userText) }, { baseDir: opts.baseDir });
50
- }
51
- const lines = [
52
- `I am resolving this to the recent cron failure alert: ${jobs.join(', ')}.`,
53
- 'More than one job was in that alert, so I am not going to guess or start background work.',
54
- '',
55
- ];
56
- for (const job of jobs.slice(0, 5)) {
57
- const diagnostic = buildCronDiagnosticResponseForRequest({ jobName: job, wantsFix: false }, { baseDir: opts.baseDir });
58
- const preview = diagnostic
59
- ? diagnostic.split('\n').slice(1, 4).join(' ')
60
- : 'No local diagnostic summary available.';
61
- lines.push(`- ${job}: ${compactWhitespace(preview).slice(0, 260)}`);
62
- }
63
- lines.push('');
64
- lines.push(`Reply \`fix ${jobs[0]}\` or name the job you want me to repair first.`);
65
- return lines.join('\n');
66
- }
67
25
  function taskMatchesSession(task, sessionKey) {
68
26
  return task.sessionKey === sessionKey;
69
27
  }
@@ -151,22 +109,11 @@ export function resolveRecentOperationalContext(sessionKey, text, opts) {
151
109
  now: opts.now,
152
110
  });
153
111
  if (notification) {
154
- if (notification.type === 'cron_failure') {
155
- const responseText = summarizeCronNotification(notification, text, opts);
156
- if (responseText) {
157
- return {
158
- source: 'notification',
159
- reason: 'vague-followup-to-cron-failure-notification',
160
- responseText,
161
- suppressDeepMode: true,
162
- eventId: notification.id,
163
- jobNames: notification.jobNames,
164
- };
165
- }
166
- }
167
112
  return {
168
113
  source: 'notification',
169
- reason: 'vague-followup-to-proactive-notification',
114
+ reason: notification.type === 'cron_failure'
115
+ ? 'vague-followup-to-cron-failure-notification'
116
+ : 'vague-followup-to-proactive-notification',
170
117
  promptText: buildNotificationContextPrompt(notification, text),
171
118
  suppressDeepMode: true,
172
119
  eventId: notification.id,
@@ -20,7 +20,6 @@ import { listBackgroundTasks, loadBackgroundTask, markFailed } from '../agent/ba
20
20
  import { applyAssistantExperienceUpdate, detectApprovalReply, detectLocalTurn } from '../agent/local-turn.js';
21
21
  import { buildApprovalFollowupPrompt, detectActionExpectation } from '../agent/action-enforcer.js';
22
22
  import { updateClementineJson } from '../config/clementine-json.js';
23
- import { buildCronDiagnosticResponse } from './cron-diagnostic-turn.js';
24
23
  import { classifyIntent } from '../agent/intent-classifier.js';
25
24
  import { decideTurn } from '../agent/turn-policy.js';
26
25
  import { recordProactiveNotificationEvent, } from './notification-context.js';
@@ -1441,34 +1440,6 @@ export class Gateway {
1441
1440
  text = recentContext.promptText;
1442
1441
  }
1443
1442
  }
1444
- // Cron "what broke / fix this job" asks should not spin up a broad SDK
1445
- // session. They are bounded local diagnostics over run summaries and scalar
1446
- // config only, and they intentionally do not execute the cron job.
1447
- if (this.isTrustedPersonalSession(sessionKey) && !isInternalSyntheticPrompt(text)) {
1448
- const cronDiagnostic = buildCronDiagnosticResponse(text, { baseDir: BASE_DIR });
1449
- if (cronDiagnostic) {
1450
- const current = this.sessions.get(sessionKey);
1451
- if (current?.abortController && !current.abortController.signal.aborted) {
1452
- current.abortController.abort('replaced-by-cron-diagnostic');
1453
- logger.info({ sessionKey }, 'Interrupted active chat for local cron diagnostic');
1454
- }
1455
- this.assistant.injectContext(sessionKey, originalText, cronDiagnostic);
1456
- if (onText) {
1457
- try {
1458
- await onText(cronDiagnostic);
1459
- }
1460
- catch { /* channel streaming is best-effort */ }
1461
- }
1462
- logger.info({
1463
- sessionKey,
1464
- totalMs: Date.now() - tInnerStart,
1465
- chatMs: Date.now() - localTurnStarted,
1466
- localCronDiagnostic: true,
1467
- responseLen: cronDiagnostic.length,
1468
- }, 'chat:latency');
1469
- return cronDiagnostic;
1470
- }
1471
- }
1472
1443
  // Show "queued" status if either lane or session lock is contended,
1473
1444
  // so the user doesn't stare at "thinking..." for up to 60s while a
1474
1445
  // previous message is still processing.
@@ -1779,5 +1779,16 @@ export function registerAdminTools(server) {
1779
1779
  `The fix-verification tracker will roll it back automatically if the next runs don't improve. ` +
1780
1780
  `Root cause: ${d.rootCause?.slice(0, 200) ?? ""}.`);
1781
1781
  });
1782
+ server.tool('cron_diagnose', 'Return a bounded deterministic diagnosis for one cron job — recent run summary, unleashed phase status, last clean success, current scalar config, and the inferred root cause. Reads only from local run history and cron config; does NOT execute the job. Use this when the user asks what is broken with a specific job, before deciding whether to call apply_broken_job_fix or to propose a manual repair.', {
1783
+ jobName: z.string().describe('The job name as shown in CRON.md or list_broken_jobs output (e.g. "audit-inbox-check" or "ross-the-sdr:reply-detection").'),
1784
+ }, async ({ jobName }) => {
1785
+ const { buildCronDiagnosticResponseForRequest } = await import('../gateway/cron-diagnostic-turn.js');
1786
+ const response = buildCronDiagnosticResponseForRequest({ jobName, wantsFix: true }, { baseDir: BASE_DIR });
1787
+ if (!response) {
1788
+ return textResult(`No diagnostic data for \`${jobName}\` — either the job is not configured in CRON.md or there is no run history yet. ` +
1789
+ `Check the job name with cron_list or list_broken_jobs.`);
1790
+ }
1791
+ return textResult(response);
1792
+ });
1782
1793
  }
1783
1794
  //# sourceMappingURL=admin-tools.js.map
@@ -27,6 +27,8 @@ mac:
27
27
  target:
28
28
  - dmg
29
29
  - zip
30
+ dmg:
31
+ sign: true
30
32
  artifactName: Clementine-${version}-mac-${arch}.${ext}
31
33
  publish:
32
34
  provider: github
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.63",
3
+ "version": "1.18.65",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -18,7 +18,8 @@
18
18
  "desktop:debug": "npm run build && CLEMENTINE_DESKTOP_DEBUG=1 electron dist/desktop/main.js",
19
19
  "desktop:prepare": "npm run build && npm rebuild better-sqlite3",
20
20
  "desktop:pack": "npm run desktop:prepare && electron-builder --mac --dir",
21
- "desktop:dist": "npm run desktop:prepare && electron-builder --mac",
21
+ "desktop:dist": "scripts/build-desktop-mac.sh",
22
+ "desktop:dist:unnotarized": "CLEMENTINE_ALLOW_UNNOTARIZED=1 scripts/build-desktop-mac.sh",
22
23
  "mcp": "tsx src/tools/mcp-server.ts",
23
24
  "cli": "tsx src/cli/index.ts",
24
25
  "typecheck": "tsc --noEmit",
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5
+ cd "$ROOT_DIR"
6
+
7
+ load_env_file() {
8
+ local file="$1"
9
+ if [[ -f "$file" ]]; then
10
+ set -a
11
+ # shellcheck disable=SC1090
12
+ source "$file"
13
+ set +a
14
+ fi
15
+ }
16
+
17
+ load_env_file "$ROOT_DIR/.env.signing"
18
+ load_env_file "$ROOT_DIR/.env.release"
19
+ load_env_file "$HOME/.clementine/signing.env"
20
+
21
+ if [[ -n "${APPLE_ID_PASSWORD:-}" && -z "${APPLE_APP_SPECIFIC_PASSWORD:-}" ]]; then
22
+ export APPLE_APP_SPECIFIC_PASSWORD="$APPLE_ID_PASSWORD"
23
+ fi
24
+
25
+ if [[ -n "${CLEMENTINE_NOTARY_PROFILE:-}" && -z "${APPLE_KEYCHAIN_PROFILE:-}" ]]; then
26
+ export APPLE_KEYCHAIN_PROFILE="$CLEMENTINE_NOTARY_PROFILE"
27
+ fi
28
+
29
+ has_password_creds=false
30
+ if [[ -n "${APPLE_ID:-}" && -n "${APPLE_APP_SPECIFIC_PASSWORD:-}" && -n "${APPLE_TEAM_ID:-}" ]]; then
31
+ has_password_creds=true
32
+ fi
33
+
34
+ has_api_key_creds=false
35
+ if [[ -n "${APPLE_API_KEY:-}" && -n "${APPLE_API_KEY_ID:-}" && -n "${APPLE_API_ISSUER:-}" ]]; then
36
+ has_api_key_creds=true
37
+ fi
38
+
39
+ has_keychain_profile=false
40
+ if [[ -n "${APPLE_KEYCHAIN_PROFILE:-}" ]]; then
41
+ has_keychain_profile=true
42
+ fi
43
+
44
+ notarytool_args=()
45
+ if [[ "$has_password_creds" == true ]]; then
46
+ notarytool_args=(--apple-id "$APPLE_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --team-id "$APPLE_TEAM_ID")
47
+ elif [[ "$has_api_key_creds" == true ]]; then
48
+ notarytool_args=(--key "$APPLE_API_KEY" --key-id "$APPLE_API_KEY_ID" --issuer "$APPLE_API_ISSUER")
49
+ elif [[ "$has_keychain_profile" == true ]]; then
50
+ notarytool_args=(--keychain-profile "$APPLE_KEYCHAIN_PROFILE")
51
+ if [[ -n "${APPLE_KEYCHAIN:-}" ]]; then
52
+ notarytool_args=(--keychain "$APPLE_KEYCHAIN" "${notarytool_args[@]}")
53
+ fi
54
+ fi
55
+
56
+ if [[ "$has_password_creds" != true && "$has_api_key_creds" != true && "$has_keychain_profile" != true ]]; then
57
+ cat >&2 <<'EOF'
58
+ No Apple notarization credentials were detected, so the release DMG would be signed but not notarized.
59
+
60
+ Set one of these before running npm run desktop:dist:
61
+ - APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID
62
+ - APPLE_API_KEY, APPLE_API_KEY_ID, APPLE_API_ISSUER
63
+ - APPLE_KEYCHAIN_PROFILE
64
+
65
+ This script also accepts:
66
+ - APPLE_ID_PASSWORD as an alias for APPLE_APP_SPECIFIC_PASSWORD
67
+ - CLEMENTINE_NOTARY_PROFILE as an alias for APPLE_KEYCHAIN_PROFILE
68
+
69
+ One-time local keychain setup:
70
+ xcrun notarytool store-credentials clementine --apple-id <apple-id> --team-id 4AR3Y8XD72 --sync
71
+ CLEMENTINE_NOTARY_PROFILE=clementine npm run desktop:dist
72
+
73
+ For a local signed-but-unnotarized test build:
74
+ npm run desktop:dist:unnotarized
75
+ EOF
76
+ if [[ "${CLEMENTINE_ALLOW_UNNOTARIZED:-}" != "1" ]]; then
77
+ exit 1
78
+ fi
79
+ fi
80
+
81
+ npm run desktop:prepare
82
+ "$ROOT_DIR/node_modules/.bin/electron-builder" --mac
83
+
84
+ if [[ ${#notarytool_args[@]} -gt 0 ]]; then
85
+ package_version="$(node -p "require('./package.json').version")"
86
+ shopt -s nullglob
87
+ dmg_files=("$ROOT_DIR"/release/Clementine-"$package_version"-mac-*.dmg)
88
+ shopt -u nullglob
89
+
90
+ for dmg_file in "${dmg_files[@]}"; do
91
+ echo "Notarizing DMG wrapper: $(basename "$dmg_file")"
92
+ xcrun notarytool submit "$dmg_file" --wait "${notarytool_args[@]}"
93
+ xcrun stapler staple "$dmg_file"
94
+ xcrun stapler validate "$dmg_file"
95
+
96
+ case "$(uname -m)" in
97
+ arm64) app_builder="$ROOT_DIR/node_modules/app-builder-bin/mac/app-builder_arm64" ;;
98
+ x86_64) app_builder="$ROOT_DIR/node_modules/app-builder-bin/mac/app-builder_amd64" ;;
99
+ *) app_builder="" ;;
100
+ esac
101
+ if [[ -n "$app_builder" && -x "$app_builder" ]]; then
102
+ "$app_builder" blockmap --input "$dmg_file" --output "$dmg_file.blockmap"
103
+ fi
104
+
105
+ if [[ -f "$ROOT_DIR/release/latest-mac.yml" ]]; then
106
+ node --input-type=module - "$dmg_file" "$ROOT_DIR/release/latest-mac.yml" <<'NODE'
107
+ import { createHash } from 'node:crypto';
108
+ import { readFileSync, statSync, writeFileSync } from 'node:fs';
109
+ import { basename } from 'node:path';
110
+
111
+ const [dmgPath, latestPath] = process.argv.slice(2);
112
+ const url = basename(dmgPath);
113
+ const sha512 = createHash('sha512').update(readFileSync(dmgPath)).digest('base64');
114
+ const size = statSync(dmgPath).size;
115
+ const lines = readFileSync(latestPath, 'utf8').split(/\r?\n/);
116
+
117
+ for (let index = 0; index < lines.length; index++) {
118
+ if (lines[index].trim() === `- url: ${url}`) {
119
+ for (let cursor = index + 1; cursor < lines.length; cursor++) {
120
+ if (/^\S/.test(lines[cursor]) || /^\s*-\s+url:/.test(lines[cursor])) break;
121
+ if (lines[cursor].trim().startsWith('sha512:')) lines[cursor] = ` sha512: ${sha512}`;
122
+ if (lines[cursor].trim().startsWith('size:')) lines[cursor] = ` size: ${size}`;
123
+ }
124
+ break;
125
+ }
126
+ }
127
+
128
+ writeFileSync(latestPath, `${lines.join('\n').replace(/\n*$/, '')}\n`);
129
+ NODE
130
+ fi
131
+ done
132
+ fi