@danieltmn/tamnora 0.9.4 → 0.9.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 (41) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +25 -10
  3. package/harness/packages/cli/src/commands/autostart.js +5 -1
  4. package/harness/packages/cli/src/commands/doctor.js +14 -0
  5. package/harness/packages/cli/src/commands/update.js +91 -21
  6. package/harness/packages/core/src/index.js +7 -1
  7. package/harness/packages/core/src/version.js +81 -7
  8. package/harness/packages/core/test/version.test.js +34 -0
  9. package/harness/packages/server/public/app.js +337 -24
  10. package/harness/packages/server/public/favicon.ico +0 -0
  11. package/harness/packages/server/public/icons/icon-128x128.png +0 -0
  12. package/harness/packages/server/public/icons/icon-144x144.png +0 -0
  13. package/harness/packages/server/public/icons/icon-152x152.png +0 -0
  14. package/harness/packages/server/public/icons/icon-192x192.png +0 -0
  15. package/harness/packages/server/public/icons/icon-384x384.png +0 -0
  16. package/harness/packages/server/public/icons/icon-512x512.png +0 -0
  17. package/harness/packages/server/public/icons/icon-72x72.png +0 -0
  18. package/harness/packages/server/public/icons/icon-96x96.png +0 -0
  19. package/harness/packages/server/public/icons/logo.png +0 -0
  20. package/harness/packages/server/public/icons/tamnora.ico +0 -0
  21. package/harness/packages/server/public/index.html +119 -30
  22. package/harness/packages/server/public/landing/index.html +8 -4
  23. package/harness/packages/server/public/logo.png +0 -0
  24. package/harness/packages/server/public/manifest.webmanifest +20 -0
  25. package/harness/packages/server/public/markdown.js +54 -0
  26. package/harness/packages/server/public/styles.css +1775 -1633
  27. package/harness/packages/server/src/lib/advisor.js +177 -0
  28. package/harness/packages/server/src/lib/agentRun.js +2 -1
  29. package/harness/packages/server/src/lib/ttsSettings.js +2 -2
  30. package/harness/packages/server/src/lib/update.js +82 -0
  31. package/harness/packages/server/src/routes/advisor.js +76 -0
  32. package/harness/packages/server/src/routes/chat.js +30 -0
  33. package/harness/packages/server/src/routes/tts.js +1 -1
  34. package/harness/packages/server/src/routes/update.js +43 -0
  35. package/harness/packages/server/src/server.js +26 -2
  36. package/harness/packages/server/src/static.js +1 -0
  37. package/harness/packages/server/test/advisor.test.js +205 -0
  38. package/harness/packages/server/test/markdown.test.js +43 -1
  39. package/harness/packages/server/test/update.test.js +94 -0
  40. package/harness/scripts/self-update.mjs +123 -0
  41. package/package.json +1 -1
@@ -4,7 +4,10 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>Tamnora Console</title>
7
- <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23231a17'/%3E%3Ctext x='16' y='23' font-family='Georgia,serif' font-size='18' font-weight='700' fill='%23e8dfd6' text-anchor='middle'%3ET%3C/text%3E%3C/svg%3E" />
7
+ <link rel="icon" href="/favicon.ico" sizes="any" />
8
+ <link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
9
+ <link rel="manifest" href="/manifest.webmanifest" />
10
+ <meta name="theme-color" content="#231a17" />
8
11
  <script>
