@agentprojectcontext/apx 1.28.0 → 1.30.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentprojectcontext/apx",
3
- "version": "1.28.0",
3
+ "version": "1.30.0",
4
4
  "description": "APX — unified CLI + daemon for the Agent Project Context (APC) standard.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -36,6 +36,14 @@
36
36
  // doesn't talk into the dead gap before the recorder starts.
37
37
  let micReady = false;
38
38
 
39
+ // Dead-mic detection: track the loudest RMS seen this session. If it stays
40
+ // near zero for DEAD_MIC_MS the stream is silent (no permission / muted /
41
+ // wrong device) and we surface a notice instead of hanging in "listening".
42
+ let listenStartTs = 0;
43
+ let micPeakRms = 0;
44
+ const DEAD_MIC_MS = 3500;
45
+ const DEAD_MIC_RMS = 0.004;
46
+
39
47
  // Silence auto-send: once speech has been heard, SILENCE_MS of quiet
40
48
  // auto-commits the recording. RMS (time-domain) is the voice/silence gate.
41
49
  // Both are overridable from config.json (desktop.silence_ms / voice_rms).
@@ -389,17 +397,28 @@
389
397
  <div class="bubble-user">${escapeHtml(m.text)}${viaIcon}</div>
390
398
  `;
391
399
  } else {
392
- t.innerHTML = `
400
+ // Consecutive agent messages (intro + post-tool answer …) read as one
401
+ // continued reply: only the FIRST shows the "Roby" header — the rest skip
402
+ // it so a tool turn isn't a stack of repeated "Roby" labels. A new header
403
+ // only appears when something (a user message) breaks the run.
404
+ const idx = messages.indexOf(m);
405
+ const prevMsg = idx > 0 ? messages[idx - 1] : null;
406
+ const agentCont = !!(prevMsg && prevMsg.role === "agent");
407
+ if (agentCont) t.classList.add("cont");
408
+ const header = agentCont ? "" : `
393
409
  <div class="role agent">
394
410
  <span class="ava sa"><img src="assets/superagent.png" alt=""/></span>
395
411
  <span class="who">${escapeHtml(agentName)}</span>
396
412
  <span class="time">${m.t || ""}</span>
397
- </div>
398
- <div class="msg-agent">${formatWordsHtml(m.text)}</div>
399
- ${m.audio ? "" /* scrubber added separately */ : ""}
413
+ </div>`;
414
+ // Copy is an inline icon at the end of the text, hover-only, so it never
415
+ // reserves an empty row. Regenerate lives in turn-actions and CSS shows it
416
+ // only on the last turn.
417
+ t.innerHTML = `
418
+ ${header}
419
+ <div class="msg-agent">${formatWordsHtml(m.text)}<button class="btn-copy" aria-label="Copiar" title="Copiar">${ICON.copy()}</button></div>
400
420
  <div class="turn-actions">
401
421
  <button class="chip btn-regen">${ICON.refresh()} Regenerar</button>
402
- <button class="chip btn-copy">${ICON.copy()} Copiar</button>
403
422
  </div>
404
423
  `;
405
424
  if (m.audio && m.dur) {
@@ -409,13 +428,13 @@
409
428
  actions.insertAdjacentHTML("beforebegin", scrubberHtml);
410
429
  wireScrubber(t, m);
411
430
  }
412
- // copy
431
+ // copy (inline icon → swaps to a check briefly)
413
432
  t.querySelector(".btn-copy")?.addEventListener("click", (e) => {
414
433
  navigator.clipboard?.writeText(m.text).catch(() => {});
415
434
  const btn = e.currentTarget;
416
435
  btn.classList.add("done");
417
- btn.innerHTML = `${ICON.check()} Copiado`;
418
- setTimeout(() => { btn.classList.remove("done"); btn.innerHTML = `${ICON.copy()} Copiar`; }, 1400);
436
+ btn.innerHTML = ICON.check();
437
+ setTimeout(() => { btn.classList.remove("done"); btn.innerHTML = ICON.copy(); }, 1400);
419
438
  });
