openclacky 1.3.4 → 1.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/lib/clacky/agent/fake_tool_call_detector.rb +52 -0
  4. data/lib/clacky/agent/message_compressor.rb +32 -8
  5. data/lib/clacky/agent/message_compressor_helper.rb +113 -12
  6. data/lib/clacky/agent/session_serializer.rb +3 -2
  7. data/lib/clacky/agent/tool_executor.rb +0 -12
  8. data/lib/clacky/agent.rb +74 -12
  9. data/lib/clacky/api_extension.rb +81 -0
  10. data/lib/clacky/api_extension_loader.rb +13 -1
  11. data/lib/clacky/cli.rb +0 -1
  12. data/lib/clacky/client.rb +14 -17
  13. data/lib/clacky/default_agents/_panels/time_machine/panel.js +22 -0
  14. data/lib/clacky/default_agents/base_prompt.md +1 -0
  15. data/lib/clacky/default_extensions/meeting/handler.rb +331 -0
  16. data/lib/clacky/default_extensions/meeting/meeting.js +790 -0
  17. data/lib/clacky/default_extensions/meeting/meta.yml +3 -0
  18. data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +44 -0
  19. data/lib/clacky/default_skills/media-gen/SKILL.md +63 -0
  20. data/lib/clacky/default_skills/media-gen/scripts/video_seq.sh +114 -0
  21. data/lib/clacky/json_ui_controller.rb +1 -1
  22. data/lib/clacky/media/base.rb +60 -0
  23. data/lib/clacky/media/dashscope.rb +385 -21
  24. data/lib/clacky/media/gemini.rb +9 -0
  25. data/lib/clacky/media/generator.rb +52 -0
  26. data/lib/clacky/media/openai_compat.rb +166 -0
  27. data/lib/clacky/null_ui_controller.rb +13 -0
  28. data/lib/clacky/plain_ui_controller.rb +1 -1
  29. data/lib/clacky/providers.rb +50 -2
  30. data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
  31. data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
  32. data/lib/clacky/server/http_server.rb +260 -122
  33. data/lib/clacky/server/session_registry.rb +54 -3
  34. data/lib/clacky/server/web_ui_controller.rb +3 -2
  35. data/lib/clacky/session_manager.rb +35 -4
  36. data/lib/clacky/skill_loader.rb +14 -2
  37. data/lib/clacky/tools/terminal/output_cleaner.rb +1 -3
  38. data/lib/clacky/tools/terminal.rb +0 -43
  39. data/lib/clacky/ui2/components/modal_component.rb +1 -1
  40. data/lib/clacky/ui2/layout_manager.rb +0 -5
  41. data/lib/clacky/ui2/progress_handle.rb +0 -3
  42. data/lib/clacky/ui2/ui_controller.rb +140 -40
  43. data/lib/clacky/ui_interface.rb +10 -1
  44. data/lib/clacky/utils/encoding.rb +25 -0
  45. data/lib/clacky/version.rb +1 -1
  46. data/lib/clacky/web/app.css +199 -23
  47. data/lib/clacky/web/components/onboard.js +1 -14
  48. data/lib/clacky/web/components/sidebar.js +1 -3
  49. data/lib/clacky/web/features/backup/store.js +23 -0
  50. data/lib/clacky/web/features/backup/view.js +49 -22
  51. data/lib/clacky/web/features/brand/view.js +8 -5
  52. data/lib/clacky/web/features/channels/store.js +1 -20
  53. data/lib/clacky/web/features/mcp/store.js +1 -20
  54. data/lib/clacky/web/features/profile/store.js +1 -13
  55. data/lib/clacky/web/features/profile/view.js +16 -4
  56. data/lib/clacky/web/features/skills/store.js +6 -21
  57. data/lib/clacky/web/features/tasks/view.js +77 -28
  58. data/lib/clacky/web/features/version/store.js +2 -0
  59. data/lib/clacky/web/i18n.js +46 -3
  60. data/lib/clacky/web/index.html +67 -26
  61. data/lib/clacky/web/sessions.js +177 -87
  62. data/lib/clacky/web/settings.js +34 -2
  63. data/lib/clacky/web/ws-dispatcher.js +12 -4
  64. data/lib/clacky.rb +12 -5
  65. metadata +8 -1
