openclacky 1.3.6 → 1.3.7

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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/lib/clacky/agent.rb +8 -1
  4. data/lib/clacky/agent_profile.rb +121 -53
  5. data/lib/clacky/brand_config.rb +309 -6
  6. data/lib/clacky/cli.rb +4 -57
  7. data/lib/clacky/default_extensions/coding/ext.yml +18 -0
  8. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +95 -0
  9. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +254 -0
  10. data/lib/clacky/default_extensions/ext-studio/ext.yml +32 -0
  11. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +1269 -0
  12. data/lib/clacky/default_extensions/ext-studio/skills/ext-debug/SKILL.md +71 -0
  13. data/lib/clacky/default_extensions/ext-studio/skills/ext-publish/SKILL.md +74 -0
  14. data/lib/clacky/default_extensions/ext-studio/skills/ext-scaffold/SKILL.md +65 -0
  15. data/lib/clacky/default_extensions/general/ext.yml +18 -0
  16. data/lib/clacky/default_extensions/git/ext.yml +12 -0
  17. data/lib/clacky/{default_agents/_panels/git/panel.js → default_extensions/git/panels/git/view.js} +1 -2
  18. data/lib/clacky/default_extensions/meeting/{handler.rb → api/handler.rb} +78 -26
  19. data/lib/clacky/default_extensions/meeting/ext.yml +15 -0
  20. data/lib/clacky/default_extensions/meeting/{meeting.js → panels/meeting/view.js} +266 -89
  21. data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +2 -1
  22. data/lib/clacky/default_extensions/time_machine/ext.yml +12 -0
  23. data/lib/clacky/{default_agents/_panels/time_machine/panel.js → default_extensions/time_machine/panels/time_machine/view.js} +58 -47
  24. data/lib/clacky/default_skills/channel-manager/discord_setup.rb +1 -1
  25. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +83 -18
  26. data/lib/clacky/{api_extension.rb → extension/api_extension.rb} +27 -11
  27. data/lib/clacky/extension/api_loader.rb +136 -0
  28. data/lib/clacky/extension/cli_commands.rb +226 -0
  29. data/lib/clacky/{server/api_extension_dispatcher.rb → extension/dispatcher.rb} +22 -9
  30. data/lib/clacky/extension/hook_loader.rb +77 -0
  31. data/lib/clacky/extension/loader.rb +483 -0
  32. data/lib/clacky/extension/packager.rb +226 -0
  33. data/lib/clacky/{patch_loader.rb → extension/patch_loader.rb} +45 -0
  34. data/lib/clacky/extension/scaffold/template_renderer.rb +64 -0
  35. data/lib/clacky/extension/scaffold/templates/full/README.md.erb +48 -0
  36. data/lib/clacky/extension/scaffold/templates/full/agents/designer.md +5 -0
  37. data/lib/clacky/extension/scaffold/templates/full/api/handler.rb.erb +16 -0
  38. data/lib/clacky/extension/scaffold/templates/full/channels/noop.rb.erb +40 -0
  39. data/lib/clacky/extension/scaffold/templates/full/ext.yml.erb +41 -0
  40. data/lib/clacky/extension/scaffold/templates/full/hooks/audit.rb +9 -0
  41. data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +194 -0
  42. data/lib/clacky/extension/scaffold/templates/full/patches/audit.rb +15 -0
  43. data/lib/clacky/extension/scaffold/templates/full/skills/__slug__-skill/SKILL.md.erb +8 -0
  44. data/lib/clacky/extension/scaffold/templates/hello/api/handler.rb.erb +9 -0
  45. data/lib/clacky/extension/scaffold/templates/hello/ext.yml.erb +12 -0
  46. data/lib/clacky/extension/scaffold/templates/hello/panels/hello/view.js.erb +31 -0
  47. data/lib/clacky/extension/scaffold.rb +55 -0
  48. data/lib/clacky/extension/verifier.rb +196 -0
  49. data/lib/clacky/identity.rb +70 -0
  50. data/lib/clacky/locales/en.rb +19 -1
  51. data/lib/clacky/locales/zh.rb +19 -1
  52. data/lib/clacky/platform_http_client.rb +18 -15
  53. data/lib/clacky/server/channel/extension_adapter_loader.rb +32 -0
  54. data/lib/clacky/server/channel.rb +6 -0
  55. data/lib/clacky/server/http_server.rb +313 -144
  56. data/lib/clacky/server/session_registry.rb +9 -2
  57. data/lib/clacky/skill_loader.rb +28 -1
  58. data/lib/clacky/ui2/ui_controller.rb +9 -6
  59. data/lib/clacky/version.rb +1 -1
  60. data/lib/clacky/web/app.css +748 -573
  61. data/lib/clacky/web/app.js +63 -15
  62. data/lib/clacky/web/auth.js +2 -0
  63. data/lib/clacky/web/components/notify.js +2 -0
  64. data/lib/clacky/web/components/sidebar.js +1 -5
  65. data/lib/clacky/web/core/ext.js +316 -79
  66. data/lib/clacky/web/features/backup/store.js +1 -0
  67. data/lib/clacky/web/features/backup/view.js +0 -1
  68. data/lib/clacky/web/features/brand/view.js +13 -5
  69. data/lib/clacky/web/features/extensions/store.js +184 -0
  70. data/lib/clacky/web/features/extensions/view.js +389 -0
  71. data/lib/clacky/web/features/new-session/store.js +186 -0
  72. data/lib/clacky/web/features/new-session/view.js +319 -0
  73. data/lib/clacky/web/features/share/view.js +63 -14
  74. data/lib/clacky/web/features/skills/store.js +3 -1
  75. data/lib/clacky/web/features/skills/view.js +1 -1
  76. data/lib/clacky/web/features/tasks/view.js +12 -0
  77. data/lib/clacky/web/features/trash/store.js +24 -6
  78. data/lib/clacky/web/features/trash/view.js +46 -9
  79. data/lib/clacky/web/features/workspace/store.js +2 -0
  80. data/lib/clacky/web/features/workspace/view.js +0 -1
  81. data/lib/clacky/web/i18n.js +118 -106
  82. data/lib/clacky/web/index.html +138 -188
  83. data/lib/clacky/web/sessions.js +64 -269
  84. data/lib/clacky/web/settings.js +1 -1
  85. data/lib/clacky/web/skills.js +2 -0
  86. data/lib/clacky/web/theme.js +2 -0
  87. data/lib/clacky/web/ws-dispatcher.js +1 -0
  88. data/lib/clacky/web/ws.js +2 -0
  89. data/lib/clacky.rb +21 -10
  90. metadata +55 -24
  91. data/benchmark/runner.rb +0 -441
  92. data/lib/clacky/api_extension_loader.rb +0 -168
  93. data/lib/clacky/default_agents/SOUL.md +0 -3
  94. data/lib/clacky/default_agents/USER.md +0 -1
  95. data/lib/clacky/default_agents/coding/profile.yml +0 -5
  96. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  97. data/lib/clacky/default_agents/general/profile.yml +0 -2
  98. data/lib/clacky/default_extensions/meeting/meta.yml +0 -3
  99. data/lib/clacky/web/features/creator/store.js +0 -81
  100. data/lib/clacky/web/features/creator/view.js +0 -380
  101. /data/lib/clacky/{default_agents → default_extensions/coding/agents}/coding/system_prompt.md +0 -0
  102. /data/lib/clacky/{default_agents → default_extensions/general/agents}/general/system_prompt.md +0 -0
  103. /data/lib/clacky/{default_agents/base_prompt.md → prompts/base.md} +0 -0