420
439
  // regen: only the LAST agent turn can be regenerated. Past turns
421
440
  // can't because we'd have to re-issue the user prompt that came right
@@ -749,6 +768,8 @@
749
768
  micReady = false; // show "Cargando…" until the recorder is actually live
750
769
  speechSeen = false;
751
770
  lastVoiceTs = 0;
771
+ listenStartTs = 0;
772
+ micPeakRms = 0;
752
773
  pausePreviewed = false;
753
774
  reuseLiveOnStop = false;
754
775
  livePromise = null;
@@ -869,13 +890,42 @@
869
890
  // won't auto-send (speechSeen gates that).
870
891
  micReady = true;
871
892
  lastVoiceTs = Date.now();
893
+ listenStartTs = Date.now();
894
+ micPeakRms = 0;
872
895
  if (mode === "listening") render();
873
896
  } catch (e) {
897
+ // getUserMedia failed → classify and tell the user, instead of silently
898
+ // bailing to idle (which looks like "it just doesn't work").
874
899
  console.error("desktop renderer: mic error", e);
900
+ micReady = false;
875
901
  mode = "idle";
902
+ const name = e?.name || "";
903
+ let notice;
904
+ if (name === "NotAllowedError" || name === "SecurityError" || /permission|denied/i.test(e?.message || "")) {
905
+ notice = "Roby no tiene permiso para el micrófono. Activalo en Ajustes del sistema › Privacidad y seguridad › Micrófono, y volvé a intentar.";
906
+ } else if (name === "NotFoundError" || name === "DevicesNotFoundError" || name === "OverconstrainedError") {
907
+ notice = "No encontré ningún micrófono conectado. Revisá el dispositivo de entrada y reintentá.";
908
+ } else {
909
+ notice = "No pude abrir el micrófono (" + (name || e?.message || "error") + "). Reintentá o revisá los permisos.";
910
+ }
911
+ showMicNotice(notice);
876
912
  render();
877
913
  }
878
914
  }
915
+ // Visual-only system notice in the conversation (not a message, not history).
916
+ function showMicNotice(text) {
917
+ ensureConv();
918
+ if ($convScroll) {
919
+ // Replace any prior notice so they don't stack.
920
+ $convScroll.querySelector(".sys-notice")?.remove();
921
+ const el = document.createElement("div");
922
+ el.className = "sys-notice";
923
+ el.innerHTML = `<span class="ic">${ICON.mic()}</span><span>${escapeHtml(text)}</span>`;
924
+ $convScroll.appendChild(el);
925
+ scrollConvToBottom();
926
+ }
927
+ requestWindowResize();
928
+ }
879
929
  function stopMic() {
880
930
  try { mediaRecorder?.stop(); } catch {}
881
931
  try { audioStream?.getTracks().forEach((t) => t.stop()); } catch {}
@@ -915,6 +965,19 @@
915
965
  }
916
966
  const rms = Math.sqrt(sumSq / timeData.length);
917
967
  const now = Date.now();