@@ -41,6 +41,7 @@ const Sessions = (() => {
41
41
  let _searchToken = 0; // monotonic counter; in-flight requests check against this
42
42
  let _cronView = false; // are we in the cron sub-view?
43
43
  let _cronCount = 0; // total cron sessions from server
44
+ let _latestCronUpdatedAt = null; // updated_at of the newest cron session (for virtual entry sort position)
44
45
  // ── Cron sub-view independent pagination (commit 2) ──────────────────────
45
46
  // The folded cron sub-view paginates *independently* of the outer list so
46
47
  // that "Load more" inside it never advances the outer list's cursor, and so
@@ -411,42 +412,27 @@ const Sessions = (() => {
411
412
  _hideNewMessageBanner();
412
413
  });
413
414
 
414
- // Detect actual user scroll interactions (wheel, touch, keyboard)
415
- // These fire BEFORE the scroll event, so we can set the flag reliably.
416
- const detectUserScroll = (e) => {
417
- // Only flag if user is scrolling up (negative deltaY = scroll up)
418
- // For wheel events: deltaY < 0 means scroll up
419
- // For touch/keyboard: check scroll position in the scroll event
420
- const isWheelUp = e.type === "wheel" && e.deltaY < 0;
421
- const isKeyboardUp = e.type === "keydown" && (e.key === "ArrowUp" || e.key === "PageUp" || e.key === "Home");
422
-
423
- if (isWheelUp || isKeyboardUp) {
424
- _userScrolledUp = true;
425
- }
426
- };
427
-
428
- messages.addEventListener("wheel", detectUserScroll, { passive: true });
429
- messages.addEventListener("keydown", detectUserScroll);
430
-
431
- // For touch devices: touchmove doesn't tell us direction, so check in scroll event
432
- let touchStartY = 0;
433
- messages.addEventListener("touchstart", (e) => {
434
- touchStartY = e.touches[0].clientY;
435
- }, { passive: true });
436
-
437
- messages.addEventListener("touchmove", (e) => {
438
- const touchDeltaY = e.touches[0].clientY - touchStartY;
439
- // touchDeltaY > 0 means finger moved down = content scrolls up
440
- if (touchDeltaY > 5) {
441
- _userScrolledUp = true;
442
- }
443
- }, { passive: true });
444
-
445
- // Monitor scroll position: clear flag when user reaches bottom
415
+ // Single source of truth for "is the user browsing history?": the scroll
416
+ // position itself. Every scrolling method mouse wheel, dragging the
417
+ // scrollbar, keyboard (Up/PageUp/Home/Space), touch swipe and momentum
418
+ // scrolling funnels through the `scroll` event, so reading the position
419
+ // here covers them all with no blind spots.
420
+ //
421
+ // The previous approach instead listened to specific input events
422
+ // (wheel/keydown/touchmove) to *infer* intent. Dragging the scrollbar
423
+ // fires `scroll` but none of those, so it was never detected and AI
424
+ // messages kept yanking the view back to the bottom — see C-5629.
425
+ //
426
+ // Streaming-append safety: appending content grows scrollHeight but leaves
427
+ // scrollTop untouched (verified in-browser, even with overflow-anchor:auto),
428
+ // so a content update never moves us "away from bottom" and never trips a
429
+ // false positive here. The 150px threshold in _isAtBottom is extra slack.
446
430
  messages.addEventListener("scroll", () => {
447
431
  if (_isAtBottom(messages)) {
448
432
  _userScrolledUp = false;
449
433
  _hideNewMessageBanner();
434
+ } else {
435
+ _userScrolledUp = true;
450
436
  }
451
437
  });
452
438
  }
@@ -468,30 +454,26 @@ const Sessions = (() => {
468
454
  document.getElementById("btn-new-session-inline")
469
455
  .addEventListener("click", () => Sessions.create("general"));
470
456
 
471
- // Split button: arrow (toggle dropdown)
472
- document.getElementById("btn-new-session-arrow")
473
- .addEventListener("click", (e) => {
474
- e.stopPropagation();
475
- const dd = document.getElementById("new-session-dropdown");
476
- dd.hidden = !dd.hidden;
477
- });
457
+ // Split button: arrow show dropdown on hover, hide when leaving the whole wrap
458
+ const arrow = document.getElementById("btn-new-session-arrow");
459
+ const wrap = arrow.closest(".btn-split-wrap");
460
+ const dd = document.getElementById("new-session-dropdown");
461
+
462
+ arrow.addEventListener("mouseenter", () => { dd.hidden = false; });
463
+ dd.addEventListener("mouseenter", () => { dd.hidden = false; });
464
+ wrap.addEventListener("mouseleave", () => { dd.hidden = true; });
465
+ dd.addEventListener("mouseleave", () => { dd.hidden = true; });
478
466
 
479
- // Dropdown item "Advanced Options…" — delegated because the dropdown
480
- // panel may be re-rendered; this keeps the binding stable.
467
+ // Dropdown item "Advanced Options…"
481
468
  document.addEventListener("click", (e) => {
482
469
  if (e.target && e.target.id === "btn-new-session-modal") {
483
- e.stopPropagation();
484
- document.getElementById("new-session-dropdown").hidden = true;
470
+ dd.hidden = true;
485
471
  Sessions.openNewSessionModal();
472
+ } else if (!wrap.contains(e.target)) {
473
+ dd.hidden = true;
486
474
  }
487
475
  });
488
476
 
489
- // Close dropdown when clicking anywhere else
490
- document.addEventListener("click", () => {
491
- const dd = document.getElementById("new-session-dropdown");
492
- if (dd && !dd.hidden) dd.hidden = true;
493
- });
494
-
495
477
  // Welcome screen "+ New Session" button
496
478
  document.getElementById("btn-welcome-new")
497
479
  .addEventListener("click", () => Sessions.create("general"));
@@ -1404,6 +1386,7 @@ const Sessions = (() => {
1404
1386
  el.dataset.raw = ev.content || "";
1405
1387
  el.innerHTML = _renderMarkdown(ev.content || "");
1406
1388
  _appendCopyButton(el);
1389
+ _enhanceTaskItems(el, ev.content || "");
1407
1390
  container.appendChild(el);
1408
1391
  break;
1409
1392
  }
@@ -1667,6 +1650,7 @@ const Sessions = (() => {
1667
1650
  code: session.error_code,
1668
1651
  message: session.error,
1669
1652
  top_up_url: session.top_up_url,
1653
+ raw_message: session.raw_message,
1670
1654
  });
1671
1655
  } else {
1672
1656
  Sessions.appendMsg("error", session.error);
@@ -1925,6 +1909,78 @@ const Sessions = (() => {
1925
1909
  _ensureCopyDelegation();
1926
1910
  }
1927
1911
 
1912
+ // System feature: any assistant message containing GFM task-list items
1913
+ // (`- [ ] ...`) gets a "spawn" button on each UNCHECKED item, turning a
1914
+ // todo into its own session. The new session's first prompt is the item
1915
+ // text plus the full message for context.
1916
+ function _stripThink(text) {
1917
+ if (!text) return "";
1918
+ return text.replace(/<think>[\s\S]*?<\/think>/g, "").replace(/^\s+/, "");
1919
+ }
1920
+
1921
+ function _enhanceTaskItems(el, rawText) {
1922
+ const seen = new Set();
1923
+ const context = _stripThink(rawText);
1924
+ el.querySelectorAll("li").forEach((li) => {
1925
+ const box = li.querySelector('input[type="checkbox"]');
1926
+ if (!box || box.checked) return; // only unchecked todos
1927
+ if (seen.has(li)) return;
1928
+ seen.add(li);
1929
+
1930
+ const text = li.textContent.trim();
1931
+ if (!text) return;
1932
+
1933
+ const btn = document.createElement("button");
1934
+ btn.type = "button";
1935
+ btn.className = "msg-todo-spawn";
1936
+ btn.title = I18n.t("chat.todo.spawn");
1937
+ btn.textContent = I18n.t("chat.todo.spawn");
1938
+ btn.dataset.todoText = text;
1939
+ btn.dataset.todoContext = context;
1940
+ li.appendChild(btn);
1941
+ });
1942
+ _ensureTodoSpawnDelegation();
1943
+ }
1944
+
1945
+ let _todoSpawnDelegationInstalled = false;
1946
+ function _ensureTodoSpawnDelegation() {
1947
+ if (_todoSpawnDelegationInstalled) return;
1948
+ const messages = RenderTarget.outer();
1949
+ if (!messages) return;
1950
+ _todoSpawnDelegationInstalled = true;
1951
+ messages.addEventListener("click", (e) => {
1952
+ const btn = e.target.closest(".msg-todo-spawn");
1953
+ if (!btn) return;
1954
+ e.preventDefault();
1955
+ e.stopPropagation();
1956
+ _spawnFromTodo(btn);
1957
+ });
1958
+ }
1959
+
1960
+ async function _spawnFromTodo(btn) {
1961
+ if (btn.disabled) return;
1962
+ btn.disabled = true;
1963
+ const original = btn.textContent;
1964
+ btn.textContent = I18n.t("chat.todo.spawning");
1965
+
1966
+ const todoText = btn.dataset.todoText || "";
1967
+ const context = btn.dataset.todoContext || "";
1968
+ const prompt =
1969
+ `Task:\n${todoText}\n\n` +
1970
+ `--- Context (the message this task came from) ---\n${context}`;
1971
+
1972
+ try {
1973
+ await Sessions.startWith(prompt, {
1974
+ name: todoText.slice(0, 60),
1975
+ display: `📋 ${todoText}`,
1976
+ });
1977
+ } catch (err) {
1978
+ btn.disabled = false;
1979
+ btn.textContent = original;
1980
+ alert(I18n.t("chat.todo.spawnFailed", { msg: err.message }));
1981
+ }
1982
+ }
1983
+
1928
1984
  // Install the click-delegation listener on #messages exactly once.
1929
1985
  // Handles copy clicks for all current AND future assistant bubbles
1930
1986
  // AND code block copy buttons.
@@ -2323,11 +2379,12 @@ const Sessions = (() => {
2323
2379
  // ── List management ───────────────────────────────────────────────────
2324
2380
 
2325
2381
  /** Populate list from initial session_list WS event (connect only). */
2326
- setAll(list, hasMore = false, cronCount = 0) {
2382
+ setAll(list, hasMore = false, cronCount = 0, latestCronUpdatedAt = null) {
2327
2383
  _sessions.length = 0;
2328
2384
  _sessions.push(...list);
2329
- _hasMore = !!hasMore;
2330
- _cronCount = cronCount;
2385
+ _hasMore = !!hasMore;
2386
+ _cronCount = cronCount;
2387
+ _latestCronUpdatedAt = latestCronUpdatedAt || null;
2331
2388
  },
2332
2389
 
2333
2390
  /** Insert a newly created session into the local list. */
@@ -2338,6 +2395,36 @@ const Sessions = (() => {
2338
2395
  }
2339
2396
  },
2340
2397
 
2398
+ // Create a session, queue a command to run once subscribed, and navigate to
2399
+ // it. The user bubble is rendered locally on the "subscribed" event (see
2400
+ // ws-dispatcher), so callers never touch history or WS timing.
2401
+ // Returns the created session.
2402
+ async startWith(command, { name, source = "setup", display = null } = {}) {
2403
+ if (!name) {
2404
+ const maxN = _sessions.reduce((max, s) => {
2405
+ const m = s.name && s.name.match(/^Session (\d+)$/);
2406
+ return m ? Math.max(max, parseInt(m[1], 10)) : max;
2407
+ }, 0);
2408
+ name = "Session " + (maxN + 1);
2409
+ }
2410
+
2411
+ const res = await fetch("/api/sessions", {
2412
+ method: "POST",
2413
+ headers: { "Content-Type": "application/json" },
2414
+ body: JSON.stringify({ name, source }),
2415
+ });
2416
+ const data = await res.json();
2417
+ if (!res.ok) throw new Error(data.error || "failed to create session");
2418
+ const session = data.session;
2419
+ if (!session) throw new Error("no session returned");
2420
+
2421
+ Sessions.add(session);
2422
+ Sessions.renderList();
2423
+ Sessions.setPendingMessage(session.id, command, display);
2424
+ Sessions.select(session.id);
2425
+ return session;
2426
+ },
2427
+
2341
2428
  /** Patch a single session's fields (from session_update event).
2342
2429
  * If the session is not in the list yet (e.g. just created by another tab),
2343
2430
  * prepend it so the sidebar shows it immediately. */
@@ -2348,6 +2435,14 @@ const Sessions = (() => {
2348
2435
  } else {
2349
2436
  _sessions.unshift({ id, ...fields });
2350
2437
  }
2438
+ // Keep _latestCronUpdatedAt current when a cron session is patched/added.
2439
+ const updated = _sessions.find(s => s.id === id);
2440
+ if (updated?.source === "cron") {
2441
+ const t = updated.updated_at || updated.created_at;
2442
+ if (t && (!_latestCronUpdatedAt || t > _latestCronUpdatedAt)) {
2443
+ _latestCronUpdatedAt = t;
2444
+ }
2445
+ }
2351
2446
  },
2352
2447
 
2353
2448
  /** Remove a session from the list (from session_deleted event). */
@@ -2375,21 +2470,17 @@ const Sessions = (() => {
2375
2470
  Sessions.renderList();
2376
2471
 
2377
2472
  try {
2378
- // Cursor: oldest activity time (updated_at, falling back to
2379
- // created_at) in the current list, EXCLUDING pinned sessions. The
2380
- // backend always returns ALL pinned sessions on the first page (they
2381
- // bypass pagination), so their time is irrelevant for the cursor.
2382
- // Including them here would cause the cursor to jump too far back and
2383
- // skip sessions between the oldest pinned one and the real
2384
- // last-loaded non-pinned row.
2473
+ // Cursor: oldest activity time of non-pinned, non-cron sessions.
2474
+ // Cron sessions are excluded from the normal list pagination and have
2475
+ // their own independent cursor (_cronBefore) in the sub-view.
2385
2476
  const oldest = _sessions.reduce((min, s) => {
2386
- if (s.pinned) return min; // ignore pinned
2477
+ if (s.pinned || s.source === "cron") return min;
2387
2478
  const t = s.updated_at || s.created_at;
2388
2479
  if (!t) return min;
2389
2480
  return (!min || t < min) ? t : min;
2390
2481
  }, null);
2391
2482
 
2392
- const params = new URLSearchParams({ limit: "20" });
2483
+ const params = new URLSearchParams({ limit: "20", exclude_type: "cron" });
2393
2484
  if (oldest) params.set("before", oldest);
2394
2485
  if (_filter.q) params.set("q", _filter.q);
2395
2486
  if (_filter.date) params.set("date", _filter.date);
@@ -2402,8 +2493,9 @@ const Sessions = (() => {
2402
2493
  (data.sessions || []).forEach(s => {
2403
2494
  if (!_sessions.find(x => x.id === s.id)) _sessions.push(s);
2404
2495
  });
2405
- _hasMore = !!data.has_more;
2406
- _cronCount = data.cron_count || 0;
2496
+ _hasMore = !!data.has_more;
2497
+ _cronCount = data.cron_count ?? _cronCount;
2498
+ if (data.latest_cron_updated_at) _latestCronUpdatedAt = data.latest_cron_updated_at;
2407
2499
  } catch (e) {
2408
2500
  console.error("loadMore error:", e);
2409
2501
  } finally {
@@ -2799,35 +2891,28 @@ const Sessions = (() => {
2799
2891
  }
2800
2892
  cronSessions.forEach(s => _renderSessionItem(list, s));
2801
2893
  } else if (_cronCount > 0) {
2802
- // Normal list view: the cron group entry is a *virtual* row that
2803
- // participates in the time-ordering instead of being pinned to the
2804
- // top. We walk the already-sorted `visible` list and drop the group
2805
- // entry at the position of the newest (first-encountered) cron
2806
- // session so it sits exactly where the latest folded cron task
2807
- // would sort by created_at. Pinning is intentionally ignored for the
2808
- // entry itself: a pinned cron session only takes effect *inside* the
2809
- // folded sub-view, not on the outer entry.
2810
- const cronHasRunning = cronSessions.some(s => s.status === "running");
2894
+ // Normal list view: virtual cron group entry positioned by _latestCronUpdatedAt.
2895
+ // Walk the sorted non-cron list and insert the entry at the first row
2896
+ // whose time is older than the newest cron session's updated_at.
2897
+ const cronHasRunning = _sessions.some(s => s.source === "cron" && s.status === "running");
2898
+ const nonCron = visible.filter(s => s.source !== "cron");
2811
2899
  let cronEntryRendered = false;
2812
- visible.forEach(s => {
2813
- if (s.source === "cron") {
2814
- // Render the group entry once, at the first (newest) cron slot;
2815
- // skip every individual cron session in the flat list.
2816
- if (!cronEntryRendered) {
2900
+
2901
+ nonCron.forEach(s => {
2902
+ if (!cronEntryRendered && _latestCronUpdatedAt) {
2903
+ const t = s.updated_at || s.created_at;
2904
+ if (!t || t < _latestCronUpdatedAt) {
2817
2905
  _renderCronGroupItem(list, _cronCount, cronHasRunning);
2818
2906
  cronEntryRendered = true;
2819
2907
  }
2820
- return;
2821
2908
  }
2822
2909
  _renderSessionItem(list, s);
2823
2910
  });
2824
- // NOTE: we intentionally do NOT append a fallback entry when no cron
2825
- // session is loaded yet. The group entry is a virtual row that must
2826
- // ride along with pagination: it only appears at the sort slot of the
2827
- // first loaded cron session. If the newest cron lives on a later page,
2828
- // the entry simply shows up once that page is loaded — rather than
2829
- // being forced onto the bottom of page 1 (which would mislead the
2830
- // sort position and miss the running state of an unpaged cron).
2911
+ // If no insertion point found (all normal sessions are newer, or list is empty),
2912
+ // append the entry at the bottom of the loaded rows.
2913
+ if (!cronEntryRendered) {
2914
+ _renderCronGroupItem(list, _cronCount, cronHasRunning);
2915
+ }
2831
2916
  } else {
2832
2917
  // Normal list view, no cron sessions
2833
2918
  visible.forEach(s => _renderSessionItem(list, s));
@@ -3508,6 +3593,7 @@ const Sessions = (() => {
3508
3593
  el.dataset.raw = html || "";
3509
3594
  el.innerHTML = _renderMarkdown(html);
3510
3595
  _appendCopyButton(el);
3596
+ _enhanceTaskItems(el, html || "");
3511
3597
  } else {
3512
3598
  el.innerHTML = html;
3513
3599
  }
@@ -3535,7 +3621,11 @@ const Sessions = (() => {
3535
3621
  });
3536
3622
  retryBtn.disabled = true;
3537
3623
  };
3624
+ // Move any .error-raw-detail to after the retry button
3625
+ const rawDetail = el.querySelector(".error-raw-detail");
3626
+ if (rawDetail) el.removeChild(rawDetail);
3538
3627
  el.appendChild(retryBtn);
3628
+ if (rawDetail) el.appendChild(rawDetail);
3539
3629
  }
3540
3630
  messages.appendChild(el);
3541
3631
  }
@@ -3928,8 +4018,8 @@ const Sessions = (() => {
3928
4018
  },
3929
4019
 
3930
4020
  /** Register a slash-command message to send after subscribe is confirmed. */
3931
- setPendingMessage(sessionId, content) {
3932
- _pendingMessage = { session_id: sessionId, content };
4021
+ setPendingMessage(sessionId, content, display = null) {
4022
+ _pendingMessage = { session_id: sessionId, content, display };
3933
4023
  },
3934
4024
 
3935
4025
  /** Consume and return the pending message (clears it). */
@@ -1508,6 +1508,7 @@ const Settings = (() => {
1508
1508
  _showBrandResult(true, I18n.t("settings.brand.activated", { name: data.product_name || "configured" }));
1509
1509
  // Apply brand name and logo across the entire UI immediately
1510
1510
  if (data.product_name) Brand.applyBrandName(data.product_name);
1511
+ if (data.theme_color) _applyAccentColor(data.theme_color, { persist: true });
1511
1512
  Brand.clearBrandCache();
1512
1513
  Brand.applyHeaderLogo();
1513
1514
  // Refresh brand status flags (user_licensed may have flipped from false
@@ -1683,7 +1684,7 @@ const Settings = (() => {
1683
1684
  // The state object per kind:
1684
1685
  // { source, configured, model, base_url, api_key_masked, provider, available }
1685
1686
 
1686
- const MEDIA_KINDS = ["image", "video", "audio", "ocr"];
1687
+ const MEDIA_KINDS = ["image", "video", "audio", "stt", "video_understanding", "ocr"];
1687
1688
  let _mediaState = null;
1688
1689
  let _mediaDefaults = null;
1689
1690
  const _mediaCustomDraft = {};
@@ -2173,6 +2174,8 @@ const Settings = (() => {
2173
2174
  Brand.applyBrandName("OpenClacky");
2174
2175
  Brand.clearBrandCache();
2175
2176
  Brand.applyHeaderLogo();
2177
+ const userAccent = (() => { try { return localStorage.getItem("clacky-accent-color"); } catch (_) { return null; } })();
2178
+ _applyAccentColor(userAccent || ACCENT_DEFAULT, { persist: false });
2176
2179
  // Reset Skills panel state (hide Brand Skills tab, switch to My Skills)
2177
2180
  if (typeof Skills !== "undefined" && Skills.resetAfterUnbind) {
2178
2181
  Skills.resetAfterUnbind();
@@ -2203,6 +2206,7 @@ const Settings = (() => {
2203
2206
  _initLangBtns();
2204
2207
  _initFontBtns();
2205
2208
  _initCurrencyBtns();
2209
+ _initAccentColorBtns();
2206
2210
 
2207
2211
  // Re-render model cards when language changes (dynamic HTML, not data-i18n)
2208
2212
  document.addEventListener("langchange", () => {
@@ -2364,6 +2368,34 @@ const Settings = (() => {
2364
2368
  });
2365
2369
  }
2366
2370
 
2371
+ // ── Accent Color ──────────────────────────────────────────────────────
2372
+ const ACCENT_STORAGE_KEY = "clacky-accent-color";
2373
+ const ACCENT_DEFAULT = "#4f46e5";
2374
+
2375
+ function _applyAccentColor(color, { persist = true } = {}) {
2376
+ const root = document.documentElement;
2377
+ root.style.setProperty("--color-accent-primary", color);
2378
+ root.style.setProperty("--color-accent-hover", color);
2379
+ root.style.setProperty("--color-button-primary", color);
2380
+ root.style.setProperty("--color-button-primary-hover", `color-mix(in srgb, ${color} 82%, #000)`);
2381
+ if (persist) {
2382
+ try { localStorage.setItem(ACCENT_STORAGE_KEY, color); } catch (_) {}
2383
+ }
2384
+ document.querySelectorAll("#accent-color-section .settings-accent-swatch").forEach(btn => {
2385
+ btn.classList.toggle("active", btn.dataset.color === color);
2386
+ });
2387
+ }
2388
+
2389
+ function _initAccentColorBtns() {
2390
+ let saved = null;
2391
+ try { saved = localStorage.getItem(ACCENT_STORAGE_KEY); } catch (_) {}
2392
+ _applyAccentColor(saved || ACCENT_DEFAULT, { persist: !!saved });
2393
+
2394
+ document.querySelectorAll("#accent-color-section .settings-accent-swatch").forEach(btn => {
2395
+ btn.addEventListener("click", () => _applyAccentColor(btn.dataset.color));
2396
+ });
2397
+ }
2398
+
2367
2399
  // ── QR Code Lightbox ───────────────────────────────────────────────────
2368
2400
  // Sets up click-to-enlarge behaviour for the support QR code.
2369
2401
  // Safe to call multiple times — idempotent via a data attribute guard.
@@ -2404,5 +2436,5 @@ const Settings = (() => {
2404
2436
  });
2405
2437
  }
2406
2438
 
2407
- return { open, init, loadBrand: _loadBrand };
2439
+ return { open, init, loadBrand: _loadBrand, applyAccentColor: _applyAccentColor };
2408
2440
  })();
@@ -180,7 +180,7 @@ WS.onEvent(ev => {
180
180
 
181
181
  // ── Session list ───────────────────────────────────────────────────
182
182
  case "session_list": {
183
- Sessions.setAll(ev.sessions || [], !!ev.has_more, ev.cron_count || 0);
183
+ Sessions.setAll(ev.sessions || [], !!ev.has_more, ev.cron_count || 0, ev.latest_cron_updated_at || null);
184
184
  Sessions.renderList();
185
185
 
186
186
  // Restore URL hash once on initial connect; ignore subsequent session_list events.
@@ -216,7 +216,7 @@ WS.onEvent(ev => {
216
216
  // send it now — after restoreFromHash has settled — so appendMsg won't be wiped.
217
217
  const pendingMsg = Sessions.takePendingMessage();
218
218
  if (pendingMsg && pendingMsg.session_id === ev.session_id) {
219
- Sessions.appendMsg("user", escapeHtml(pendingMsg.content), { time: new Date() });
219
+ Sessions.appendMsg("user", escapeHtml(pendingMsg.display || pendingMsg.content), { time: new Date() });
220
220
  WS.send({ type: "message", session_id: pendingMsg.session_id, content: pendingMsg.content, lang: I18n.lang() });
221
221
  }
222
222
  break;
@@ -310,6 +310,9 @@ WS.onEvent(ev => {
310
310
  if (ev.session_id !== Sessions.activeId) break;
311
311
  Sessions.clearProgress();
312
312
  Sessions.appendMsg("assistant", ev.content);
313
+ if (window.Clacky && Clacky.ext) {
314
+ Clacky.ext.emit("session:assistant-message", { sessionId: ev.session_id, content: ev.content });
315
+ }
313
316
  break;
314
317
 
315
318
  case "tool_call":
@@ -442,10 +445,15 @@ function renderErrorEvent(ev) {
442
445
  const action = ev.top_up_url
443
446
  ? ` <a href="${escapeHtml(ev.top_up_url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(I18n.t("error.insufficient_credit.action"))} →</a>`
444
447
  : "";
445
- Sessions.appendMsg("error", `<span>${body}${action}</span>`);
448
+ Sessions.appendMsg("error", `<span>${body}${action}</span>${_buildRawDetail(ev.raw_message)}`);
446
449
  return;
447
450
  }
448
- Sessions.appendMsg("error", escapeHtml(ev.message));
451
+ Sessions.appendMsg("error", escapeHtml(ev.message) + _buildRawDetail(ev.raw_message));
452
+ }
453
+
454
+ function _buildRawDetail(raw) {
455
+ if (!raw) return "";
456
+ return `<details class="error-raw-detail"><summary>${escapeHtml(I18n.t("error.show_detail"))}</summary><pre>${escapeHtml(raw)}</pre></details>`;
449
457
  }
450
458
 
451
459
  window.renderErrorEvent = renderErrorEvent;
data/lib/clacky.rb CHANGED
@@ -155,20 +155,27 @@ require_relative "clacky/server/api_extension_dispatcher"
155
155
 
156
156
  module Clacky
157
157
  class AgentInterrupted < Exception; end # Inherit from Exception to bypass rescue StandardError
158
- class AgentError < StandardError; end
158
+ class AgentError < StandardError
159
+ attr_reader :raw_message
160
+
161
+ def initialize(message, raw_message: nil)
162
+ super(message)
163
+ @raw_message = raw_message
164
+ end
165
+ end
159
166
  class BadRequestError < AgentError
160
167
  attr_reader :display_message
161
168
 
162
- def initialize(message, display_message: nil)
163
- super(message)
169
+ def initialize(message, display_message: nil, raw_message: nil)
170
+ super(message, raw_message: raw_message)
164
171
  @display_message = display_message
165
172
  end
166
173
  end
167
174
  class InsufficientCreditError < AgentError
168
175
  attr_reader :error_code, :provider_id
169
176
 
170
- def initialize(message, error_code: nil, provider_id: nil)
171
- super(message)
177
+ def initialize(message, error_code: nil, provider_id: nil, raw_message: nil)
178
+ super(message, raw_message: raw_message)
172
179
  @error_code = error_code
173
180
  @provider_id = provider_id
174
181
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openclacky
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.4
4
+ version: 1.3.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy
@@ -338,6 +338,7 @@ files:
338
338
  - lib/clacky/aes_gcm.rb
339
339
  - lib/clacky/agent.rb
340
340
  - lib/clacky/agent/cost_tracker.rb
341
+ - lib/clacky/agent/fake_tool_call_detector.rb
341
342
  - lib/clacky/agent/hook_manager.rb
342
343
  - lib/clacky/agent/llm_caller.rb
343
344
  - lib/clacky/agent/memory_updater.rb
@@ -375,6 +376,10 @@ files:
375
376
  - lib/clacky/default_agents/coding/webui/.gitkeep
376
377
  - lib/clacky/default_agents/general/profile.yml
377
378
  - lib/clacky/default_agents/general/system_prompt.md
379
+ - lib/clacky/default_extensions/meeting/handler.rb
380
+ - lib/clacky/default_extensions/meeting/meeting.js
381
+ - lib/clacky/default_extensions/meeting/meta.yml
382
+ - lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md
378
383
  - lib/clacky/default_parsers/doc_parser.rb
379
384
  - lib/clacky/default_parsers/docx_parser.rb
380
385
  - lib/clacky/default_parsers/pdf_parser.rb
@@ -399,6 +404,7 @@ files:
399
404
  - lib/clacky/default_skills/extend-openclacky/SKILL.md
400
405
  - lib/clacky/default_skills/mcp-manager/SKILL.md
401
406
  - lib/clacky/default_skills/media-gen/SKILL.md
407
+ - lib/clacky/default_skills/media-gen/scripts/video_seq.sh
402
408
  - lib/clacky/default_skills/new/SKILL.md
403
409
  - lib/clacky/default_skills/new/scripts/create_rails_project.sh
404
410
  - lib/clacky/default_skills/onboard/SKILL.md
@@ -449,6 +455,7 @@ files:
449
455
  - lib/clacky/message_format/bedrock.rb
450
456
  - lib/clacky/message_format/open_ai.rb
451
457
  - lib/clacky/message_history.rb
458
+ - lib/clacky/null_ui_controller.rb
452
459
  - lib/clacky/openai_stream_aggregator.rb
453
460
  - lib/clacky/patch_loader.rb
454
461
  - lib/clacky/plain_ui_controller.rb