@@ -4,23 +4,51 @@
4
4
 
5
5
  (function () {
6
6
  const ANNOTATE_INTERVAL_MS = 120000;
7
- const WAKE_PATTERNS = [/@clacky/i, /小[克客可刻课氪]/, /clacky/i, /克拉奇/];
7
+ // Wake phrases must be "greeting + name", never a bare product word.
8
+ // Bare "clacky" / "小克" are too easily tripped by casual mentions of the
9
+ // brand or STT hallucinations pulled from the meeting vocabulary.
10
+ // Reduplicated Chinese calls ("小克小克", "克拉奇克拉奇") are natural
11
+ // attention-getters and count as wake too.
12
+ const WAKE_PATTERNS = [
13
+ /@clacky\b/i,
14
+ /\b(hey|hi|hello|ok|okay|yo)[\s,,]+clacky\b/i,
15
+ /(你好|嘿|喂|哎|请问)[\s,,]*小[克客可刻课氪]/,
16
+ /(你好|嘿|喂|哎|请问)[\s,,]*克拉奇/,
17
+ /小[克客可刻课氪][\s,,]*小[克客可刻课氪]/,
18
+ /克拉奇[\s,,]*克拉奇/,
19
+ ];
20
+ const WAKE_STRIP = [
21
+ /@clacky/gi,
22
+ /\b(hey|hi|hello|ok|okay|yo)[\s,,]+clacky\b/gi,
23
+ /(你好|嘿|喂|哎|请问)[\s,,]*小[克客可刻课氪]/g,
24
+ /(你好|嘿|喂|哎|请问)[\s,,]*克拉奇/g,
25
+ /小[克客可刻课氪][\s,,]*小[克客可刻课氪]/g,
26
+ /克拉奇[\s,,]*克拉奇/g,
27
+ ];
8
28
 
9
29
  // Self-contained i18n: extensions can't register keys into the host I18n
10
30
  // dictionary, so we keep our own table and pick the language via I18n.lang().
11
31
  const MEETING_I18N = {
12
32
  en: {
13
- "tab.label": "Meeting",
14
- "btn.start": "Start Meeting",
15
- "btn.stop": "End Meeting",
33
+ "tab.label": "Meeting Assistant",
34
+ "btn.start": "Start Recording",
35
+ "btn.stop": "Summarize Notes",
16
36
  "btn.resume": "Resume Recording",
17
- "hint.wake": 'Say "@clacky" or "小克" to ask a question during the meeting.',
37
+ "btn.mute": "Mute",
38
+ "btn.unmute": "Unmute",
39
+ "btn.mute.hint": "Temporarily stop recording (mic off)",
40
+ "btn.silence": "Stop Voice",
41
+ "btn.silence.hint": "Stop the current voice playback",
42
+ "btn.interrupt": "Interrupt",
43
+ "btn.interrupt.hint": "Cancel the current answer",
44
+ "hint.wake": 'Say "Hey Clacky" or "@clacky" to ask a question during the meeting.',
18
45
  "hint.resume": "A meeting is still in progress. Resume recording to continue (microphone access required again).",
19
46
  "status.recording": "Recording",
20
47
  "status.transcribing": "Transcribing…",
21
48
  "status.listening": "Listening… ({{n}}s)",
22
49
  "status.thinking": "Thinking…",
23
50
  "status.speaking": "Playing…",
51
+ "status.muted": "Muted",
24
52
  "vocab.label": "Meeting vocabulary (proper nouns)",
25
53
  "vocab.placeholder": "Type a term, press Enter",
26
54
  "vocab.save": "Save vocabulary",
@@ -32,19 +60,28 @@
32
60
  "stt.failed": "⚠ Transcription failed: {{msg}}",
33
61
  "alert.noSession": "No active session",
34
62
  "alert.startFailed": "Failed to start meeting: {{msg}}",
63
+ "end.displayMessage": "🛑 Meeting ended — generating meeting minutes…",
35
64
  },
36
65
  zh: {
37
- "tab.label": "会议",
38
- "btn.start": "开始会议",
39
- "btn.stop": "结束会议",
66
+ "tab.label": "会议助理",
67
+ "btn.start": "开始记录",
68
+ "btn.stop": "总结纪要",
40
69
  "btn.resume": "继续录音",
41
- "hint.wake": "会议中说「@clacky」或「小克」即可向我提问。",
70
+ "btn.mute": "静音",
71
+ "btn.unmute": "解除静音",
72
+ "btn.mute.hint": "临时暂停录音(关闭麦克风)",
73
+ "btn.silence": "停止播报",
74
+ "btn.silence.hint": "停止当前语音播放",
75
+ "btn.interrupt": "打断",
76
+ "btn.interrupt.hint": "取消当前回答",
77
+ "hint.wake": "会议中说「你好小克」「小克小克」或「@clacky」即可向我提问。",
42
78
  "hint.resume": "有一场会议仍在进行中。点击「继续录音」继续(需要重新授权麦克风)。",
43
79
  "status.recording": "录音中",
44
80
  "status.transcribing": "识别中…",
45
81
  "status.listening": "正在听你说…({{n}}s)",
46
82
  "status.thinking": "思考中…",
47
83
  "status.speaking": "播放中…",
84
+ "status.muted": "已静音",
48
85
  "vocab.label": "会议词汇(专有名词)",
49
86
  "vocab.placeholder": "输入词汇后回车添加",
50
87
  "vocab.save": "保存词汇",
@@ -56,6 +93,7 @@
56
93
  "stt.failed": "⚠ 识别失败:{{msg}}",
57
94
  "alert.noSession": "没有进行中的会话",
58
95
  "alert.startFailed": "开启会议失败:{{msg}}",
96
+ "end.displayMessage": "🛑 会议结束 — 正在生成会议纪要…",
59
97
  },
60
98
  };
61
99
 
@@ -67,53 +105,45 @@
67
105
  return str;
68
106
  }
69
107
 
108
+ // ── Layer 1: module-level (page lifetime) ────────────────────────────
109
+ // A registry of all live meeting sessions on this page so the module-level
110
+ // `session:assistant-message` subscription and the `langchange` listener
111
+ // can dispatch to whichever session the event is about, without any of
112
+ // them holding a reference to the module `state` (there is no module state).
113
+ const _sessionsById = new Map();
114
+
115
+ // When the agent produces a reply during meeting mode we dispatch it to the
116
+ // matching per-session runtime — every session's meeting has its own
117
+ // "expecting speech" flag and its own audio player.
118
+ Clacky.ext.subscribe("session:assistant-message", function (payload) {
119
+ const sess = payload && payload.sessionId && _sessionsById.get(payload.sessionId);
120
+ if (sess) sess.onAssistantMessage(payload);
121
+ });
122
+
123
+ // Re-render every live meeting on language switch so labels follow the
124
+ // host language.
125
+ document.addEventListener("langchange", function () {
126
+ _sessionsById.forEach((sess) => sess.rerender());
127
+ });
128
+
129
+ // ── Layer 2/3: per-session runtime ──────────────────────────────────
130
+ // Everything below is instantiated fresh by `create(ctx)` each time a
131
+ // session shows the meeting tab for the first time; state stays alive
132
+ // until the session leaves the sidebar (or the page unloads).
133
+ function createMeetingSession(ctx) {
134
+
70
135
  // VAD (voice activity detection) — slice on natural speech pauses instead
71
136
  // of a fixed timer, so a sentence is never cut mid-word.
72
- const VAD_SILENCE_THRESHOLD = 0.012; // RMS below this counts as silence
73
- const VAD_SILENCE_HOLD_MS = 500; // pause this long => end of utterance
74
- const VAD_MIN_SPEECH_MS = 400; // ignore utterances shorter than this
75
- const VAD_MAX_SEGMENT_MS = 12000; // force-cut a very long monologue
76
-
77
- const HALLUCINATION_PHRASES = new Set([
78
- "no", "no.", "yes", "yes.", "ok", "okay", "thank you", "thank you.",
79
- "thanks", "thanks for watching", "thanks for watching!", "you", "bye",
80
- "uh", "um", "hmm", "mm", "mm-hmm", ".", "..", "...",
81
- "yeah", "yeah.", "yep", "but", "and", "so", "oh", "ah", "ahh", "huh",
82
- "an", "a", "i", "the", "more", "well", "right", "hi", "hey", "wow",
83
- "嗯", "啊", "哦", "呃", "谢谢", "谢谢观看", "谢谢大家", "好", "好的", "对",
84
- ]);
85
-
86
- // STT models hallucinate single isolated words during silence/noise. Real
87
- // speech segments (gated by VAD_MIN_SPEECH_MS) almost never decode to a lone
88
- // 1-2 word fragment, so treat those as noise.
89
- function isStructuralNoise(t) {
90
- const stripped = t.replace(/[\(\[][^\)\]]*[\)\]]/g, "").trim();
91
- if (stripped === "") return true;
92
- // Pure punctuation / digits / timestamp-like fragments ("00:00", "1.", ":").
93
- if (/^[\s\d.:,;!?。,、!?\-—]+$/.test(stripped)) return true;
94
- const hasCJK = /[\u4e00-\u9fff\u3040-\u30ff]/.test(stripped);
95
- const core = stripped.replace(/[\s。,、!?!?.,;:]+/g, "");
96
- if (hasCJK) {
97
- // A single isolated CJK character is almost always a filler/hallucination.
98
- if (core.length <= 1) return true;
99
- } else {
100
- const words = stripped.split(/\s+/).filter(Boolean);
101
- // A single short Latin word (≤3 chars), e.g. "An", "Oh", "Zero" miswrites.
102
- if (words.length === 1 && words[0].replace(/[^A-Za-z]/g, "").length <= 3) return true;
103
- }
104
- return false;
105
- }
106
-
107
- function isHallucination(text) {
108
- const t = text.trim();
109
- if (isStructuralNoise(t)) return true;
110
- const normalized = t.toLowerCase().replace(/[\s。,,!!??]+/g, " ").trim();
111
- return normalized === "" || HALLUCINATION_PHRASES.has(normalized);
112
- }
137
+ const VAD_SILENCE_THRESHOLD = 0.02; // RMS below this counts as silence
138
+ const VAD_SILENCE_HOLD_MS = 700; // pause this long => end of utterance
139
+ const VAD_MIN_SPEECH_MS = 500; // ignore utterances shorter than this
140
+ const VAD_MAX_SEGMENT_MS = 12000; // force-cut a very long monologue
141
+ const VAD_MIN_ACTIVE_RATIO = 0.25; // fraction of samples above threshold
142
+ // required to accept a segment
113
143
 