968
+ if (rms > micPeakRms) micPeakRms = rms;
969
+ // Dead-mic guard: a real mic always has a noise floor (rms ≳ 0.005). If
970
+ // after DEAD_MIC_MS the signal is essentially flat zero, the stream is
971
+ // muted / wrong device / OS-blocked — tell the user instead of sitting
972
+ // in "listening" forever waiting for speech that can't arrive.
973
+ if (!speechSeen && listenStartTs && now - listenStartTs > DEAD_MIC_MS && micPeakRms < DEAD_MIC_RMS) {
974
+ waveRaf = null;
975
+ stopMic();
976
+ mode = "idle";
977
+ showMicNotice("No me está llegando audio del micrófono. Revisá que Roby tenga permiso (Ajustes del sistema › Privacidad y seguridad › Micrófono) y que esté seleccionado el micrófono correcto.");
978
+ render();
979
+ return;
980
+ }
918
981
  if (rms > VOICE_RMS) {
919
982
  speechSeen = true;
920
983
  lastVoiceTs = now;
@@ -328,19 +328,30 @@ button { font-family: inherit; }
328
328
  .wavebar i.cur { transform: scaleY(1.25); }
329
329
  .audio .dur { font-size: 11px; color: var(--ink-3); font-variant-numeric: tabular-nums; min-width: 30px; text-align: right; }
330
330
 
331
- /* per-turn actions */
331
+ /* Continued agent messages (no repeated "Roby" header) hug the previous one. */
332
+ .turn.cont { padding-top: 0; }
333
+ .turn.cont .msg-agent { margin-top: 1px; }
334
+
335
+ /* Inline copy icon at the end of an agent message — hover-only, so it never
336
+ reserves an empty row of vertical space. */
337
+ .msg-agent .btn-copy {
338
+ display: inline-flex; align-items: center; vertical-align: -3px;
339
+ margin-left: 6px; padding: 1px; border: none; background: transparent;
340
+ cursor: pointer; color: var(--ink-3); opacity: 0; transition: opacity .15s ease, color .15s ease;
341
+ }
342
+ .turn:hover .msg-agent .btn-copy { opacity: .55; }
343
+ .msg-agent .btn-copy:hover { opacity: 1; color: var(--ink); }
344
+ .msg-agent .btn-copy.done { opacity: 1; color: oklch(0.6 0.15 150); }
345
+
346
+ /* per-turn actions — only Regenerate now, and only on the LAST agent turn.
347
+ Regenerating a past turn would replay the most-recent user prompt (not the
348
+ one that produced that reply) and silently break the flow, so it's hidden
349
+ everywhere else and takes no space. */
332
350
  .turn-actions {
333
- margin: 7px 0 0 24px; display: flex; gap: 4px;
351
+ margin: 7px 0 0 24px; display: none; gap: 4px;
334
352
  opacity: 0; transition: opacity .2s ease;
335
353
  }
336
- .turn:hover .turn-actions, .turn.last .turn-actions { opacity: 1; }
337
-
338
- /* Regenerate is only meaningful on the LAST agent turn — regenerating a
339
- past one would replay the most-recent user prompt (not the one that
340
- produced this reply) and silently break the conversation flow. Copy
341
- stays available on every turn so users can grab old replies. */
342
- .turn .btn-regen { display: none; }
343
- .turn.last .btn-regen { display: inline-flex; }
354
+ .turn.last .turn-actions { display: flex; opacity: 1; }
344
355
  .chip {
345
356
  display: inline-flex; align-items: center; gap: 5px; cursor: pointer;
346
357
  padding: 4px 8px; border-radius: 9px; border: 1px solid var(--glass-hairline);
@@ -350,6 +361,16 @@ button { font-family: inherit; }
350
361
  .chip:hover { background: var(--glass-hairline); color: var(--ink); border-color: transparent; }
351
362
  .chip.done { color: oklch(0.6 0.15 150); }
352
363
 
364
+ /* system notice (e.g. no mic permission / dead mic) — not a chat message */
365
+ .sys-notice {
366
+ margin: 8px 0 4px; padding: 9px 11px; border-radius: 11px;
367
+ display: flex; align-items: flex-start; gap: 8px;
368
+ background: color-mix(in oklch, oklch(0.72 0.17 55) 12%, transparent);
369
+ border: 1px solid color-mix(in oklch, oklch(0.72 0.17 55) 35%, transparent);
370
+ color: var(--ink); font-size: 12.5px; line-height: 1.4;
371
+ }
372
+ .sys-notice .ic { flex: none; color: oklch(0.62 0.18 45); display: inline-flex; margin-top: 1px; }
373
+
353
374
  /* tool pill (when the agent runs a tool) */
354
375
  .tool-pill {
355
376
  display: inline-flex; align-items: center; gap: 6px;