9
12
  (function () {
10
13
  var theme = null;
@@ -34,7 +37,7 @@
34
37
  </head>
35
38
  <body>
36
39
  <header class="topbar">
37
- <div class="brand"><span class="logo"><span class="logo-t">T</span><span class="logo-h">H</span></span> <span class="ver">v0.9.4</span></div>
40
+ <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span class="ver">v0.9.6</span></div>
38
41
  <nav class="tabs">
39
42
  <button class="tab active" data-tab="chat">chat</button>
40
43
  <button class="tab" data-tab="providers">proveedores</button>
@@ -88,6 +91,15 @@
88
91
  </div>
89
92
  </header>
90
93
 
94
+ <div id="update-banner" class="update-banner" hidden>
95
+ <span class="update-banner-icon" aria-hidden="true">⬆</span>
96
+ <span id="update-banner-text" class="update-banner-text"></span>
97
+ <div class="update-banner-actions">
98
+ <button id="update-run" type="button" class="btn primary">actualizar</button>
99
+ <button id="update-later" type="button" class="btn">más tarde</button>
100
+ </div>
101
+ </div>
102
+
91
103
  <main>
92
104
  <section id="view-chat" class="view active">
93
105
  <aside id="sidebar" class="sidebar">
@@ -142,16 +154,45 @@
142
154
  <div id="attach-preview" class="attach-preview" hidden></div>
143
155
  <div id="chat-queue" class="chat-queue" hidden></div>
144
156
  <div id="audio-player" class="audio-player" hidden></div>
145
- <div class="composer-row">
146
- <span class="prompt-mark">❯</span>
147
- <button type="button" id="attach-image" class="btn icon" title="Adjuntar imagen (o pegala / arrastrala)" aria-label="adjuntar imagen">
157
+ <div class="composer-top">
158
+ <button type="button" id="cwd-pill" class="ctrl-pill" title="carpeta de trabajo de la sesión">
148
159
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
149
- <rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
150
- <circle cx="9" cy="9" r="2" />
151
- <path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
160
+ <path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
152
161
  </svg>
162
+ <span id="cwd-pill-label">sin carpeta</span>
163
+ <svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
164
+ </button>
165
+ <div class="ctrl-wrap">
166
+ <button type="button" id="mode-pill" class="ctrl-pill" aria-haspopup="menu" aria-expanded="false" title="permisos del agente">
167
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /></svg>
168
+ <span id="mode-pill-label">Workspace Write</span>
169
+ <svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
170
+ </button>
171
+ <div id="mode-menu" class="ctrl-menu" role="menu" hidden>
172
+ <button type="button" class="ctrl-menu-item" data-kind="read" role="menuitemradio" aria-checked="false">
173
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" /><circle cx="12" cy="12" r="3" /></svg>
174
+ <span>Read Only</span>
175
+ <svg class="ctrl-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>
176
+ </button>
177
+ <button type="button" class="ctrl-menu-item" data-kind="write" role="menuitemradio" aria-checked="false">
178
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" /><path d="m15 5 4 4" /></svg>
179
+ <span>Workspace Write</span>
180
+ <svg class="ctrl-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>
181
+ </button>
182
+ <button type="button" class="ctrl-menu-item" data-kind="full" role="menuitemradio" aria-checked="false">
183
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /><path d="m9 12 2 2 4-4" /></svg>
184
+ <span>Full access</span>
185
+ <svg class="ctrl-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>
186
+ </button>
187
+ </div>
188
+ </div>
189
+ </div>
190
+
191
+ <div class="composer-row">
192
+ <button type="button" id="attach-image" class="add-btn" title="Adjuntar imagen (o pegala / arrastrala)" aria-label="adjuntar imagen">
193
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14" /><path d="M12 5v14" /></svg>
153
194
  </button>
154
- <button type="button" id="mic-btn" class="btn icon" title="Dictar por voz (tocá para grabar y parar)" aria-label="dictar por voz">
195
+ <button type="button" id="mic-btn" class="add-btn" title="Dictar por voz (tocá para grabar y parar)" aria-label="dictar por voz">
155
196
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
156
197
  <path d="M12 19v3" />
157
198
  <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
@@ -161,27 +202,30 @@
161
202
  </button>
162
203
  <input id="image-input" type="file" accept="image/*" multiple hidden />
163
204
  <textarea id="chat-input" rows="1" placeholder="escribí un mensaje…" title="Enter envía · Shift+Enter salto" spellcheck="false"></textarea>
164
- <button class="btn primary" type="submit">enviar</button>
165
- <button class="btn" type="button" id="stop-btn" title="Detener la respuesta" hidden>parar</button>
205
+ <button type="button" id="model-pill" class="ctrl-pill model-pill" title="proveedor y modelo activos">
206
+ <span id="model-pill-label">—</span>
207
+ <svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
208
+ </button>
209
+ <button class="send-btn" type="submit" aria-label="enviar" title="enviar">
210
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12 7-7 7 7" /><path d="M12 19V5" /></svg>
211
+ </button>
212
+ <button class="add-btn" type="button" id="stop-btn" title="Detener la respuesta" aria-label="detener" hidden>
213
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2" /></svg>
214
+ </button>
215
+ </div>
216
+
217
+ <div id="statusbar" class="composer-status" title="estado del run" role="button" tabindex="0">
218
+ <span id="sb-activity" class="sb-activity" hidden></span>
219
+ <span id="sb-sync" class="sb-channel retrying" hidden title="sin sincronía con el server"></span>
220
+ <span id="sb-channel" class="sb-channel" hidden title="canal de eventos entre dispositivos"></span>
221
+ <span id="sb-rounds">listo</span>
222
+ <span class="spacer"></span>
223
+ <span id="sb-context" class="ctx muted" title="uso del contexto del modelo">
224
+ <span id="sb-tokens">tokens —</span>
225
+ <span class="ctx-bar" aria-hidden="true"><span id="sb-context-fill" class="ctx-fill"></span></span>
226
+ </span>
166
227
  </div>
167
228
  </form>
168
- <div id="statusbar" class="statusbar" title="cambiar modo, proveedor y modelo" role="button" tabindex="0">
169
- <span id="sb-mode">agent</span>
170
- <span class="dot">·</span>
171
- <span id="sb-provider">—</span>
172
- <span class="dot">·</span>
173
- <span id="sb-model">—</span>
174
- <span class="dot">·</span>
175
- <span id="sb-activity" class="sb-activity" hidden></span>
176
- <span id="sb-sync" class="sb-channel retrying" hidden title="sin sincronía con el server"></span>
177
- <span id="sb-channel" class="sb-channel" hidden title="canal de eventos entre dispositivos"></span>
178
- <span id="sb-rounds">listo</span>
179
- <span class="spacer"></span>
180
- <span id="sb-context" class="ctx muted" title="uso del contexto del modelo">
181
- <span id="sb-tokens">tokens —</span>
182
- <span class="ctx-bar" aria-hidden="true"><span id="sb-context-fill" class="ctx-fill"></span></span>
183
- </span>
184
- </div>
185
229
  </div>
186
230
 
187
231
  <div id="resizer-explorer" class="resizer" title="ajustar" role="separator" aria-orientation="vertical"></div>
@@ -415,8 +459,9 @@
415
459
  <button type="button" class="ajustes-tab" data-panel="mcp" role="tab">MCP</button>
416
460
  <button type="button" class="ajustes-tab" data-panel="skills" role="tab">Skills</button>
417
461
  <button type="button" class="ajustes-tab" data-panel="telegram" role="tab">Telegram</button>
418
- <button type="button" class="ajustes-tab" data-panel="voz" role="tab">Voz</button>
419
- <button type="button" class="ajustes-tab" data-panel="transcripcion" role="tab">Transcripción</button>
462
+ <button type="button" class="ajustes-tab" data-panel="voz" role="tab">Voz</button>
463
+ <button type="button" class="ajustes-tab" data-panel="asesor" role="tab">Asesor</button>
464
+ <button type="button" class="ajustes-tab" data-panel="transcripcion" role="tab">Transcripción</button>
420
465
  <button type="button" class="ajustes-tab" data-panel="limites" role="tab">Límites</button>
421
466
  </nav>
422
467
 
@@ -626,6 +671,12 @@
626
671
  <label class="check" title="avisar al terminar">
627
672
  <input id="voz-ann-end" type="checkbox" /> el cierre
628
673
  </label>
674
+ <label class="check" title="leer los consejos del asesor">
675
+ <input id="voz-ann-advice" type="checkbox" /> los consejos del asesor
676
+ </label>
677
+ <label class="check" title="leer la respuesta final del agente, sin bloques de código ni tablas">
678
+ <input id="voz-ann-answer" type="checkbox" /> la respuesta final (sin código)
679
+ </label>
629
680
  </div>
630
681
  <p id="voz-ffmpeg" class="muted" hidden>
631
682
  Sin <code>ffmpeg</code> en el PATH, en Telegram llega como audio MP3 en vez de nota
@@ -638,6 +689,44 @@
638
689
  </div>
639
690
  </div>
640
691
 
692
+ <div class="panel ajustes-panel" data-panel="asesor">
693
+ <div class="row between">
694
+ <h2>Asesor</h2>
695
+ <span id="asesor-status" class="summary">cargando…</span>
696
+ </div>
697
+ <p class="muted verify-line">
698
+ Un asesor que observa lo que el agente hace, lo interpreta y solo habla cuando tiene
699
+ algo que aportar: te alerta de un problema en el código, te explica un proceso o te da
700
+ un consejo. No lee la respuesta del agente. Usa el proveedor y modelo activos.
701
+ </p>
702
+ <label class="check" title="tener un asesor que interprete y comente el turno">
703
+ <input id="asesor-enabled" type="checkbox" /> Habilitar el asesor
704
+ </label>
705
+ <div class="voz-announce">
706
+ <span class="muted">qué aporta:</span>
707
+ <label class="check" title="alertar de problemas reales en el código">
708
+ <input id="asesor-code" type="checkbox" /> problemas en el código
709
+ </label>
710
+ <label class="check" title="explicar procesos o decisiones">
711
+ <input id="asesor-explain" type="checkbox" /> explicar procesos
712
+ </label>
713
+ <label class="check" title="dar consejos concretos">
714
+ <input id="asesor-advice" type="checkbox" /> consejos
715
+ </label>
716
+ </div>
717
+ <label class="field"><span>largo de cada consejo (palabras)</span>
718
+ <input id="asesor-words" type="number" min="10" max="120" step="5" value="40" />
719
+ </label>
720
+ <label class="field"><span>instrucciones para el asesor (opcional)</span>
721
+ <textarea id="asesor-notes" rows="3" placeholder="Ej: avisame siempre si toco secretos o config de producción."></textarea>
722
+ </label>
723
+ <div class="row">
724
+ <button id="asesor-save" class="btn primary" type="button">guardar</button>
725
+ <button id="asesor-test" class="btn" type="button">probar asesor</button>
726
+ <span id="asesor-msg" class="summary"></span>
727
+ </div>
728
+ </div>
729
+
641
730
  <div class="panel ajustes-panel" data-panel="transcripcion">
642
731
  <div class="row between">
643
732
  <h2>Transcripción</h2>
@@ -4,6 +4,10 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>Tamnora Harness</title>
7
+ <link rel="icon" href="/favicon.ico" sizes="any" />
8
+ <link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
9
+ <link rel="manifest" href="/manifest.webmanifest" />
10
+ <meta name="theme-color" content="#231a17" />
7
11
  <script>
8
12
  (function () {
9
13
  var theme = null;
@@ -129,13 +133,13 @@
129
133
  border-radius: 11px;
130
134
  display: grid;
131
135
  place-items: center;
136
+ overflow: hidden;
132
137
  background: var(--surface);
133
138
  backdrop-filter: blur(10px);
134
- font-family: Georgia, serif;
135
- font-weight: 700;
136
- font-size: 19px;
137
139
  }
138
140
 
141
+ .logo-mark img { width: 100%; height: 100%; display: block; object-fit: cover; }
142
+
139
143
  .logo-text strong {
140
144
  display: block;
141
145
  font-family: var(--serif);
@@ -420,7 +424,7 @@
420
424
  <div class="page">
421
425
  <header>
422
426
  <a href="/landing" class="logo">
423
- <div class="logo-mark">T</div>
427
+ <div class="logo-mark"><img src="/icons/logo.png" alt="Tamnora" /></div>
424
428
  <div class="logo-text">
425
429
  <strong>Tamnora Harness</strong>
426
430
  <small>Harness de agentes IA</small>
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "Tamnora Harness",
3
+ "short_name": "Tamnora",
4
+ "description": "Consola web del harness local de agentes IA.",
5
+ "start_url": "/",
6
+ "scope": "/",
7
+ "display": "standalone",
8
+ "background_color": "#f5efe2",
9
+ "theme_color": "#231a17",
10
+ "icons": [
11
+ { "src": "/icons/icon-72x72.png", "sizes": "72x72", "type": "image/png" },
12
+ { "src": "/icons/icon-96x96.png", "sizes": "96x96", "type": "image/png" },
13
+ { "src": "/icons/icon-128x128.png", "sizes": "128x128", "type": "image/png" },
14
+ { "src": "/icons/icon-144x144.png", "sizes": "144x144", "type": "image/png" },
15
+ { "src": "/icons/icon-152x152.png", "sizes": "152x152", "type": "image/png" },
16
+ { "src": "/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
17
+ { "src": "/icons/icon-384x384.png", "sizes": "384x384", "type": "image/png" },
18
+ { "src": "/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }
19
+ ]
20
+ }
@@ -215,3 +215,57 @@ export function renderMarkdown(text) {
215
215
  flush();
216
216
  return out.join('\n');
217
217
  }
218
+
219
+ function cleanSpeechLine(line) {
220
+ return line
221
+ .replace(/^\s*#{1,6}\s+/, '')
222
+ .replace(/^\s*>\s?/, '')
223
+ .replace(/^\s*([-*+]|\d+[.)])\s+/, '')
224
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
225
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
226
+ .replace(/`([^`]+)`/g, '$1')
227
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
228
+ .replace(/__([^_]+)__/g, '$1')
229
+ .replace(/(^|[^*\w])\*([^*\n]+)\*/g, '$1$2')
230
+ .replace(/(^|[^_\w])_([^_\n]+)_/g, '$1$2');
231
+ }
232
+
233
+ /**
234
+ * Version hablada de una respuesta: deja el texto corrido y saca lo que no se
235
+ * lee bien (bloques de codigo, tablas, marcas de markdown). Recorta a `maxChars`
236
+ * cortando en un limite de oracion, para no mandar una sintesis gigante al TTS.
237
+ */
238
+ export function speechText(text, { maxChars = 1500 } = {}) {
239
+ const stripped = String(text ?? '')
240
+ .replace(/```[\s\S]*?```/g, ' ')
241
+ .replace(/~~~[\s\S]*?~~~/g, ' ');
242
+ const lines = stripped.replace(/\r\n?/g, '\n').split('\n');
243
+ const kept = [];
244
+ let i = 0;
245
+ while (i < lines.length) {
246
+ const line = lines[i];
247
+ if (line.includes('|') && i + 1 < lines.length && isDelimiterRow(lines[i + 1])) {
248
+ i += 2;
249
+ while (i < lines.length && lines[i].trim() && lines[i].includes('|')) i += 1;
250
+ continue;
251
+ }
252
+ if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
253
+ i += 1;
254
+ continue;
255
+ }
256
+ kept.push(cleanSpeechLine(line));
257
+ i += 1;
258
+ }
259
+ let out = kept.join('\n').replace(/\s+/g, ' ').trim();
260
+ if (out.length > maxChars) {
261
+ const slice = out.slice(0, maxChars);
262
+ const cut = Math.max(
263
+ slice.lastIndexOf('. '),
264
+ slice.lastIndexOf('! '),
265
+ slice.lastIndexOf('? '),
266
+ slice.lastIndexOf('; ')
267
+ );
268
+ out = `${(cut > maxChars * 0.5 ? slice.slice(0, cut + 1) : slice).trim()}…`;
269
+ }
270
+ return out;
271
+ }