114
144
  let state = {
115
145
  active: false,
116
- sessionId: null,
146
+ sessionId: ctx.sessionId,
117
147
  meetingId: null,
118
148
  mediaRecorder: null,
119
149
  annotateTimer: null,
@@ -132,6 +162,8 @@
132
162
  transcribing: 0, // count of in-flight STT requests
133
163
  container: null,
134
164
  resumable: false,
165
+ micMuted: false,
166
+ ttsAudio: null,
135
167
  };
136
168
 
137
169
  function apiUrl(path) {
@@ -226,6 +258,8 @@
226
258
  let hadSpeech = false;
227
259
  let segmentStart = 0;
228
260
  let silenceStart = 0;
261
+ let activeSamples = 0;
262
+ let totalSamples = 0;
229
263
 
230
264
  function newRecorder() {
231
265
  const r = new MediaRecorder(stream, mime ? { mimeType: mime } : {});
@@ -233,13 +267,16 @@
233
267
  hadSpeech = false;
234
268
  segmentStart = performance.now();
235
269
  silenceStart = 0;
270
+ activeSamples = 0;
271
+ totalSamples = 0;
236
272
  r.ondataavailable = (e) => { if (e.data && e.data.size > 0) chunks.push(e.data); };
237
273
  r.onstop = () => {
238
274
  const captured = chunks;
239
275
  const speech = hadSpeech;
240
276
  const dur = performance.now() - segmentStart;
277
+ const ratio = totalSamples > 0 ? activeSamples / totalSamples : 0;
241
278
  if (state.active) newRecorder();
242
- if (speech && dur >= VAD_MIN_SPEECH_MS && captured.length) {
279
+ if (speech && dur >= VAD_MIN_SPEECH_MS && ratio >= VAD_MIN_ACTIVE_RATIO && captured.length) {
243
280
  sendAudioChunk(new Blob(captured, mime ? { type: mime } : {}));
244
281
  }
245
282
  };
@@ -260,7 +297,9 @@
260
297
  const now = performance.now();
261
298
  const level = rms();
262
299
 
300
+ totalSamples++;
263
301
  if (level >= VAD_SILENCE_THRESHOLD) {
302
+ activeSamples++;
264
303
  hadSpeech = true;
265
304
  silenceStart = 0;
266
305
  } else if (hadSpeech) {
@@ -296,15 +335,11 @@
296
335
  clearInterval(state.annotateTimer);
297
336
 
298
337
  try {
299
- const result = await postJson("/end", {
338
+ await postJson("/end", {
300
339
  session_id: state.sessionId,
301
340
  meeting_id: state.meetingId,
341
+ display_message: t("end.displayMessage"),
302
342
  });
303
- if (result && result.ok === false) {
304
- console.error("[meeting] end summarization failed:", result.error);
305
- } else if (result && result.skipped) {
306
- console.warn("[meeting] end: transcript was empty, no summary generated");
307
- }
308
343
  } catch (err) {
309
344
  console.error("[meeting] end failed:", err);
310
345
  }
@@ -329,6 +364,7 @@
329
364
  const MIN_AUDIO_BYTES = 2000; // drop near-empty blobs before hitting STT
330
365
 
331
366
  async function sendAudioChunk(blob) {
367
+ if (state.micMuted) return;
332
368
  if (!blob || blob.size < MIN_AUDIO_BYTES) return; // too little audio to be speech
333
369
  const buf = await blob.arrayBuffer();
334
370
  const bytes = new Uint8Array(buf);
@@ -347,7 +383,7 @@
347
383
  vocabulary: state.vocabulary,
348
384
  });
349
385
 
350
- if (result.text && result.text.trim() && !isHallucination(result.text)) {
386
+ if (result.text && result.text.trim()) {
351
387
  const entry = { ts: Date.now(), text: result.text.trim() };
352
388
  state.transcripts.push(entry);
353
389
  checkWakeWord(entry.text);
@@ -368,19 +404,38 @@
368
404
  }
369
405
 
370
406
  const CONVERSATION_WINDOW_MS = 30000; // after a wake word, keep listening this long without re-triggering
407
+ const MIN_QUESTION_CHARS = 4;
408
+ const WAKE_ONLY_TAIL = /^\s*[??!!。.,,]?\s*$/;
409
+
410
+ function stripWake(text) {
411
+ let out = text;
412
+ for (const re of WAKE_STRIP) out = out.replace(re, " ");
413
+ return out.replace(/[\s\p{P}]+/gu, " ").trim();
414
+ }
371
415
 
372
416
  function checkWakeWord(text) {
373
417
  const triggered = WAKE_PATTERNS.some((p) => p.test(text));
374
418
  const inConversation = state.conversationUntil && Date.now() < state.conversationUntil;
375
419
 
376
420
  if (!triggered && !inConversation) return;
377
- if (state.asking) return; // a question is still being answered; don't pile on
378
421
 
379
- const question = text.replace(/@clacky/gi, "").replace(/clacky/gi, "").replace(/小[克客可刻课氪]/g, "").replace(/克拉奇/g, "").trim();
380
- if (!question) return;
422
+ const question = stripWake(text);
423
+ // Wake phrase alone (no real question in the same segment): arm listening
424
+ // but don't dispatch — the next segment will be treated as the question.
425
+ if (triggered && WAKE_ONLY_TAIL.test(question.replace(/[\s\p{P}]/gu, ""))) {
426
+ state.conversationUntil = Date.now() + CONVERSATION_WINDOW_MS;
427
+ state.expectingSpeech = true;
428
+ return;
429
+ }
430
+ const meaningful = question.replace(/[^\p{L}\p{N}]/gu, "");
431
+ if (meaningful.length < MIN_QUESTION_CHARS) return;
381
432
 
382
433
  state.conversationUntil = Date.now() + CONVERSATION_WINDOW_MS;
383
434
  state.expectingSpeech = true;
435
+ // A new question supersedes any in-flight answer: stop the current TTS
436
+ // playback right away; the backend `/ask` handler will interrupt the
437
+ // running agent turn before submitting the new one.
438
+ stopTts();
384
439
  state.asking = true;
385
440
  postJson("/ask", {
386
441
  session_id: state.sessionId,
@@ -410,11 +465,14 @@
410
465
  // speaking > thinking > conversation(countdown) > transcribing > listening.
411
466
  function updateStatus() {
412
467
  const el = document.getElementById("meeting-status");
413
- if (!el) return;
468
+ if (!el) { updateControls(); return; }
414
469
  const now = Date.now();
415
470
  const inConversation = state.conversationUntil && now < state.conversationUntil;
416
471
  let text, cls;
417
- if (state.phase === "speaking") {
472
+ if (state.micMuted) {
473
+ text = t("status.muted");
474
+ cls = "muted";
475
+ } else if (state.phase === "speaking") {
418
476
  text = t("status.speaking");
419
477
  cls = "speaking";
420
478
  } else if (state.asking) {
@@ -433,6 +491,7 @@
433
491
  }
434
492
  el.textContent = " " + text;
435
493
  el.className = "meeting-status meeting-status-" + cls;
494
+ updateControls();
436
495
  }
437
496
 
438
497
  function startStatusTicker() {
@@ -640,9 +699,41 @@
640
699
  stopBtn.className = "meeting-btn meeting-btn-stop";
641
700
  stopBtn.textContent = t("btn.stop");
642
701
  stopBtn.onclick = () => stopMeeting(container);
702
+
703
+ const muteBtn = document.createElement("button");
704
+ muteBtn.id = "meeting-mute-btn";
705
+ muteBtn.className = "meeting-btn meeting-btn-mute";
706
+ muteBtn.textContent = state.micMuted ? t("btn.unmute") : t("btn.mute");
707
+ muteBtn.title = t("btn.mute.hint");
708
+ muteBtn.onclick = toggleMic;
709
+
710
+ const stopTtsBtn = document.createElement("button");
711
+ stopTtsBtn.id = "meeting-stop-tts";
712
+ stopTtsBtn.className = "meeting-btn meeting-btn-silence";
713
+ stopTtsBtn.textContent = t("btn.silence");
714
+ stopTtsBtn.title = t("btn.silence.hint");
715
+ stopTtsBtn.style.display = state.phase === "speaking" ? "" : "none";
716
+ stopTtsBtn.onclick = stopTts;
717
+
718
+ const interruptBtn = document.createElement("button");
719
+ interruptBtn.id = "meeting-interrupt-btn";
720
+ interruptBtn.className = "meeting-btn meeting-btn-interrupt";
721
+ interruptBtn.textContent = t("btn.interrupt");
722
+ interruptBtn.title = t("btn.interrupt.hint");
723
+ interruptBtn.style.display = state.asking ? "" : "none";
724
+ interruptBtn.onclick = interruptAsk;
725
+
726
+ header.appendChild(muteBtn);
727
+ header.appendChild(stopTtsBtn);
728
+ header.appendChild(interruptBtn);
643
729
  header.appendChild(stopBtn);
644
730
  wrapper.appendChild(header);
645
731
 
732
+ const activeHint = document.createElement("p");
733
+ activeHint.className = "meeting-hint meeting-hint-active";
734
+ activeHint.textContent = t("hint.wake");
735
+ wrapper.appendChild(activeHint);
736
+
646
737
  const sttError = document.createElement("div");
647
738
  sttError.id = "meeting-stt-error";
648
739
  sttError.className = "meeting-stt-error";
@@ -698,9 +789,14 @@
698
789
  const data = await postJson("/speak", { text: text });
699
790
  if (!data.audio_base64) return;
700
791
  const audio = new Audio(`data:${data.mime_type || "audio/wav"};base64,${data.audio_base64}`);
792
+ state.ttsAudio = audio;
701
793
  state.phase = "speaking";
702
794
  updateStatus();
703
- const clear = () => { state.phase = "idle"; updateStatus(); };
795
+ const clear = () => {
796
+ if (state.ttsAudio === audio) state.ttsAudio = null;
797
+ state.phase = "idle";
798
+ updateStatus();
799
+ };
704
800
  audio.onended = clear;
705
801
  audio.onerror = clear;
706
802
  audio.play().catch(clear);
@@ -711,45 +807,119 @@
711
807
  }
712
808
  }
713
809
 
714
- // Speak the agent's reply aloud, but only while a meeting is live.
715
- Clacky.ext.subscribe("session:assistant-message", function (payload) {
716
- if (payload && payload.sessionId && state.sessionId && payload.sessionId !== state.sessionId) return;
810
+ function stopTts() {
811
+ const a = state.ttsAudio;
812
+ if (!a) return;
813
+ try { a.pause(); a.currentTime = 0; } catch (_) {}
814
+ state.ttsAudio = null;
815
+ if (state.phase === "speaking") {
816
+ state.phase = "idle";
817
+ updateStatus();
818
+ }
819
+ }
820
+
821
+ function toggleMic() {
822
+ if (!state.stream) return;
823
+ const tracks = state.stream.getAudioTracks();
824
+ if (!tracks.length) return;
825
+ state.micMuted = !state.micMuted;
826
+ tracks.forEach((t) => { t.enabled = !state.micMuted; });
827
+ updateStatus();
828
+ updateControls();
829
+ }
830
+
831
+ function interruptAsk() {
832
+ stopTts();
717
833
  state.asking = false;
718
- if (!state.active) return;
719
- if (!state.expectingSpeech) return;
720
834
  state.expectingSpeech = false;
721
- speakAnswer(payload && payload.content);
722
- });
835
+ state.conversationUntil = 0;
836
+ try {
837
+ if (Clacky.WS && typeof Clacky.WS.send === "function") {
838
+ Clacky.WS.send({ type: "interrupt", session_id: state.sessionId });
839
+ }
840
+ } catch (e) {
841
+ console.error("[meeting] interrupt send failed:", e && e.message);
842
+ }
843
+ updateStatus();
844
+ updateControls();
845
+ }
723
846
 
724
- // Register as a tab in the session aside panel
725
- Clacky.ext.ui.mount("session.aside", function (ctx) {
726
- const container = document.createElement("div");
727
- container.className = "meeting-container";
728
- state.container = container;
729
- renderUI(container);
847
+ function updateControls() {
848
+ if (!state.container) return;
849
+ const muteBtn = state.container.querySelector("#meeting-mute-btn");
850
+ if (muteBtn) {
851
+ muteBtn.textContent = state.micMuted ? t("btn.unmute") : t("btn.mute");
852
+ muteBtn.classList.toggle("meeting-btn-muted", state.micMuted);
853
+ }
854
+ const stopTtsBtn = state.container.querySelector("#meeting-stop-tts");
855
+ if (stopTtsBtn) stopTtsBtn.style.display = state.phase === "speaking" ? "" : "none";
856
+ const interruptBtn = state.container.querySelector("#meeting-interrupt-btn");
857
+ if (interruptBtn) interruptBtn.style.display = state.asking ? "" : "none";
858
+ }
859
+
860
+ // Handler dispatched from the module-level assistant-message subscription.
861
+ // - if we're expecting speech (a wake-word triggered ask), speak it and
862
+ // keep `asking` locked until playback ends (or fails).
863
+ // - otherwise release `asking` immediately.
864
+ function onAssistantMessage(payload) {
865
+ if (!state.active || !state.expectingSpeech) {
866
+ state.asking = false;
867
+ updateStatus();
868
+ return;
869
+ }
870
+ state.expectingSpeech = false;
871
+ speakAnswer(payload && payload.content);
872
+ }
873
+
874
+ // Register into the module registry so cross-session events reach us.
875
+ _sessionsById.set(ctx.sessionId, {
876
+ onAssistantMessage,
877
+ rerender() { if (state.container) renderUI(state.container); },
878
+ });
730
879
 
731
- // Load saved vocabulary, then probe for an in-progress meeting so a page
732
- // refresh restores the captions instead of silently dropping them.
880
+ // Kick off vocabulary + resume probe eagerly so the tab is ready even
881
+ // before the user first opens it (create runs on first render).
733
882
  fetch(apiUrl("/vocabulary"))
734
883
  .then((r) => r.json())
735
884
  .then((d) => {
736
885
  state.vocabulary = (d && d.vocabulary) || "";
737
- renderUI(container);
886
+ if (state.container) renderUI(state.container);
738
887
  })
739
888
  .catch(() => null)
740
- .then(() => probeActiveMeeting(container));
889
+ .then(() => probeActiveMeeting(state.container));
890
+
891
+ return {
892
+ state,
893
+ // Every tab activation gets a fresh empty container; wire it into state
894
+ // and paint the current state onto it.
895
+ attach(container) {
896
+ container.className = "meeting-container";
897
+ state.container = container;
898
+ renderUI(container);
899
+ },
900
+ // Session left the sidebar for good — release recorder / audio / timers.
901
+ dispose() {
902
+ _sessionsById.delete(ctx.sessionId);
903
+ state.active = false;
904
+ if (state.vadRaf) cancelAnimationFrame(state.vadRaf);
905
+ try { if (state.mediaRecorder && state.mediaRecorder.state !== "inactive") state.mediaRecorder.stop(); } catch (_) {}
906
+ if (state.stream) state.stream.getTracks().forEach((t) => t.stop());
907
+ if (state.audioCtx) { try { state.audioCtx.close(); } catch (_) {} }
908
+ clearInterval(state.annotateTimer);
909
+ stopStatusTicker();
910
+ stopTts();
911
+ },
912
+ };
913
+ }
741
914
 
742
- return container;
915
+ Clacky.ext.ui.mount("session.aside", {
916
+ create(ctx) { return createMeetingSession(ctx); },
917
+ render(container, ctx, runtime) { runtime.attach(container); },
743
918
  }, {
744
919
  tab: { id: "meeting", label: () => t("tab.label") },
745
920
  order: 200,
746
921
  });
747
922
 
748
- // Re-render on language switch so all labels follow the host language.
749
- document.addEventListener("langchange", function () {
750
- if (state.container) renderUI(state.container);
751
- });
752
-
753
923
  // Inject minimal styles
754
924
  const style = document.createElement("style");
755
925
  style.textContent = `
@@ -757,9 +927,16 @@
757
927
  .meeting-btn { padding: 7px 14px; border-radius: var(--radius-sm); border: 1px solid transparent; cursor: pointer; font-size: 13px; font-weight: 500; transition: background .15s, border-color .15s; }
758
928
  .meeting-btn-start { background: var(--color-button-primary); color: var(--color-button-primary-text); }
759
929
  .meeting-btn-start:hover { background: var(--color-button-primary-hover); }
760
- .meeting-btn-stop { background: transparent; color: var(--color-error); border-color: var(--color-error-border); margin-left: auto; }
930
+ .meeting-btn-stop { background: transparent; color: var(--color-error); border-color: var(--color-error-border); }
761
931
  .meeting-btn-stop:hover { background: var(--color-error-bg); }
932
+ .meeting-btn-mute, .meeting-btn-silence, .meeting-btn-interrupt { background: transparent; color: var(--color-text-secondary); border-color: var(--color-border-primary); }
933
+ .meeting-btn-mute:hover, .meeting-btn-silence:hover, .meeting-btn-interrupt:hover { background: var(--color-bg-hover); color: var(--color-text-primary); }
934
+ .meeting-btn-mute.meeting-btn-muted { color: var(--color-error); border-color: var(--color-error-border); background: var(--color-error-bg); }
935
+ .meeting-btn-silence, .meeting-btn-interrupt { color: var(--color-warning, var(--color-text-secondary)); border-color: var(--color-warning-border, var(--color-border-primary)); }
936
+ .meeting-header .meeting-btn-mute { margin-left: auto; }
937
+ .meeting-header .meeting-btn-mute + .meeting-btn { margin-left: 6px; }
762
938
  .meeting-hint { color: var(--color-text-tertiary); margin: 10px 0 0; font-size: 12px; line-height: 1.5; }
939
+ .meeting-hint-active { margin: 8px 0 4px; font-size: 11px; opacity: 0.75; }
763
940
  .meeting-vocab-section { margin-top: 20px; border-top: 1px solid var(--color-border-primary); padding-top: 16px; }
764
941
  .meeting-vocab-label { display: block; font-size: 12px; color: var(--color-text-tertiary); margin-bottom: 6px; }
765
942
  .meeting-vocab-box { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; width: 100%; box-sizing: border-box; background: var(--color-bg-input); border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); padding: 6px 8px; cursor: text; min-height: 36px; }
@@ -40,5 +40,6 @@ Produce the summary in this structure:
40
40
  1. Be concise — each bullet should be one sentence max.
41
41
  2. If speakers are identifiable from context, attribute decisions and actions to them.
42
42
  3. Ignore filler words, small talk, and off-topic tangents.
43
- 4. If the transcript is too short or empty, say so and skip the structured output.
43
+ 4. **If the transcript is empty, or contains only a handful of repeated words / meaningless fragments (e.g. the same term repeated with no real dialogue), do NOT invent content and do NOT produce the structured output.** Respond with a single short line noting there was no meaningful conversation to summarize, and stop.
44
44
  5. Write the summary in the same language the meeting was conducted in.
45
+ 6. Do not use tools other than reading the transcript from the user message. Never open a browser, run shell commands, or invoke unrelated skills.
@@ -0,0 +1,12 @@
1
+ id: time_machine
2
+ name: Time Machine
3
+ description: Vertical timeline of session tasks with per-task diff and one-click restore
4
+ version: "0.1.0"
5
+ author: OpenClacky
6
+ homepage: https://www.openclacky.com
7
+ license: MIT
8
+ origin: self
9
+ contributes:
10
+ panels:
11
+ - id: time_machine
12
+ view: panels/time_machine/view.js