@@ -68,6 +69,7 @@ https://github.com/user-attachments/assets/88326b9d-e763-46db-9635-75f9df9e8682
| **الصور** PNG, JPEG, TIFF, BMP, WebP | ✓ يُقرأ نصها بالتعرّف الضوئي على الحروف (OCR)³ | صور وإنفوجرافيك (Infographic)² | — | إدراجها في مستندات جديدة |
| **Markdown**، والنص العادي | ✓ | ✓ Markdown | — | — |
| **HTML** | ✓ | ✓ صفحة ويب | — | — |
+| **التسجيلات الصوتية** WAV وMP3 وFLAC وOgg Vorbis | ✓ تُفرَّغ نصيًا على جهازك | — | — | — |
| **بودكاست صوتي** (Podcast) | — | ✓ مع تفريغ نصي، وتُولَّد الأصوات على الجهاز نفسه افتراضيًا | — | — |
| **الخرائط الذهنية (Mind map)، والبطاقات التعليمية (Flashcards)، والاختبارات (Quiz)** | — | ✓ داخل التطبيق | — | — |
@@ -75,6 +77,25 @@ https://github.com/user-attachments/assets/88326b9d-e763-46db-9635-75f9df9e8682
عمليات التحرير والتحويل وأدوات PDF تُنشئ دائمًا ملفًا جديدًا، ولا يتغيّر الملف الأصلي أبدًا. تحظى ملفات PDF وWord وExcel اليوم بأوسع الإمكانات، ويتحسّن دعم الصيغ الأخرى باستمرار.
+## الإضافات
+
+تُربط من **الإعدادات › الإضافات** (Settings › Plugins).
+
+**من SurfSense** (By SurfSense). تقرأ الصفحات العامة دون الحاجة إلى حسابات شخصية، وتتطلب رخصة SurfSense (License): يكفي [تنزيل التطبيق](https://www.surfsense.com/downloads) وإدخال البريد الإلكتروني فيه للحصول على رخصة مجانية لمدة 30 يومًا.
+
+| الإضافة | ما تفعله |
+|---|---|
+| **Google Search** | البحث في Google وقراءة نتائجه وإعلاناته والأسئلة ذات الصلة |
+| **Web Crawl** | قراءة صفحة ويب، أو الزحف على موقع كامل وتحويله إلى نص نظيف |
+| **YouTube** | قراءة مقاطع فيديو YouTube وقنواته والتعليقات عليها |
+| **TikTok** | قراءة مقاطع فيديو TikTok والتعليقات عليها، والبحث عن المستخدمين، ومعرفة الرائج حاليًا |
+| **Google Maps** | العثور على الأماكن في Google Maps وقراءة مراجعاتها |
+| **Amazon** | قراءة منتجات Amazon ونتائج البحث فيه |
+| **Walmart** | قراءة منتجات Walmart ونتائج البحث فيه والمراجعات |
+| **Indeed** | قراءة إعلانات الوظائف على Indeed |
+
+**من جهات خارجية** (Third-party). خوادم MCP من [سجل الإضافات](plugins/registry)، مثل DeepWiki وNotion، أو أي خادم يُضاف عبر عنوان URL. لا تتطلب رخصة، وتبقى معطّلة إلى أن يُسمح بها.
+
## كيف يقارن بغيره
| | NotebookLM (أصبح الآن Gemini Notebook) | SurfSense |
@@ -92,18 +113,6 @@ https://github.com/user-attachments/assets/88326b9d-e763-46db-9635-75f9df9e8682
-## تاريخ النجوم
-
-
-
## الرخصة
مرخّص بموجب Apache-2.0. التفاصيل في [LICENSE](LICENSE).
diff --git a/README.de.md b/README.de.md
index 91a8aa4311..c85e4dd33a 100644
--- a/README.de.md
+++ b/README.de.md
@@ -26,6 +26,7 @@
Formate ·
Schnellstart ·
Vergleich ·
+ Plugins ·
Preise ·
Discord
@@ -68,6 +69,7 @@ SurfSense ist eine kostenlose Open-Source-Desktop-App für Dokumente, die du nic
| **Bilder** PNG, JPEG, TIFF, BMP, WebP | ✓ Text per OCR erkannt³ | Bilder und Infografiken² | — | In neue Dokumente einfügen |
| **Markdown**, reiner Text | ✓ | ✓ Markdown | — | — |
| **HTML** | ✓ | ✓ Webseite | — | — |
+| **Aufnahmen** WAV, MP3, FLAC, Ogg Vorbis | ✓ auf deinem Rechner transkribiert | — | — | — |
| **Podcast** (Audio) | — | ✓ mit Transkript, standardmäßig auf deinem Rechner vertont | — | — |
| **Mindmaps, Karteikarten, Quizze** | — | ✓ in der App | — | — |
@@ -75,6 +77,25 @@ SurfSense ist eine kostenlose Open-Source-Desktop-App für Dokumente, die du nic
Bearbeitungen, Umwandlungen und PDF-Werkzeuge erzeugen immer eine neue Datei; dein Original wird nie verändert. Bei PDF, Word und Excel kann SurfSense heute am meisten, und die Unterstützung der anderen Formate wird laufend besser.
+## Plugins
+
+Verbinde sie unter **Einstellungen › Plugins**.
+
+**Von SurfSense.** Diese Plugins lesen öffentliche Seiten ohne deine eigenen Konten und brauchen eine SurfSense-Lizenz: [Lade die App herunter](https://www.surfsense.com/downloads) und gib dort deine E-Mail-Adresse ein, dann bekommst du eine kostenlose Lizenz für 30 Tage.
+
+| Plugin | Was es macht |
+|---|---|
+| **Google Search** | Google durchsuchen und die Ergebnisse, Anzeigen und verwandten Fragen lesen |
+| **Web Crawl** | Eine Webseite lesen oder eine ganze Website crawlen und in sauberen Text umwandeln |
+| **YouTube** | YouTube-Videos, -Kanäle und ihre Kommentare lesen |
+| **TikTok** | TikTok-Videos und ihre Kommentare lesen, Nutzer suchen und sehen, was gerade angesagt ist |
+| **Google Maps** | Orte auf Google Maps finden und ihre Bewertungen lesen |
+| **Amazon** | Amazon-Produkte und Suchergebnisse lesen |
+| **Walmart** | Walmart-Produkte, Suchergebnisse und Bewertungen lesen |
+| **Indeed** | Stellenanzeigen auf Indeed lesen |
+
+**Drittanbieter.** MCP-Server aus der [Plugin-Registry](plugins/registry), etwa DeepWiki und Notion, oder jeder, den du per URL hinzufügst. Keine Lizenz nötig; sie bleiben aus, bis du sie erlaubst.
+
## SurfSense im Vergleich
| | NotebookLM (jetzt Gemini Notebook) | SurfSense |
@@ -92,18 +113,6 @@ Bearbeitungen, Umwandlungen und PDF-Werkzeuge erzeugen immer eine neue Datei; de
-## Sterneverlauf
-
-
-
## Lizenz
Apache-2.0. Siehe [LICENSE](LICENSE).
diff --git a/README.es.md b/README.es.md
index cf127e44a8..4bda0473f8 100644
--- a/README.es.md
+++ b/README.es.md
@@ -27,6 +27,7 @@
Formatos ·
Inicio rápido ·
Comparativa ·
+ Plugins ·
Precios ·
Discord
@@ -69,6 +70,7 @@ SurfSense es una aplicación de escritorio gratuita y de código abierto para lo
| **Imágenes** PNG, JPEG, TIFF, BMP, WebP | ✓ texto leído por OCR³ | Imágenes e infografías² | — | Insertar en documentos nuevos |
| **Markdown**, texto sin formato | ✓ | ✓ Markdown | — | — |
| **HTML** | ✓ | ✓ página web | — | — |
+| **Grabaciones** WAV, MP3, FLAC, Ogg Vorbis | ✓ transcritas en tu equipo | — | — | — |
| **Podcast** (audio) | — | ✓ con transcripción; por defecto, la voz se genera en tu equipo | — | — |
| **Mapas mentales, tarjetas didácticas, cuestionarios** | — | ✓ en la aplicación | — | — |
@@ -76,6 +78,25 @@ SurfSense es una aplicación de escritorio gratuita y de código abierto para lo
Las ediciones, las conversiones y las herramientas de PDF siempre crean un archivo nuevo; tu original nunca se modifica. Hoy por hoy, PDF, Word y Excel son los formatos con los que más se puede hacer, y la compatibilidad con el resto de formatos no deja de mejorar.
+## Plugins
+
+Conéctalos en **Ajustes › Plugins**.
+
+**De SurfSense.** Leen páginas públicas sin usar tus propias cuentas y necesitan una licencia de SurfSense: [descarga la aplicación](https://www.surfsense.com/downloads) e introduce allí tu correo electrónico para conseguir una gratuita de 30 días.
+
+| Plugin | Qué hace |
+|---|---|
+| **Google Search** | Buscar en Google y leer sus resultados, anuncios y preguntas relacionadas |
+| **Web Crawl** | Leer una página web o rastrear un sitio entero y convertirlo en texto limpio |
+| **YouTube** | Leer videos y canales de YouTube, con sus comentarios |
+| **TikTok** | Leer videos de TikTok y sus comentarios, buscar usuarios y ver qué es tendencia |
+| **Google Maps** | Encontrar lugares en Google Maps y leer sus reseñas |
+| **Amazon** | Leer productos y resultados de búsqueda de Amazon |
+| **Walmart** | Leer productos, resultados de búsqueda y reseñas de Walmart |
+| **Indeed** | Leer ofertas de empleo en Indeed |
+
+**De terceros.** Servidores MCP del [registro de plugins](plugins/registry), como DeepWiki y Notion, o cualquiera que añadas por URL. No necesitan licencia; siguen desactivados hasta que los permitas.
+
## Cómo se compara
| | NotebookLM (ahora Gemini Notebook) | SurfSense |
@@ -93,18 +114,6 @@ Las ediciones, las conversiones y las herramientas de PDF siempre crean un archi
-## Historial de estrellas
-
-
-
## Licencia
Apache-2.0. Consulta el archivo [LICENSE](LICENSE).
diff --git a/README.fr.md b/README.fr.md
index 8c7e815a6b..fa82e22387 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -26,6 +26,7 @@
Formats ·
Démarrage rapide ·
Comparaison ·
+ Plugins ·
Tarifs ·
Discord
@@ -68,6 +69,7 @@ SurfSense est une application de bureau gratuite et open source pour les documen
| **Images** PNG, JPEG, TIFF, BMP, WebP | ✓ texte lu par OCR³ | Images et infographies² | — | Insérer dans de nouveaux documents |
| **Markdown**, texte brut | ✓ | ✓ Markdown | — | — |
| **HTML** | ✓ | ✓ page web | — | — |
+| **Enregistrements** WAV, MP3, FLAC, Ogg Vorbis | ✓ transcrits sur votre machine | — | — | — |
| **Podcast** (audio) | — | ✓ avec transcription, mis en voix sur votre machine par défaut | — | — |
| **Cartes mentales, cartes mémoire, quiz** | — | ✓ dans l'application | — | — |
@@ -75,6 +77,25 @@ SurfSense est une application de bureau gratuite et open source pour les documen
Les modifications, les conversions et les outils PDF créent toujours un nouveau fichier ; votre original n'est jamais modifié. C'est avec les formats PDF, Word et Excel que SurfSense en fait le plus aujourd'hui, et la prise en charge des autres formats continue de s'améliorer.
+## Plugins
+
+Connectez-les dans **Paramètres › Plugins**.
+
+**Par SurfSense.** Ils lisent des pages publiques sans passer par vos propres comptes et nécessitent une licence SurfSense : [téléchargez l'application](https://www.surfsense.com/downloads) et saisissez-y votre adresse e-mail pour recevoir une licence gratuite de 30 jours.
+
+| Plugin | Ce qu'il fait |
+|---|---|
+| **Google Search** | Rechercher sur Google et lire ses résultats, ses annonces et les questions associées |
+| **Web Crawl** | Lire une page web, ou explorer un site entier et le convertir en texte propre |
+| **YouTube** | Lire le contenu des vidéos et des chaînes YouTube, avec leurs commentaires |
+| **TikTok** | Lire le contenu des vidéos TikTok et leurs commentaires, rechercher des utilisateurs et voir ce qui est tendance |
+| **Google Maps** | Trouver des lieux sur Google Maps et lire leurs avis |
+| **Amazon** | Lire les produits et les résultats de recherche d'Amazon |
+| **Walmart** | Lire les produits, les résultats de recherche et les avis de Walmart |
+| **Indeed** | Lire les offres d'emploi sur Indeed |
+
+**Tiers.** Des serveurs MCP issus du [registre de plugins](plugins/registry), comme DeepWiki et Notion, ou tout serveur que vous ajoutez par URL. Aucune licence n'est nécessaire ; ils restent désactivés tant que vous ne les autorisez pas.
+
## Comparaison
| | NotebookLM (désormais Gemini Notebook) | SurfSense |
@@ -92,18 +113,6 @@ Les modifications, les conversions et les outils PDF créent toujours un nouveau
-## Historique des étoiles
-
-
-
## Licence
Apache-2.0. Voir [LICENSE](LICENSE).
diff --git a/README.hi.md b/README.hi.md
index a9f7068c3c..5bac269ee3 100644
--- a/README.hi.md
+++ b/README.hi.md
@@ -26,6 +26,7 @@
फ़ॉर्मैट ·
झटपट शुरुआत ·
तुलना ·
+ प्लगइन ·
प्राइसिंग ·
Discord
@@ -68,6 +69,7 @@ SurfSense एक मुफ़्त, ओपन-सोर्स डेस्क
| **इमेज** PNG, JPEG, TIFF, BMP, WebP | ✓ टेक्स्ट OCR से पढ़ा जाता है³ | इमेज और इन्फ़ोग्राफ़िक² | — | नए दस्तावेज़ों में लगाना |
| **Markdown**, सादा टेक्स्ट | ✓ | ✓ Markdown | — | — |
| **HTML** | ✓ | ✓ वेब पेज | — | — |
+| **रिकॉर्डिंग** WAV, MP3, FLAC, Ogg Vorbis | ✓ आपकी अपनी मशीन पर ट्रांसक्राइब की जाती हैं | — | — | — |
| **पॉडकास्ट** (ऑडियो) | — | ✓ ट्रांसक्रिप्ट के साथ, आवाज़ डिफ़ॉल्ट रूप से आपकी अपनी मशीन पर दी जाती है | — | — |
| **माइंड मैप, फ़्लैशकार्ड, क्विज़** | — | ✓ ऐप के अंदर | — | — |
@@ -75,6 +77,25 @@ SurfSense एक मुफ़्त, ओपन-सोर्स डेस्क
एडिट, कन्वर्ज़न और PDF टूल हमेशा एक नई फ़ाइल बनाते हैं; आपकी मूल फ़ाइल कभी नहीं बदली जाती। आज सबसे ज़्यादा सुविधाएँ PDF, Word और Excel के लिए हैं, और बाकी फ़ॉर्मैट का सपोर्ट लगातार बेहतर हो रहा है।
+## प्लगइन
+
+इन्हें **सेटिंग्स › प्लगइन** में कनेक्ट करें।
+
+**SurfSense की ओर से।** ये आपके अपने अकाउंट के बिना सार्वजनिक पेज पढ़ते हैं और इनके लिए SurfSense लाइसेंस ज़रूरी है: [ऐप डाउनलोड करें](https://www.surfsense.com/downloads) और 30 दिन का मुफ़्त लाइसेंस पाने के लिए वहाँ अपना ईमेल डालें।
+
+| प्लगइन | यह क्या करता है |
+|---|---|
+| **Google Search** | Google पर खोजना और उसके नतीजे, विज्ञापन और संबंधित सवाल पढ़ना |
+| **Web Crawl** | कोई वेब पेज पढ़ना, या पूरी साइट को क्रॉल करके साफ़ टेक्स्ट में बदलना |
+| **YouTube** | YouTube के वीडियो, चैनल और उनकी टिप्पणियाँ पढ़ना |
+| **TikTok** | TikTok के वीडियो और उनकी टिप्पणियाँ पढ़ना, यूज़र खोजना और यह देखना कि क्या ट्रेंड कर रहा है |
+| **Google Maps** | Google Maps पर जगहें खोजना और उनके रिव्यू पढ़ना |
+| **Amazon** | Amazon के प्रोडक्ट और खोज के नतीजे पढ़ना |
+| **Walmart** | Walmart के प्रोडक्ट, खोज के नतीजे और रिव्यू पढ़ना |
+| **Indeed** | Indeed पर नौकरियों की लिस्टिंग पढ़ना |
+
+**थर्ड-पार्टी।** [प्लगइन रजिस्ट्री](plugins/registry) के MCP सर्वर, जैसे DeepWiki और Notion, या कोई भी सर्वर जिसे आप URL से जोड़ें। इनके लिए लाइसेंस की ज़रूरत नहीं; जब तक आप अनुमति न दें, ये बंद रहते हैं।
+
## दूसरों से तुलना
| | NotebookLM (अब Gemini Notebook) | SurfSense |
@@ -92,18 +113,6 @@ SurfSense एक मुफ़्त, ओपन-सोर्स डेस्क
-## स्टार हिस्ट्री
-
-
-
## ライセンス
Apache-2.0。[LICENSE](LICENSE) を参照してください。
diff --git a/README.ko.md b/README.ko.md
index 6b70188e0c..b92eaceb71 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -26,6 +26,7 @@
형식 ·
빠른 시작 ·
비교 ·
+ 플러그인 ·
요금 ·
Discord
@@ -68,6 +69,7 @@ SurfSense는 업로드할 수 없는 문서를 위한 무료 오픈소스 데스
| **이미지** PNG, JPEG, TIFF, BMP, WebP | ✓ OCR로 텍스트 인식³ | 이미지와 인포그래픽² | — | 새 문서에 넣기 |
| **Markdown**, 일반 텍스트 | ✓ | ✓ Markdown | — | — |
| **HTML** | ✓ | ✓ 웹 페이지 | — | — |
+| **녹음 파일** WAV, MP3, FLAC, Ogg Vorbis | ✓ 내 컴퓨터에서 텍스트로 변환 | — | — | — |
| **팟캐스트** (오디오) | — | ✓ 스크립트 포함, 기본적으로 내 컴퓨터에서 음성 생성 | — | — |
| **마인드맵, 플래시카드, 퀴즈** | — | ✓ 앱 안에서 | — | — |
@@ -76,6 +78,25 @@ SurfSense는 업로드할 수 없는 문서를 위한 무료 오픈소스 데스
편집, 변환, PDF 도구는 항상 새 파일을 만들며, 원본은 절대 바뀌지 않습니다. 지금은 PDF, Word, Excel에서 할 수 있는 일이 가장 많고, 다른 형식의 지원도 계속 나아지고 있습니다.
+## 플러그인
+
+**설정 › 플러그인**에서 연결하세요.
+
+**SurfSense 제공.** 내 계정 없이 공개 페이지를 읽으며, SurfSense 라이선스가 필요합니다. [앱을 다운로드](https://www.surfsense.com/downloads)한 다음 앱에서 이메일을 입력하면 30일 무료 라이선스를 받을 수 있습니다.
+
+| 플러그인 | 하는 일 |
+|---|---|
+| **Google Search** | Google에서 검색하고 검색 결과, 광고, 관련 질문 읽기 |
+| **Web Crawl** | 웹 페이지를 읽거나 사이트 전체를 크롤링해 깔끔한 텍스트로 만들기 |
+| **YouTube** | YouTube 동영상, 채널, 댓글 읽기 |
+| **TikTok** | TikTok 동영상과 댓글 읽기, 사용자 검색, 지금 인기 있는 콘텐츠 보기 |
+| **Google Maps** | Google Maps에서 장소를 찾고 리뷰 읽기 |
+| **Amazon** | Amazon 상품과 검색 결과 읽기 |
+| **Walmart** | Walmart 상품, 검색 결과, 리뷰 읽기 |
+| **Indeed** | Indeed의 채용 공고 읽기 |
+
+**서드파티.** [플러그인 레지스트리](plugins/registry)에 있는 DeepWiki, Notion 같은 MCP 서버 또는 URL로 직접 추가한 서버입니다. 라이선스는 필요 없으며, 허용하기 전까지 꺼져 있습니다.
+
## 비교
| | NotebookLM (현재 Gemini Notebook) | SurfSense |
@@ -93,18 +114,6 @@ SurfSense는 업로드할 수 없는 문서를 위한 무료 오픈소스 데스
-## 스타 기록
-
-
-
## 라이선스
Apache-2.0. [LICENSE](LICENSE)를 보세요.
diff --git a/README.md b/README.md
index 57afdcb388..80fe978875 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,7 @@
Formats ·
Quick start ·
Compare ·
+ Plugins ·
Pricing ·
Discord
@@ -76,6 +77,7 @@ SurfSense is a free, open-source desktop app for documents you can't upload. A p
| **Images** PNG, JPEG, TIFF, BMP, WebP | ✓ text read by OCR³ | Images and infographics² | — | Place in new documents |
| **Markdown**, plain text | ✓ | ✓ Markdown | — | — |
| **HTML** | ✓ | ✓ web page | — | — |
+| **Recordings** WAV, MP3, FLAC, Ogg Vorbis | ✓ transcribed on your machine | — | — | — |
| **Podcast** (audio) | — | ✓ with a transcript, voiced on your machine by default | — | — |
| **Mind maps, flashcards, quizzes** | — | ✓ in the app | — | — |
@@ -83,6 +85,25 @@ SurfSense is a free, open-source desktop app for documents you can't upload. A p
Edits, conversions and PDF tools always make a new file; your original is never changed. PDF, Word and Excel do the most today, and support for the other formats keeps improving.
+## Plugins
+
+Connect them in **Settings › Plugins**.
+
+**By SurfSense.** They read public pages without your own accounts and need a SurfSense license: [download the app](https://www.surfsense.com/downloads) and enter your email there for a free 30-day one.
+
+| Plugin | What it does |
+|---|---|
+| **Google Search** | Search Google and read its results, ads and related questions |
+| **Web Crawl** | Read a web page, or crawl a whole site into clean text |
+| **YouTube** | Read YouTube videos, channels and their comments |
+| **TikTok** | Read TikTok videos and their comments, search users, and see what is trending |
+| **Google Maps** | Find places on Google Maps and read their reviews |
+| **Amazon** | Read Amazon products and search results |
+| **Walmart** | Read Walmart products, search results and reviews |
+| **Indeed** | Read job listings on Indeed |
+
+**Third-party.** MCP servers from the [plugin registry](plugins/registry), such as DeepWiki and Notion, or any you add by URL. No license needed; they stay off until you allow them.
+
## How it compares
| | NotebookLM (now Gemini Notebook) | SurfSense |
@@ -100,18 +121,6 @@ Edits, conversions and PDF tools always make a new file; your original is never
-## Star history
-
-
-
## License
Apache-2.0. See [LICENSE](LICENSE).
diff --git a/README.pt-BR.md b/README.pt-BR.md
index 848ecf50fe..88bec4418a 100644
--- a/README.pt-BR.md
+++ b/README.pt-BR.md
@@ -27,6 +27,7 @@
Formatos ·
Início rápido ·
Comparativo ·
+ Plugins ·
Preços ·
Discord
@@ -69,6 +70,7 @@ O SurfSense é um aplicativo de desktop gratuito e open source para os documento
| **Imagens** PNG, JPEG, TIFF, BMP, WebP | ✓ texto lido por OCR³ | Imagens e infográficos² | — | Inserir em novos documentos |
| **Markdown**, texto simples | ✓ | ✓ Markdown | — | — |
| **HTML** | ✓ | ✓ página web | — | — |
+| **Gravações** WAV, MP3, FLAC, Ogg Vorbis | ✓ transcritas na sua máquina | — | — | — |
| **Podcast** (áudio) | — | ✓ com transcrição; por padrão, a voz é gerada na sua máquina | — | — |
| **Mapas mentais, flashcards, quizzes** | — | ✓ no aplicativo | — | — |
@@ -76,6 +78,25 @@ O SurfSense é um aplicativo de desktop gratuito e open source para os documento
Edições, conversões e ferramentas de PDF sempre geram um arquivo novo; o seu original nunca é alterado. Hoje, PDF, Word e Excel são os formatos com mais recursos, e o suporte aos demais formatos continua melhorando.
+## Plugins
+
+Conecte-os em **Configurações › Plugins**.
+
+**Da SurfSense.** Eles leem páginas públicas sem usar suas próprias contas e precisam de uma licença do SurfSense: [baixe o aplicativo](https://www.surfsense.com/downloads) e informe seu e-mail nele para receber uma licença gratuita de 30 dias.
+
+| Plugin | O que faz |
+|---|---|
+| **Google Search** | Pesquisar no Google e ler os resultados, os anúncios e as perguntas relacionadas |
+| **Web Crawl** | Ler uma página web ou rastrear um site inteiro e transformá-lo em texto limpo |
+| **YouTube** | Ler vídeos, canais e comentários do YouTube |
+| **TikTok** | Ler vídeos e comentários do TikTok, pesquisar usuários e ver o que está em alta |
+| **Google Maps** | Encontrar lugares no Google Maps e ler as avaliações deles |
+| **Amazon** | Ler produtos e resultados de pesquisa da Amazon |
+| **Walmart** | Ler produtos, resultados de pesquisa e avaliações do Walmart |
+| **Indeed** | Ler vagas de emprego no Indeed |
+
+**De terceiros.** Servidores MCP do [registro de plugins](plugins/registry), como DeepWiki e Notion, ou qualquer servidor que você adicione por URL. Não precisam de licença e ficam desativados até você permitir.
+
## Como ele se compara
| | NotebookLM (agora Gemini Notebook) | SurfSense |
@@ -93,18 +114,6 @@ Edições, conversões e ferramentas de PDF sempre geram um arquivo novo; o seu
-## Histórico de estrelas
-
-
-
## Licença
Apache-2.0. Veja o arquivo [LICENSE](LICENSE).
diff --git a/README.ru.md b/README.ru.md
index df1288ae7b..a0c32a750c 100644
--- a/README.ru.md
+++ b/README.ru.md
@@ -26,6 +26,7 @@
Форматы ·
Быстрый старт ·
Сравнение ·
+ Плагины ·
Цены ·
Discord
@@ -68,6 +69,7 @@ SurfSense — бесплатное настольное приложение с
| **Изображения** PNG, JPEG, TIFF, BMP, WebP | ✓ текст распознаётся через OCR³ | Изображения и инфографика² | — | Вставка в новые документы |
| **Markdown**, обычный текст | ✓ | ✓ Markdown | — | — |
| **HTML** | ✓ | ✓ веб-страница | — | — |
+| **Аудиозаписи** WAV, MP3, FLAC, Ogg Vorbis | ✓ расшифровываются на вашем компьютере | — | — | — |
| **Подкаст** (аудио) | — | ✓ с расшифровкой, по умолчанию озвучивается на вашем компьютере | — | — |
| **Интеллект-карты, карточки, тесты** | — | ✓ в приложении | — | — |
@@ -76,6 +78,25 @@ SurfSense — бесплатное настольное приложение с
Правки, преобразования и инструменты для PDF всегда создают новый файл; оригинал никогда не меняется. Шире всего сейчас поддерживаются PDF, Word и Excel, а поддержка остальных форматов постоянно улучшается.
+## Плагины
+
+Подключите их в разделе **Настройки › Плагины**.
+
+**От SurfSense.** Они читают общедоступные страницы без ваших собственных аккаунтов, и для них нужна лицензия SurfSense: [скачайте приложение](https://www.surfsense.com/downloads) и введите в нём свою почту, чтобы получить бесплатную лицензию на 30 дней.
+
+| Плагин | Что делает |
+|---|---|
+| **Google Search** | Ищет в Google и читает результаты, рекламу и похожие вопросы |
+| **Web Crawl** | Читает веб-страницу или обходит весь сайт, превращая его в чистый текст |
+| **YouTube** | Читает видео и каналы YouTube, а также комментарии к ним |
+| **TikTok** | Читает видео TikTok и комментарии к ним, ищет пользователей и показывает, что сейчас в тренде |
+| **Google Maps** | Находит места на Google Maps и читает отзывы о них |
+| **Amazon** | Читает карточки товаров и результаты поиска на Amazon |
+| **Walmart** | Читает карточки товаров, результаты поиска и отзывы на Walmart |
+| **Indeed** | Читает вакансии на Indeed |
+
+**Сторонние.** MCP-серверы из [реестра плагинов](plugins/registry), например DeepWiki и Notion, или любые другие, которые вы добавите по URL. Лицензия не нужна; они остаются выключенными, пока вы их не разрешите.
+
## Сравнение
| | NotebookLM (теперь Gemini Notebook) | SurfSense |
@@ -93,18 +114,6 @@ SurfSense — бесплатное настольное приложение с
-## История звёзд
-
-
-
## 许可证
Apache-2.0。详见 [LICENSE](LICENSE)。
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 0bc8fd978d..023be148f7 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -6,11 +6,11 @@ What the maintainers are working on, grouped by when. This page names the initia
- **Release and CI health.** v2.0.3 was the first release to carry the llama.cpp, sd.cpp and audio.cpp runtimes, built and packaged on all three runners, and v2.2.0 the first whose installers carry opencode, signed on Windows and notarized on macOS. What is left is CI that catches a break before a release, and the installers' third-party notices ([about](architecture/about.md)). See [packaging](architecture/packaging.md#known-gaps) and [updates](architecture/updates.md).
- **Egress gaps.** The follow-up fetch for a remote image URL leaves without a consent decision, and some stored grants no longer match what Settings › Network shows. See [egress](architecture/egress.md).
-- **Import from cloud.** The hosted export window closes on 18 Oct 2026. Imports need a progress summary, and a re-run must bring back the chat threads an interrupted import missed. See [import](architecture/import.md).
+- **Import from cloud.** The hosted export window closes on 18 Oct 2026. Imports need a progress summary. See [import](architecture/import.md).
- **Scraper API and MCP license mode.** PATs are purged on 18 Oct 2026; after that, license mode is how scraper API and MCP users keep access. See [contract 2](contracts/02-scraper-api-auth.md).
- **Model catalog.** One catalog for local and remote models, each classified offline from a reviewed, packaged manifest and keyed by model type. See the [proposal](proposals/model-catalog.md).
- **Hosted wind-down to 18 Oct 2026.** `/sunset` lacks the deletion date, the refund offer and the MCP change; background jobs keep running through the export-only tail; and the purge is still ahead. See [sunset](architecture/sunset.md), the [license portal](architecture/license/portal.md) and the page plan in [`plans/community-local/portal/02-pages.md`](../plans/community-local/portal/02-pages.md).
-- **Plugins.** Redesigned: a plugin is an MCP server the agent and the user call through one Tool Gateway, the chat engine later, remote servers hosted by their publishers first, with SurfSense's paid scrapers as the first paid plugin once the scraper API has license mode. The earlier local runner is kept for bundles later. See the [proposal](proposals/plugins/README.md) and ADRs [0051](adr/0051-plugins-are-mcp-servers-behind-one-tool-gateway.md) to [0054](adr/0054-surfsenses-plugins-run-on-one-plugin-host.md).
+- **Plugins.** Remote plugins are built: SurfSense's scrapers on its plugin host, a signed registry of third-party plugins, and any server by URL, called by the agent in Agentic threads. Left: long calls as jobs and the host's scale rule, then the chat engine's router step for Basic threads. Bundles stay deferred. See [plugins](architecture/plugins.md), the [plugin host](architecture/plugin-host.md), the [proposal](proposals/plugins/README.md) for what is not built, and ADRs [0051](adr/0051-plugins-are-mcp-servers-behind-one-tool-gateway.md) to [0054](adr/0054-surfsenses-plugins-run-on-one-plugin-host.md).
- **Agent.** In progress: a chat opened in Agentic mode is the agent's, answered in steps by opencode over the workspace's extracted text, on any model a technical gate, such as a stated no on tool calls, does not keep out. It makes Word, PDF, PowerPoint and Excel files as scripts SurfSense runs, revises the user's own Word, Excel and PowerPoint files into new copies, Word's as tracked changes, works on PDFs and analyses data; the user's file itself is never written. opencode ships in every installer and dev run, and a list of measured or assumed models sets each chat's default mode ([model capabilities](architecture/model-capabilities.md)); a model below the bar will also run fixed workflows with structured output. The [file agent proposal](proposals/file-agent/README.md) orders what comes next: engines, versions, jobs such as the contract redline, and linked folders. See [agent](architecture/agent.md), the [agent proposal](proposals/agent/README.md) and [ADR 0039](adr/0039-document-scripts-run-without-approval.md).
- **Docker.** The desktop stack without Electron as one container, replacing the legacy self-host compose. The CORS origins setting, the `VITE_API_BASE` fallback and boot outside `index.ts` are in; the headless entry is next. See the [proposal](proposals/docker/README.md) and [ADR 0035](adr/0035-docker-compose-runs-the-desktop-stack.md).
diff --git a/docs/adr/0022-markdown-only-cloud-import.md b/docs/adr/0022-markdown-only-cloud-import.md
index 046693cefa..8fd46326a6 100644
--- a/docs/adr/0022-markdown-only-cloud-import.md
+++ b/docs/adr/0022-markdown-only-cloud-import.md
@@ -13,7 +13,9 @@ Moving the hosted service's current users onto the desktop app is the point of t
- The export is one ZIP for the whole account, markdown only ([contract 3](../contracts/03-export-bundle.md)): every ready document's extracted content with its folder structure and title, plus the chat threads. Original uploads and old generated artifacts stay behind. Tool calls and agent steps are dropped, and citations are reduced to document titles.
- Import is a bulk upload on the API side, and nothing touches the network. It creates one local workspace per hosted workspace, writes each markdown file through the upload path so it gets a `dedup_key`, keeps `folder_path`, `source` and the hosted ids in `document_metadata`, and enqueues the existing `ingest_document` ([`modules/migration/`](../../surfsense_local/backend/modules/migration/)).
- An imported chat thread keeps its hosted id in `cloud_id`. Re-import skips a
- thread with that id without changing it and creates one that is missing.
+ thread with that id without changing it. In an ordinary workspace it creates
+ a missing thread; in a workspace marked as holding legacy unkeyed imports, it
+ only assigns that id to the unique unkeyed row with the same creation time.
- The app re-chunks and re-embeds everything locally. Markdown is in `TEXT_SUFFIXES` ([`worker/ingestion/parsing.py`](../../surfsense_local/backend/worker/ingestion/parsing.py)), so Docling is skipped and import is chunk and embed only.
- An imported assistant message keeps its citations as a "Sources: A, B" line in its text, because local citations are chunk-backed and imported ones cannot be.
diff --git a/docs/adr/0036-the-index-records-its-embedder.md b/docs/adr/0036-the-index-records-its-embedder.md
index 07dec7f83d..4eae663da0 100644
--- a/docs/adr/0036-the-index-records-its-embedder.md
+++ b/docs/adr/0036-the-index-records-its-embedder.md
@@ -1,6 +1,6 @@
# ADR 0036: The index records which embedder built it, and the embedder is fixed once per install
-- **Status:** Accepted
+- **Status:** Accepted; amended by [ADR 0058](0058-the-bundled-embedder-runs-at-fp32-on-x86-64.md): the FP16 file stays the one pinned and recorded, and on x86-64 it runs upcast to FP32 in memory
- **Date:** 2026-10-01
- **Amends:** [ADR 0007](0007-bundled-embeddings.md): the width comes from the index's own record rather than a setting, and bge-small is the default rather than the only embedder
- **Source:** [Choosing the embedding model once](../proposals/embedding-model-choice.md)
diff --git a/docs/adr/0038-chatgpt-plans-sign-in-through-openai-not-codex.md b/docs/adr/0038-chatgpt-plans-sign-in-through-openai-not-codex.md
index a02491ec8b..46f47dddf0 100644
--- a/docs/adr/0038-chatgpt-plans-sign-in-through-openai-not-codex.md
+++ b/docs/adr/0038-chatgpt-plans-sign-in-through-openai-not-codex.md
@@ -1,6 +1,6 @@
# ADR 0038: A ChatGPT plan is a connection signed in through OpenAI's open-source flow, not the Codex CLI's client
-- **Status:** Accepted; its request body, and the consequence that a plan model has no tools or structured output, superseded by [ADR 0050](0050-a-model-answers-on-its-own-route-with-its-connections-credential.md)
+- **Status:** Accepted; its request body, and the consequence that a plan model has no tools or structured output, superseded by [ADR 0050](0050-a-model-answers-on-its-own-route-with-its-connections-credential.md); its consequence that a Claude subscription is not offered superseded by [ADR 0057](0057-a-claude-plan-runs-through-the-users-own-claude-code.md)
- **Date:** 2026-10-02
- **Source:** [ChatGPT subscription proposal L22–34](https://github.com/MODSetter/SurfSense/blob/e9a1bd801fc9ac74a3c492fff40241701d6b9515/docs/proposals/chatgpt-subscription.md#L22-L34)
diff --git a/docs/adr/0047-premium-plugins-are-source-available.md b/docs/adr/0047-premium-plugins-are-source-available.md
index e661cbf5a2..69274e5204 100644
--- a/docs/adr/0047-premium-plugins-are-source-available.md
+++ b/docs/adr/0047-premium-plugins-are-source-available.md
@@ -1,6 +1,6 @@
# ADR 0047: SurfSense's paid plugins are source-available under the Business Source License and unlocked by the SurfSense license
-- **Status:** Accepted; where paid plugins live and that each is its own container would be superseded by [ADR 0054](0054-surfsenses-plugins-run-on-one-plugin-host.md), proposed
+- **Status:** Accepted; where paid plugins live and that each is its own container superseded by [ADR 0054](0054-surfsenses-plugins-run-on-one-plugin-host.md)
- **Date:** 2026-10-08
- **Supersedes:** [ADR 0025](0025-scraper-client-as-paid-plugin.md) in part: paid plugin source living in `plugins/` under Apache-2.0
- **Source:** [file-agent README L252](https://github.com/MODSetter/SurfSense/blob/2327494854e590f484bd61e3b5ff9d1fc4db70ce/docs/proposals/file-agent/README.md#L252), [file-agent strategy L171–187](https://github.com/MODSetter/SurfSense/blob/2327494854e590f484bd61e3b5ff9d1fc4db70ce/plans/community-local/file-agent-strategy.md#L171-L187), maintainer decision, 8 Oct 2026 (no permalink)
@@ -14,7 +14,7 @@ Plugins became MCP servers, remote ones first, hosted by their publishers ([ADR
## Decision
- SurfSense's paid plugins live in `plugins/proprietary/` under the Business Source License 1.1, each a container of its own that SurfSense deploys, holding everything it sells. Everything under `plugins/proprietary/` is under the Business Source License, including helpers that are Apache-2.0 in the backend today, such as the proxy, captcha and crawl code. All of it was written by SurfSense's maintainers, so SurfSense holds the rights to relicense it. The scrapers move there from `surfsense_backend/app/proprietary/`: copied now, and deleted from the backend once its scraper API no longer serves outside clients, never before the purge on 18 Oct 2026.
-- A paid plugin is unlocked by the SurfSense license key, a trial included, checked by the plugin's server on every call and never by the app ([plugins proposal](../proposals/plugins/core/05-paid.md)).
+- A paid plugin is unlocked by the SurfSense license key, a trial included, checked by the plugin's server on every call and never by the app ([plugin host](../architecture/plugin-host.md#the-license-check)).
- The first kind of paid plugin is remote: SurfSense hosts its server, and the user connects to it. A paid plugin that runs on the user's machine comes later, delivered by the license server only to a valid license, never in the installer or the public plugin list.
- Other publishers' paid plugins never use the SurfSense license. They charge on their own service and check access themselves.
- Everything else stays Apache-2.0: SurfSense's free plugins in `plugins/`, the app, its engines, the format skills, all job content, and job packs.
diff --git a/docs/adr/0050-a-model-answers-on-its-own-route-with-its-connections-credential.md b/docs/adr/0050-a-model-answers-on-its-own-route-with-its-connections-credential.md
index 7163471a4d..ee1e1cbfd1 100644
--- a/docs/adr/0050-a-model-answers-on-its-own-route-with-its-connections-credential.md
+++ b/docs/adr/0050-a-model-answers-on-its-own-route-with-its-connections-credential.md
@@ -1,6 +1,6 @@
# ADR 0050: A remote text model answers on the route its manifest entry records, with its connection's credential, and SurfSense owns a ChatGPT plan's sign-in for both engines
-- **Status:** Accepted
+- **Status:** Accepted; that SurfSense owns a plan's sign-in for both engines narrowed to the ChatGPT plan by [ADR 0057](0057-a-claude-plan-runs-through-the-users-own-claude-code.md)
- **Date:** 2026-10-07
- **Supersedes:** [ADR 0038](0038-chatgpt-plans-sign-in-through-openai-not-codex.md) in part: its request body and the consequence that a plan model has no tools or structured output
- **Source:** [OpenAI, Sign in with ChatGPT: preview limitations](https://developers.openai.com/siwc/token-sharing-open-source/preview-limitations)
diff --git a/docs/adr/0051-plugins-are-mcp-servers-behind-one-tool-gateway.md b/docs/adr/0051-plugins-are-mcp-servers-behind-one-tool-gateway.md
index 7e3cc8fd74..0c2f83e938 100644
--- a/docs/adr/0051-plugins-are-mcp-servers-behind-one-tool-gateway.md
+++ b/docs/adr/0051-plugins-are-mcp-servers-behind-one-tool-gateway.md
@@ -1,6 +1,6 @@
# ADR 0051: A plugin is an MCP server, and SurfSense decides which of its tools each caller gets, remote servers first
-- **Status:** Proposed, with the [plugins proposal](../proposals/plugins/README.md)
+- **Status:** Accepted
- **Date:** 2026-10-08
- **Source:** maintainer decision, 8 Oct 2026 (no permalink); [Pi's built-in MCP extension](https://github.com/earendil-works/pi/blob/ce950d78f424dcaf9f5d6a03ce80ab141130eb1d/packages/coding-agent/src/extensions/mcp/index.ts), [Pi's MCP client](https://github.com/earendil-works/pi/blob/ce950d78f424dcaf9f5d6a03ce80ab141130eb1d/packages/mcp/README.md)
@@ -32,4 +32,4 @@ The agent already calls SurfSense's own tools over MCP ([agent](../architecture/
## Where the code stands
-The MCP client's listing, the gateway's readiness and approval rules, the plugin tables and the `/plugins` routes are in `surfsense_local/backend/modules/plugins/`. Nothing yet gives the agent's opencode plugin servers or rules. The gateway's call path and a table of calls come with the chat engine. `modules/plugins/bundles/` holds the earlier design's runner, and `plugins/bundles/core/` its SDK, CLI and manifest rules.
+Built for remote servers ([plugins](../architecture/plugins.md)): the MCP client's listing, the gateway's readiness and approval rules, the plugin tables and the `/plugins` routes are in `surfsense_local/backend/modules/plugins/`, and `modules/agent/plugin_tools/` gives the agent's opencode the ready servers and their rules each turn ([agent](../architecture/agent.md#plugins)). There is no interface for sources yet, only the remote one. The gateway's call path and a table of calls come with the chat engine ([proposal](../proposals/plugins/README.md#the-chat-engines-router-step)). `modules/plugins/bundles/` holds the earlier design's runner, and `plugins/bundles/core/` its SDK, CLI and manifest rules.
diff --git a/docs/adr/0052-the-chat-model-never-calls-tools.md b/docs/adr/0052-the-chat-model-never-calls-tools.md
index ea41efe31c..671e50a8a3 100644
--- a/docs/adr/0052-the-chat-model-never-calls-tools.md
+++ b/docs/adr/0052-the-chat-model-never-calls-tools.md
@@ -1,6 +1,6 @@
# ADR 0052: The chat engine's model never calls tools; SurfSense chooses a plugin tool through structured output and calls it
-- **Status:** Proposed, with the [plugins proposal](../proposals/plugins/README.md); deferred until plugins reach the chat engine, after agent threads
+- **Status:** Proposed, with the [plugins proposal](../proposals/plugins/README.md#the-chat-engines-router-step); deferred until plugins reach the chat engine, after agent threads
- **Date:** 2026-10-08
- **Source:** maintainer decision, 8 Oct 2026 (no permalink); [agent: which engine a model gets](../proposals/agent/01-which-engine.md)
diff --git a/docs/adr/0053-plugins-are-listed-in-a-registry-and-third-party-ones-start-off.md b/docs/adr/0053-plugins-are-listed-in-a-registry-and-third-party-ones-start-off.md
index 9dd6e9e3b3..c56d9f2188 100644
--- a/docs/adr/0053-plugins-are-listed-in-a-registry-and-third-party-ones-start-off.md
+++ b/docs/adr/0053-plugins-are-listed-in-a-registry-and-third-party-ones-start-off.md
@@ -1,6 +1,6 @@
# ADR 0053: First-party plugins are listed in the app, third-party plugins in a registry in this repository, and third-party plugins start off
-- **Status:** Proposed, with the [plugins proposal](../proposals/plugins/README.md)
+- **Status:** Accepted; verifying an entry by its domain superseded by [ADR 0055](0055-verified-is-a-maintainers-judgement-and-ci-contacts-each-server.md)
- **Date:** 2026-10-08
- **Source:** maintainer decision, 8 Oct 2026 (no permalink); [obsidianmd/obsidian-releases](https://github.com/obsidianmd/obsidian-releases), [Obsidian plugin security](https://obsidian.md/help/Extending+Obsidian/Plugin+security)
@@ -15,11 +15,11 @@ Obsidian keeps its community plugins as one list, served from its own server wit
- SurfSense's plugins are listed in a first-party list packaged into the app, shown with no network and no consent. It changes with app releases. It holds entries only, a few hundred bytes each, never plugin code, bundle files or version lists.
- Every third-party plugin is listed in one file, `plugins/registry/plugins.json` in this repository. An entry is added by pull request and reviewed once by a maintainer. A change to the server behind it needs no review.
- Neither list holds plugins: a plugin's code, and later its bundle files, stay with its publisher, in its own host or repository.
-- Both lists share one entry schema with a `kind`, `remote` now, with `bundle` reserved. The publisher is set by the list: `surfsense` only in the first-party list, `partner` or `community` in the registry. `auth: license` and `access: license` appear only in the first-party list, so a fetched file can never claim to be SurfSense or receive the license key. `CODEOWNERS` requires a maintainer's approval for every change to the registry.
+- Both lists share one entry schema with a `kind`, `remote` now, with `bundle` reserved. Who publishes is set by the list: SurfSense by being in the first-party list, and in the registry `verification`, `verified` for a plugin proven by its domain to be run by its author or `community`, set in review. `auth: license` and `access: license` appear only in the first-party list, so a fetched file can never claim to be SurfSense or receive the license key. `CODEOWNERS` requires a maintainer's approval for every change to the registry.
- Every plugin not published by SurfSense, a custom one added by URL included, is third-party and off until the user allows third-party plugins once. The switch plays the part of Obsidian's Restricted mode, named for what it allows.
-- A tool a server adds after the user connected, or one that changes as the proposal's rule says, starts off until the user turns it on ([tools added later](../proposals/plugins/core/02-registry.md#tools-added-later)).
+- A tool a server adds after the user connected, or one whose description changes or whose annotations become less safe, starts off until the user turns it on ([tools added later](../architecture/plugins.md#ready-tools)).
- A maintainer delists a registry plugin by moving its entry to `removed` with a reason; apps stop offering its tools.
-- On each merge to `main`, CI signs the registry and publishes it to SurfSense's own plugin host. The app fetches it when the user allows third-party plugins, after consent, checks the signature against a key compiled in, and caches it. No installer carries it, so the registry grows and shrinks without app updates.
+- On each merge to `dev`, CI signs the registry, and it is published to SurfSense's own plugin host with the next release to `main`. The app fetches it when the user allows third-party plugins, after consent, checks the signature against a key compiled in, and caches it. No installer carries it, so the registry grows and shrinks without app updates.
## Consequences
@@ -30,4 +30,4 @@ Obsidian keeps its community plugins as one list, served from its own server wit
## Where the code stands
-`plugins/registry/` and the first-party list do not exist, and the app has no copy of the registry, third-party switch or plugin screen. The only plugin interface is a "Plugins / Coming soon" button in [`dashboard-page.tsx`](../../surfsense_local/frontend/src/features/dashboard/dashboard-page.tsx).
+Built: the registry, its check and signing in `plugins/registry/`, the first-party list packaged into the app, the app's signed, cached copy, the third-party switch and Settings → Plugins ([plugin registry](../architecture/plugin-registry.md), [plugins](../architecture/plugins.md)). The registry is served from `plugins.surfsense.com`.
diff --git a/docs/adr/0054-surfsenses-plugins-run-on-one-plugin-host.md b/docs/adr/0054-surfsenses-plugins-run-on-one-plugin-host.md
index 4c4b105b9b..d2cbfb2da8 100644
--- a/docs/adr/0054-surfsenses-plugins-run-on-one-plugin-host.md
+++ b/docs/adr/0054-surfsenses-plugins-run-on-one-plugin-host.md
@@ -1,6 +1,6 @@
# ADR 0054: SurfSense's own plugins, free and paid, run on one plugin host, with the registry served beside it
-- **Status:** Proposed, with the [plugins proposal](../proposals/plugins/README.md)
+- **Status:** Accepted
- **Date:** 2026-10-10
- **Supersedes:** [ADR 0047](0047-premium-plugins-are-source-available.md) in part: paid plugins living in `plugins/proprietary/`, each a container of its own
- **Source:** design discussion, 10 Oct 2026 (no permalink)
@@ -33,4 +33,5 @@ SurfSense publishes its plugins as remote MCP servers and hosts them itself ([AD
## Where the code stands
-- No plugin host or `plugins/registry/` exists. `plugins/remote/` holds only `proprietary/LICENSE`, which the root `LICENSE` names. The earlier design's tooling is in `plugins/bundles/`.
+- Built ([plugin host](../architecture/plugin-host.md)): the host, stateless MCP, Stop through `/cancel` and Redis, the license check, ten scraper plugins in `plugins/remote/proprietary/`, the image, and the registry's container, both deployed at `plugins.surfsense.com`.
+- Not built ([proposal](../proposals/plugins/README.md#long-calls-as-jobs-on-the-plugin-host)): jobs and their object storage, rate limits per license and per IP address, license state in Redis, and the scale rule.
diff --git a/docs/adr/0055-verified-is-a-maintainers-judgement-and-ci-contacts-each-server.md b/docs/adr/0055-verified-is-a-maintainers-judgement-and-ci-contacts-each-server.md
new file mode 100644
index 0000000000..800c0c1e77
--- /dev/null
+++ b/docs/adr/0055-verified-is-a-maintainers-judgement-and-ci-contacts-each-server.md
@@ -0,0 +1,25 @@
+# ADR 0055: Verified is a maintainer's judgement, and CI contacts each server a pull request adds
+
+- **Status:** Accepted
+- **Date:** 2026-10-14
+- **Supersedes:** [ADR 0053](0053-plugins-are-listed-in-a-registry-and-third-party-ones-start-off.md) in part: a verified entry proven by its `domain`
+- **Source:** maintainer decision, 14 Oct 2026 (no permalink); [#2249](https://github.com/MODSetter/SurfSense/pull/2249)
+
+## Context
+
+A `verified` registry entry had to name a `domain` its publisher proved they own, by a file, a DNS record or the MCP Registry, and the check required the server's hosts to sit on it. The check never fetched the proof: a maintainer did that by hand, so the rule only checked that the entry agreed with itself. DeepWiki was marked verified without a `domain`, the check failed, and the sign job stopped before 2.2.1's list was signed; with no proof published by its author, it became `community` ([#2249](https://github.com/MODSetter/SurfSense/pull/2249)). Meanwhile nothing checked what review actually needed: that the server answers MCP, lists the tools its entry names, and signs in on the hosts the user is asked about.
+
+## Decision
+
+- `verified` is set by a maintainer when they are satisfied, on any evidence, that `author` runs the server. The entry carries no `domain`, and no check reads one.
+- Only a maintainer's approval merges a registry change (CODEOWNERS), so only a maintainer sets or keeps `verified`.
+- On every pull request, CI connects to each entry it adds or changes with the MCP client the app uses, and fails it when the app could not use it: unreachable, not MCP over Streamable HTTP, sign-in asked where `auth` says none, tools that differ from `tools`, or OAuth that cannot be found, registered with, or reached on the listed `hosts`.
+- What MCP leaves optional, such as a tool's safety hints, only warns, so any server the app could use passes.
+- The sign job never contacts servers: a third-party server that is down must not hold up the list.
+
+## Consequences
+
+- The checkmark means SurfSense's maintainers vouch for who runs the server, not that a proof was published; maintainers can verify a well-known publisher that will never add a file or a record.
+- A broken listing is caught before merge, not when a maintainer or a user connects it.
+- CI holds no credentials, so a server behind a key or an account is checked for reachability and sign-in, not its tools; the maintainer still connects it.
+- A pull request's check depends on the third party's server being up at that moment; rerunning it is the fix.
diff --git a/docs/adr/0056-speech-recognition-runs-on-a-second-audiocpp-server.md b/docs/adr/0056-speech-recognition-runs-on-a-second-audiocpp-server.md
new file mode 100644
index 0000000000..49eec3d4d2
--- /dev/null
+++ b/docs/adr/0056-speech-recognition-runs-on-a-second-audiocpp-server.md
@@ -0,0 +1,33 @@
+# ADR 0056: Speech recognition runs on a second audio.cpp server that only the API calls
+
+- **Status:** Accepted
+- **Date:** 2026-10-14
+- **Source:** [Local speech recognition proposal, the runtime L119–147](https://github.com/MODSetter/SurfSense/blob/55b3ddc2e9d6423f194ff7e5ef0c62910cece95f/docs/proposals/local-speech-to-text.md#L119-L147)
+
+## Context
+
+Recordings as sources and a mic in the chat composer need a speech recogniser that runs on the user's CPU and takes as little of the machine as it can. The app already ships audio.cpp's `audiocpp_server` for podcast voices ([packaging](../architecture/packaging.md)), and its pinned v0.8.2 source has NVIDIA's Nemotron 3.5 ASR, which held 1.19 GB loaded and ran at 11.5× real time on that server's CPU path. Compiling its family adds 9 source files and no new library.
+
+The voices' server cannot also serve it. Under its flags a dictation would evict Kokoro between a podcast's turns; a request that finds the other model busy fails at once with 503; the podcast's closing `unload_all_models` would unload a warm speech model and wait without bound on a transcription in flight; and every voice install, delete and API start rewrites `server.json`, which restarts the server and kills whatever it is transcribing.
+
+Text models already reach their runtime only through the API, so one gate can order every request ([ADR 0048](0048-the-api-is-the-only-path-to-a-text-model.md)). A recording's windows from the ingest worker and a speaker's phrases from the composer compete for one model the same way.
+
+## Decision
+
+- Speech recognition runs on `audiocpp_server`, the binary staged for voices, compiled with `nemotron_asr` beside the voice families and started a second time as its own sidecar, `audiocpp-transcription`.
+- It reads its own config, `/audio/transcription.json`, which the API writes, and listens on its own port. Electron passes the flags: the CPU backend, half the logical cores up to 8, one loaded model, an unload after 5 idle minutes, 1 GiB kept free, no web UI. It gets no eSpeak.
+- Electron starts it when that file exists, restarts it when the file changes and stops it when the file goes. After a crash it restarts it, at most once every 10 seconds, and leaves it stopped after 3 crashes in 5 minutes, until the file changes again.
+- Only the API is told where it is. Every other process reaches speech recognition through the API.
+- One gate in the API sends every request to it, one at a time, since it runs one model and one request at a time anyway. A dictation's phrase goes ahead of a recording's window and waits at most for the window in flight. While a dictation is open, from the mic press until its last phrase or 20 s without one, no window starts.
+- A hold of the text runtime, which a podcast takes while it voices and a local image while it draws, also starts no window until it ends, waits for the request in flight, and unloads the speech model by id. Dictation stays available during it, behind the memory check.
+- Before a request that would load the model, the API checks the entry's measured peak plus 1 GiB against the memory the operating system says is available, unless the server says the model is loaded. Speech never unloads or holds the chat model; a shortfall is refused in a sentence.
+- [transcribe.cpp](https://github.com/handy-computer/transcribe.cpp) is the fallback, replacing the API's client, the sidecar spec and the config writer, not the routes.
+
+## Consequences
+
+- Voices and speech recognition never unload, evict, restart or wait on each other. The second process costs 7 MiB idle and 10 MiB after its model is unloaded (measured).
+- A dictation does not wait for a recording behind the server's own queue, because the API decides the order. A recording pauses while someone dictates, and while a podcast voices or an image draws.
+- The server's own memory guard, which estimates from the file's size, is only a backstop: the app admits from a measured peak.
+- The languages are those of the families in the pin. audio.cpp will not run Whisper, so the long tail of languages needs a later model or the fallback.
+- One binary serves both, so a pin bump is measured for both: the voices' peaks and the speech model's.
+- Settings › Resources counts the second process under its audio.cpp row with no change, since it counts every `audiocpp_server`.
diff --git a/docs/adr/0057-a-claude-plan-runs-through-the-users-own-claude-code.md b/docs/adr/0057-a-claude-plan-runs-through-the-users-own-claude-code.md
new file mode 100644
index 0000000000..d36274a97f
--- /dev/null
+++ b/docs/adr/0057-a-claude-plan-runs-through-the-users-own-claude-code.md
@@ -0,0 +1,36 @@
+# ADR 0057: A Claude plan is reached through the user's own Claude Code, and SurfSense holds no Claude credential
+
+- **Status:** Accepted
+- **Date:** 2026-10-16
+- **Supersedes:** [ADR 0038](0038-chatgpt-plans-sign-in-through-openai-not-codex.md) in part: its consequence that a Claude subscription is not offered. [ADR 0050](0050-a-model-answers-on-its-own-route-with-its-connections-credential.md) in part: that SurfSense owns a plan's sign-in for both engines, which now holds for the ChatGPT plan only
+- **Source:** [Use your Claude plan proposal, decisions and Anthropic's terms L59–84](https://github.com/MODSetter/SurfSense/blob/e1332966dd2ec2cfcf636331da6f770d836fcff1/docs/proposals/claude-plan/README.md#L59-L84)
+
+## Context
+
+ADR 0038 left Claude reachable only with an API key, because Anthropic forbids a third-party app to sign users in to Claude. That still holds: "Anthropic does not permit third-party developers to offer Claude.ai login into their own applications", and developers "may not collect, store, or intermediate Claude.ai credentials or session tokens" ([legal and compliance](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use)). opencode removed its own Claude sign-in "per legal requests" ([anomalyco/opencode#18186](https://github.com/anomalyco/opencode/pull/18186)), and Hermes Agent, which calls the API with Claude Code's credentials, draws only on extra-usage credits, not the plan ([Hermes providers](https://hermes-agent.nousresearch.com/docs/integrations/providers)).
+
+The same terms do not prevent "an end user from signing in to the unmodified Claude Code binary with their own Claude subscription", and Anthropic's support page, updated 7 Oct 2026, says the Agent SDK and third-party apps can use a plan's limits ([support](https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan)). OpenClaw runs the user's installed Claude Code this way and never touches its tokens ([OpenClaw](https://docs.openclaw.ai/providers/anthropic)).
+
+A Claude plan has no HTTP endpoint SurfSense may call, so opencode cannot reach it through the model endpoint as it reaches a ChatGPT plan (ADR 0050): Claude Code runs its own agent loop.
+
+## Decision
+
+- A Claude plan is offered as a `claude_plan` connection that drives the user's own installed, unmodified Claude Code through Anthropic's Agent SDK. Claude Code keeps the sign-in, in the user's own config directory. SurfSense runs no sign-in flow, stores no Claude credential, never reads Claude Code's, and never uses `claude setup-token`.
+- SurfSense ships, downloads and updates no Claude Code. The SDK's bundled copy is excluded from the build, and every run names the user's binary in `cli_path`.
+- Chat runs Claude Code with no tools and one turn, behind the `Generator` protocol, so [ADR 0052](0052-the-chat-model-never-calls-tools.md) holds.
+- The agent runs Claude Code as a second engine beside opencode, behind an engine interface, with SurfSense's tools and plugins over MCP and Claude Code's file tools confined to the thread folder. SurfSense's permission rules decide every tool call in a `PreToolUse` hook, with `can_use_tool` as a second gate and `permission_mode="default"` forced.
+- Claude Code's network traffic goes through SurfSense's proxy, which tunnels only `api.anthropic.com` once the user allows it ([ADR 0027](0027-egress-consent-per-host.md)).
+- The feature is called "Claude plan" and never "Claude Code".
+
+## Consequences
+
+- Claude Pro, Max and Team subscribers use their plan in chat and in the agent. The user must install Claude Code and sign in to it themselves; SurfSense can only say how.
+- Usage shares the plan's limits with the user's own Claude Code use.
+- The agent gains a second engine to maintain. A thread that switches models across engines carries a handover note, and each engine keeps its own session.
+- SurfSense depends on a binary it does not control and that updates itself. Claude Code 2.1.301 already changed its default permission mode to `auto`, which skips `can_use_tool`; the forced mode, the hook and a contract test against the real binary cover that kind of change.
+- Anthropic has changed this policy several times in 2026 and may enforce "without prior notice". Withdrawing the feature is removing the `claude_plan` connection kind and the Claude engine; nothing else depends on them.
+- The API key connection stays, and Max and Team plans' monthly API credits keep working through it.
+
+## Where the code stands
+
+Not built. The work is laid out in the [Use your Claude plan](../proposals/claude-plan/README.md) proposal.
diff --git a/docs/adr/0058-the-bundled-embedder-runs-at-fp32-on-x86-64.md b/docs/adr/0058-the-bundled-embedder-runs-at-fp32-on-x86-64.md
new file mode 100644
index 0000000000..c2e3843709
--- /dev/null
+++ b/docs/adr/0058-the-bundled-embedder-runs-at-fp32-on-x86-64.md
@@ -0,0 +1,35 @@
+# ADR 0058: The bundled embedder runs at FP32 on x86-64, upcast in memory, and its vector space is still the pinned FP16 file
+
+- **Status:** Accepted
+- **Date:** 2026-10-16
+- **Amends:** [ADR 0036](0036-the-index-records-its-embedder.md): its consequence that the bundled file's "FP16 weights stay" now holds for the file on disk and what the index records, not for the precision onnxruntime computes in, which on x86-64 is FP32
+- **Source:** measured on a Ryzen 7 5800X under Windows with onnxruntime 1.29.0, recorded in [embedding](../architecture/embedding.md#precision)
+
+## Context
+
+ADR 0036 pins the bundled bge-small file, `model_optimized.onnx`, by its sha256 and records it in every index's spec, so the file can never change without a re-embed. It stores its weights as FLOAT16. On x86-64, onnxruntime's CPU provider runs its 36 MatMuls in FP16 through a slow kernel, 83% of kernel time on a batch: it embedded 2.04 chunk-sized passages a second at 8 threads and 1.86 at one. No session option, optimisation level or config key in onnxruntime 1.29 changes that kernel. The same weights at FP32 ran 11.6 a second.
+
+Every FP16 value is exactly an FP32 value, so upcasting the weights changes none of them; only the arithmetic becomes more precise.
+
+## Decision
+
+- On an x86-64 build, `sysconfig.get_platform()` ending in `amd64` or `x86_64`, the encoder reads the bundled file, checks its sha256 against the pin, rewrites every FLOAT16 initializer, Constant tensor, Cast target and declared type as FLOAT in memory, and opens the session from those bytes ([`encoder.py`](../../surfsense_local/backend/modules/embedding/encoder.py)).
+- Nothing on disk changes: not the pack, the pin in [`bundled.py`](../../surfsense_local/backend/modules/embedding/bundled.py), `fetch_embedding_model.py`, migration `0022`, the index's recorded spec or its vector table. The pinned file still defines the vector space; the runtime executes it at higher precision, which the index does not record.
+- Only the bundled file is upcast, matched by repo, revision and pinned weights. Curated and Hugging Face embedders run as downloaded, since their FP16 builds were never measured upcast.
+- The rewrite is stdlib and numpy over the protobuf wire format ([`fp32_upcast.py`](../../surfsense_local/backend/modules/embedding/fp32_upcast.py)), because the frozen app has no `onnx` package. It refuses what it cannot rewrite whole, such as a subgraph, a sparse or external tensor, or an FP16 type attribute other than Cast's `to`. A hash mismatch, a refusal or an onnxruntime error logs one warning and opens the file as shipped.
+- `SURFSENSE_LOCAL_EMBED_PRECISION` is the escape hatch: `auto` by default, `fp16` for the file as shipped, `fp32` to upcast on any architecture. A value it cannot read means `fp16`.
+- arm64 stays FP16 until the upcast is measured there. Apple silicon already runs the FP16 file at 14.5 chunk-sized passages a second.
+- **Rejected: shipping an FP32 file.** It adds 2.0 MB to the installer under LZMA but 66 MB installed, needs a second pinned hash, and every existing index records the FP16 file's hash, so it would also need a decision on whether the recorded spec changes. It can come with the next change to the pack.
+- **Rejected: adding the `onnx` package** to convert at load. The wire rewrite adds no dependency, and its model's output is bit-identical to an `onnx`-converted file's.
+
+## Consequences
+
+- Embedding on x86-64 is 5.7 times as fast at 8 threads (2.04 to 11.6 passages a second; 6.2 times through the shipped encoder), 4.1 times at 4 threads and 2.2 times at 1. On the ingest research's A/B set of 6 documents, it alone cut the time to ingest them by 27%, from 500.6 s to 364.6 s.
+- Vectors barely move: cosine at least 0.99999958 against the FP16 run, and the retrieval eval's answer at the same rank on 266 of 266 queries. Nothing is re-embedded; a library built at FP16 keeps its vectors, and what x86-64 embeds later sits beside them.
+- Each process that embeds (`api`, `worker-ingest`, `worker-studio`) loads the model about 0.22 s slower once, holds about 52 MB more after the load, and peaks about 290 MB higher while it loads.
+- The encoder clears onnxruntime's private `InferenceSession._model_bytes`, which would hold another 126 MB, so an onnxruntime upgrade is checked against a unit test that fails if the session keeps the bytes.
+- If the pinned file ever changes, its upcast is measured again before it ships.
+
+## Where the code stands
+
+- Measured on one machine. Linux x64, a 4-core laptop, and the x64 build under emulation on Windows on ARM, which `auto` upcasts by its build, are not measured.
diff --git a/docs/adr/0059-a-born-digital-pdf-is-read-from-its-text-layer.md b/docs/adr/0059-a-born-digital-pdf-is-read-from-its-text-layer.md
new file mode 100644
index 0000000000..53b2e7f66d
--- /dev/null
+++ b/docs/adr/0059-a-born-digital-pdf-is-read-from-its-text-layer.md
@@ -0,0 +1,38 @@
+# ADR 0059: A born-digital PDF is read from its own text layer, and Docling reads only a PDF with a page that needs it
+
+- **Status:** Accepted
+- **Date:** 2026-10-16
+- **Source:** measured on a Ryzen 7 5800X under Windows, with the embedder at FP32 ([ADR 0058](0058-the-bundled-embedder-runs-at-fp32-on-x86-64.md)), recorded in [documents](../architecture/documents.md#pdfs-with-a-text-layer)
+
+## Context
+
+Every PDF went to Docling with OCR and table structure on: about 3.0 s and 32 CPU-s a page, up to 5.5 s on table pages, plus 11 to 17 s to import Docling and load its models in a worker's first job. A 46-page budget took two minutes to be searchable, and the one ingest thread ([ADR 0008](0008-two-job-queues.md)) held every upload behind it. A born-digital PDF already holds its text, and pypdfium2, which the worker ships, reads it in milliseconds a page. On born-digital budget pages Docling's OCR changed nothing: with it off, the Markdown was byte-identical.
+
+Docling's Markdown was not the better text for this app either. In an answer-quality A/B, Basic mode, on 50 questions over five born-digital City of Boston PDFs (four budget volumes and a standard contract) and a Word contract, with four models and retrieval through the app's own chunker and hybrid search, answers from the text layer scored 140/200 = 0.70 strict and from Docling's Markdown 124/200 = 0.62. The gap was in table cells, 48/92 against 35/92; prose and contract clauses tied. Docling makes every bold label a `##` heading and the chunker splits at headings first, so a table's passage was cut off from the program or department it belongs to, and its padded pipe tables split one table into three or more passages that named nothing. The answer was in the top 5 passages for 41 questions against 38.
+
+What the text layer cannot see: a page that is a picture, such as a scan or a cover photo; a scanner's invisible OCR layer, which may be wrong; garbage from a broken font map; a turned page, whose lines pdfium returns last to first; and a filled form's answers, which live in its fields, not on the page.
+
+## Decision
+
+- A `.pdf` is read from its text layer when every page passes a triage that takes milliseconds: a page of running text (`ok:text`), a page with under 20 characters and almost no image (`ok:sparse`), an empty page (`ok:blank`), or at least 20 characters over images covering most of the page, as on a section cover (`mixed:big-image`). At least one page must be `ok:text`. The rules are in [documents](../architecture/documents.md#pdfs-with-a-text-layer), and the code in [`pdf_text_layer/`](../../surfsense_local/backend/worker/ingestion/pdf_text_layer/).
+- Any other page, a turned page, a filled form field, an empty text layer or a pdfium error sends the whole file to Docling, which reads it as before and keeps its figures. The choice is made for the whole document, for safety in a release: no source's text is spliced from two readers, and a PDF that needs Docling anywhere reads exactly as it did before. Splicing Docling's pages in by page range can follow once it is measured. Amended: a PDF with some failed pages, at most half of them, now has only those pages read by Docling, by page range, and the rest from its text layer, while a scan, a filled form, a file pdfium cannot read and one with no `ok:text` page still go to Docling whole ([documents](../architecture/documents.md#pdfs-with-a-text-layer)).
+- The text is the A/B's text arm: each page's text as pdfium lays it out, and pages a blank line apart. Three changes: a word hyphenated at a line's end is joined again, control codes are dropped, and a blank line is added where a paragraph ends, found from the gaps between its lines, so the chunker cuts there and not at an arbitrary line. Only `\n` is added; the A/B's characters are kept as they were. It has no Markdown headings and no pipe tables.
+- Ingest keeps no figures for a text-read PDF. By the maintainer's decision they come from the figures-only pass when the agent or Studio first asks for them. That pass reads born-digital pages with Docling's layout model alone, about 6 CPU-s a page against 32 for a full conversion, and gives way to waiting sources between page runs. For a PDF of at most 40 pages whose pages hold image objects, ingest also queues the pass at the lowest priority, so it runs once nothing else waits.
+- `SURFSENSE_LOCAL_PDF_TEXT_LAYER=off` sends every PDF to Docling, as before. A value it cannot read also means off.
+- **Rejected: a Docling pass afterwards** over pages that have a text layer, to replace their text with Docling's. Docling's text answered worse, and the pass would double the CPU and change chunk ids under existing citations.
+- **Rejected: LiteParse** as the reader. Its tables were the best of the fast tools, but it gave empty Markdown on a turned page and 76 false code blocks in 30 pages, dropped a column, bundles a second PDFium, and downloads Tesseract data by default.
+- **Rejected: TableFormer's FAST mode** to speed Docling up. It got 26 of 53 rows right on a 53-row table.
+- **Rejected: Docling's classic PDF backend.** It was 1.3 to 1.55 times as fast, but lost the heading "Program 2. Valuation" from a budget.
+
+## Consequences
+
+- Measured to `ready` through the upload route and the pipeline, Docling warm: a 12-page executive summary 16.7 s to 1.4 s, a 46-page operating budget 125.6 s to 9.0 s, and a 33-page LaTeX spec 73.1 s to 3.8 s, 12 to 19 times as fast; CPU fell from 176, 1,327 and 863 CPU-s to 10, 63 and 29. Reading and triage take about 7 ms a page; chunking and embedding take the rest. Reading such a PDF never imports Docling, so its ingest also skips the cold start and about 1 GB of memory; only a figures-only pass loads Docling, after the file is ready.
+- 32 of the 36 PDFs measured are read from their text layer. Of the 19 Boston budget PDFs, two go to Docling for a cover photo whose title is under 20 characters, and one for a raster org chart whose page holds only its running header. A filled form goes to Docling too.
+- Through the shipped code, on the A/B's questions with Gemma 3 4B, Qwen3.5-9B and Haiku 4.5, the text layer scored 105/150 = 0.70 strict against Docling's 90/150 = 0.60, and 35/69 table cells against 24/69. The second corpus was six PDFs: a two-column paper, a deck exported to PDF, the spec and three reports. There it scored 52/69 against 54/69: better on charts (6/9 against 2/9), level on slides, and worse on tables (19/30 against 27/30). Most of that loss is one paper's Table 1, whose caption and header the chunker cut from its rows; the rest is a table whose rows begin with a number.
+- Content that exists only as pixels on a page with running text is not in the text. That covers a raster chart or org chart under 65% of the page, and a picture under a cover's title. Docling's Markdown had lost these as well. A wrong ToUnicode map gives fluent but wrong letters, which are indexed as they are; no reader tested caught it.
+- Docling reads only the runs of pages that need it, when they are at most half the file; a scan, a filled form and a file pdfium cannot read are still read whole, so a scan gains only the embedder's speed-up.
+- A text-read PDF's figures arrive after it is ready. Until the pass has run, the agent hears that its images are being extracted, and Studio's first draft places none from it.
+
+## Where the code stands
+
+- Measured on one machine, and the stress PDFs are generated. The table loss on the second corpus was in chunking and line geometry, not the triage. Paragraph breaks keep Table 1's caption, header and rows in one passage, and put the answer in the top 5 passages for 22 of 23 questions there (20 before) and 44 of 50 on Boston (41). Measured again, answers scored 0.83 to Docling's 0.78 on the second corpus, table cells 28 of 30 to 27, and 0.70 to 0.60 on Boston (Gemma 3 4B and Qwen3.5 9B), where Gemma lost three table cells to passages that now pack across a page break. Rows that begin with a number are still open.
diff --git a/docs/adr/README.md b/docs/adr/README.md
index 1cdc3a0e17..6590436506 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -70,15 +70,20 @@ The Date is the day the decision was first written down. Source links are permal
| 0033 | [Every candidate is scored on its own cosine, because a chunk the vector leg did not reach is unmeasured rather than unrelated](0033-every-candidate-is-scored-on-its-own-cosine.md) | Accepted |
| 0034 | [Whether a chat model reads images is llama.cpp's own answer for a local model and the catalog's for a remote one, and it is stored nowhere](0034-vision-is-the-runtimes-answer-stored-nowhere.md) | Accepted |
| 0035 | [The desktop app's stack also ships as one Docker container, with Caddy as its only listener](0035-docker-compose-runs-the-desktop-stack.md) | Accepted |
-| 0036 | [The index records which embedder built it, and the embedder is fixed once per install](0036-the-index-records-its-embedder.md) | Accepted |
+| 0036 | [The index records which embedder built it, and the embedder is fixed once per install](0036-the-index-records-its-embedder.md) | Accepted, amended by 0058 |
| 0037 | [Curated embedders are a fourth engine in the local catalog, and embedding is a type, never a slot](0037-embedding-is-a-type-not-a-slot.md) | Accepted |
-| 0038 | [A ChatGPT plan is a connection signed in through OpenAI's open-source flow, not the Codex CLI's client](0038-chatgpt-plans-sign-in-through-openai-not-codex.md) | Accepted, its request body superseded by 0050 |
+| 0038 | [A ChatGPT plan is a connection signed in through OpenAI's open-source flow, not the Codex CLI's client](0038-chatgpt-plans-sign-in-through-openai-not-codex.md) | Accepted, its request body superseded by 0050, its Claude consequence by 0057 |
| 0039 | [Model-written document scripts run without approval in SurfSense's script runner, and the agent's general shell is off](0039-document-scripts-run-without-approval.md) | Accepted |
-| 0047 | [SurfSense's paid plugins are source-available under the Business Source License and unlocked by the SurfSense license](0047-premium-plugins-are-source-available.md) | Accepted, in part to be superseded by 0054 |
+| 0047 | [SurfSense's paid plugins are source-available under the Business Source License and unlocked by the SurfSense license](0047-premium-plugins-are-source-available.md) | Accepted, in part superseded by 0054 |
| 0048 | [The API is the only path to a text model, so one gate can count everything that generates](0048-the-api-is-the-only-path-to-a-text-model.md) | Accepted |
| 0049 | [A prompt changes only at its end, so the cached start of it is never read again](0049-prompts-grow-at-the-end.md) | Accepted |
-| 0050 | [A remote text model answers on the route its manifest entry records, with its connection's credential, and SurfSense owns a ChatGPT plan's sign-in for both engines](0050-a-model-answers-on-its-own-route-with-its-connections-credential.md) | Accepted |
-| 0051 | [A plugin is an MCP server, and SurfSense decides which of its tools each caller gets, remote servers first](0051-plugins-are-mcp-servers-behind-one-tool-gateway.md) | Proposed |
+| 0050 | [A remote text model answers on the route its manifest entry records, with its connection's credential, and SurfSense owns a ChatGPT plan's sign-in for both engines](0050-a-model-answers-on-its-own-route-with-its-connections-credential.md) | Accepted, in part narrowed by 0057 |
+| 0051 | [A plugin is an MCP server, and SurfSense decides which of its tools each caller gets, remote servers first](0051-plugins-are-mcp-servers-behind-one-tool-gateway.md) | Accepted |
| 0052 | [The chat engine's model never calls tools; SurfSense chooses a plugin tool through structured output and calls it](0052-the-chat-model-never-calls-tools.md) | Proposed, deferred |
-| 0053 | [First-party plugins are listed in the app, third-party plugins in a registry in this repository, and third-party plugins start off](0053-plugins-are-listed-in-a-registry-and-third-party-ones-start-off.md) | Proposed |
-| 0054 | [SurfSense's own plugins, free and paid, run on one plugin host, with the registry served beside it](0054-surfsenses-plugins-run-on-one-plugin-host.md) | Proposed |
+| 0053 | [First-party plugins are listed in the app, third-party plugins in a registry in this repository, and third-party plugins start off](0053-plugins-are-listed-in-a-registry-and-third-party-ones-start-off.md) | Accepted, in part superseded by 0055 |
+| 0054 | [SurfSense's own plugins, free and paid, run on one plugin host, with the registry served beside it](0054-surfsenses-plugins-run-on-one-plugin-host.md) | Accepted |
+| 0055 | [Verified is a maintainer's judgement, and CI contacts each server a pull request adds](0055-verified-is-a-maintainers-judgement-and-ci-contacts-each-server.md) | Accepted |
+| 0056 | [Speech recognition runs on a second audio.cpp server that only the API calls](0056-speech-recognition-runs-on-a-second-audiocpp-server.md) | Accepted |
+| 0057 | [A Claude plan is reached through the user's own Claude Code, and SurfSense holds no Claude credential](0057-a-claude-plan-runs-through-the-users-own-claude-code.md) | Accepted |
+| 0058 | [The bundled embedder runs at FP32 on x86-64, upcast in memory, and its vector space is still the pinned FP16 file](0058-the-bundled-embedder-runs-at-fp32-on-x86-64.md) | Accepted |
+| 0059 | [A born-digital PDF is read from its own text layer, and Docling reads only a PDF with a page that needs it](0059-a-born-digital-pdf-is-read-from-its-text-layer.md) | Accepted |
diff --git a/docs/architecture/about.md b/docs/architecture/about.md
index f60f4fdf09..971bc2ceb1 100644
--- a/docs/architecture/about.md
+++ b/docs/architecture/about.md
@@ -19,5 +19,5 @@ Outside the desktop app there is no bridge: the version and release notes rows a
## Known gaps
-- No third-party license notices ship with the app. Generating them needs the frontend's npm dependencies, the Python sidecars' packages and the bundled native binaries collected at build time.
+- The app does not show its third-party license notices. They are generated at build time and ship as `resources/licenses/THIRD_PARTY_NOTICES.json` and `.txt` ([packaging](packaging.md#third-party-notices)), but nothing in Settings › About opens them yet.
- The app writes no log files, so there is no logs folder to open. This run's output is kept in memory and shown in Report issue ([issue reports](issue-reports.md)), and is gone once the app quits.
diff --git a/docs/architecture/agent.md b/docs/architecture/agent.md
index 6416f88a91..de475f08a7 100644
--- a/docs/architecture/agent.md
+++ b/docs/architecture/agent.md
@@ -32,9 +32,9 @@ A new thread is the agent's when it is opened in Agentic mode: the mode the user
## From a thread to a running opencode
1. **Electron, at boot.** Where an opencode is staged, Electron picks its port, a random password and a random Word snapshot key, passes them to the API alone, and removes any `agent/opencode.json` the last run left. opencode does not start yet. Where none is staged, Electron passes nothing, and the API mounts none of the routes opencode calls or that answer it: the model endpoint, the tool endpoint, the permission answer and the Word snapshot routes.
-2. **The API, when a thread first needs the agent.** `ready_opencode()` reads the selected model's window: what llama-server loaded a local model with, what a ChatGPT plan states for its model, or the `context` the remote catalog records, else 32,768. It reads the route the model answers on. It reads whether the model reads images ([Image input](#image-input)). It writes `/agent/opencode.json`, readable by this user only, and only when its contents change.
-3. **Electron, on that file.** Checked every 2 seconds: opencode starts once the file exists and stops when it goes. A crash restarts it, at most once every 10 seconds. A rewrite does not restart it.
-4. **The API, until opencode serves it.** opencode reads its configuration per folder, the first time the folder is used, and keeps what it read. So the API checks the running server's version is `1.18.34`, reads `GET /config`, and calls `POST /global/dispose` once if what is loaded is not the provider package, launch key, window and image input just written. It returns when they match, or after 60 seconds with "not ready". Every call has a timeout, because opencode accepts a connection a moment before it answers it.
+2. **The API, when a thread first needs the agent.** `ready_opencode()` reads the selected model's window: what llama-server loaded a local model with, what a ChatGPT plan states for its model, or the `context` the remote catalog records, else 32,768. It reads the route the model answers on. It reads whether the model reads images ([Image input](#image-input)). It writes `/agent/opencode.json`, readable by this user only, and only when its contents change. When Electron gave up on opencode after the file's last write, a turn or a new session touches the file instead, since an unchanged file would never earn opencode another start. Reading a thread back does not, so the read that follows a failed turn cannot start it a second time.
+3. **Electron, on that file.** Checked every 2 seconds: opencode starts once the file exists and stops when it goes. A rewrite does not restart it. A crash restarts it after 10 seconds, then 20 ([`opencode-restarts.ts`](../../surfsense_local/electron/src/main/sidecars/opencode-restarts.ts)), a run of 30 seconds or more counting as no failure. After three quick exits in a row Electron stops, writes the reason to `opencode-failed.json` in the agent folder and raises the "The agent stopped" toast once; the next write of the configuration earns one more start. A child that cannot be spawned at all counts as an exit.
+4. **The API, until opencode serves it.** opencode reads its configuration per folder, the first time the folder is used, and keeps what it read. So the API checks the running server's version is `1.18.34`, reads `GET /config`, and calls `POST /global/dispose` once if what is loaded is not the provider package, launch key, window and image input just written. It returns when they match, or after 60 seconds with "not ready", or at once with Electron's reason when `opencode-failed.json` is newer than the configuration it just wrote ([`opencode_failure.py`](../../surfsense_local/backend/modules/agent/opencode_failure.py)). Every call has a timeout, because opencode accepts a connection a moment before it answers it.
## The configuration
@@ -94,7 +94,7 @@ When the entry declares image input, an image a tool returns reaches the model:
Each agent thread works in its own folder ([`thread_folder/`](../../surfsense_local/backend/modules/agent/thread_folder/)). Its session is created there, and its turns sync, register the tools, stream, check status and answer approvals there. `sources/` holds only the sources the thread's scope resolves to ([the ticked sources](#the-ticked-sources)), each at the path of its folder in the Library, so the agent finds a source where the user filed it; an unfiled artifact ticked by id sits at the top. Paths are decided from the workspace's live folders and the source's own row, never the scope, so a tick never moves another source ([`mirror_path.py`](../../surfsense_local/backend/modules/agent/thread_folder/mirror_path.py)): characters a file system refuses become `_`, leading and trailing dots and spaces go (a leading dot would hide a folder from `grep` and `glob`), a name is cut at 100 characters, a Windows device name, an instruction file's name, `outputs` (so no mirrored folder passes for the agent's on macOS, where the edit rules match case as written but the disk ignores it) or a top-level `figures` or `pages` gains `_`, siblings equal once made safe get ` [f]`, and a folder whose directory would pass 247 characters, or leave under 24 for a file within 259, has the deepest uncut folder of its chain cut to 8 characters and its id, so a folder is always one directory. A title is then cut to fit; past that the file goes to the top as `<8 characters> [].md`, and one that fits nowhere is left out with a warning. The `[]` always stays. Lengths are counted in UTF-16 units, as Windows counts them, so an emoji counts two. Figures, pages and previews have names that cannot be cut, so each is checked against the same 259 and 247 ([`path_budget.py`](../../surfsense_local/backend/modules/agent/thread_folder/path_budget.py)): where the thread's folder sits too deep for one, a figure's line says it has no copy to open, `surfsense_source_pages` is refused, and a render gets no previews, each in a sentence. A file's partial copy is never named longer than the file. A file `agent/.git` beside the workspace's threads names no repository ([`repo_fence.py`](../../surfsense_local/backend/modules/agent/thread_folder/repo_fence.py)): opencode takes the nearest `.git` above a thread's folder as its project and every folder in the project as the agent's own, so a git repository above the data folder, such as a home folder kept in git, would open every thread's and workspace's folder to it. git fails on this one, so opencode finds no repository, as when none is there. `ready_opencode()` writes it for every workspace before opencode can open any of its folders, so reading a thread's history is fenced too, and only where a `.git` lies above the workspace's agent folder, so opencode runs no git for a user without a repository. The user's own repository is not fenced off: `git status -uall` still lists the threads' files and `git add -A` adds them.
-Before each turn the folder is brought in line in short transactions, never writing a file inside one ([`sync.py`](../../surfsense_local/backend/modules/agent/thread_folder/sync.py)): the layout and the sources with text in one, then their text 200 at a time. Each text is written once to `text/` under its digest and hard-linked into the thread, so threads share one file per version and Defender scans it once; on a disk without hard links (FAT, exFAT) each thread gets a copy, logged once. Anything else in `sources/`, file, folder, link or junction, is removed without following links, empty folders are pruned, and figures and pages of sources no longer in scope go. Anything under `outputs/` named like an instruction file is renamed with a trailing `_`. At most every 10 minutes, cache files older than 10 minutes that no thread links are deleted. Syncs of one thread are serialised; two threads syncing one changed source at once may leave one a turn behind. The first sync in each workspace per run removes the folders every thread once shared, `agent/sources/` and `agent/outputs/previews/` (the rest of `agent/outputs/` stays until the workspace goes), and any `threads//` that is no agent thread of the workspace.
+Before each turn the folder is brought in line in short transactions, never writing a file inside one ([`sync.py`](../../surfsense_local/backend/modules/agent/thread_folder/sync.py)): the layout and the sources with text in one, then their text 200 at a time. A source's text is its `content` as ingest wrote it. For a file Docling read, that is Docling's Markdown. A PDF read from its [text layer](documents.md#pdfs-with-a-text-layer) is plain lines instead, its pages a blank line apart, with no Markdown headings or pipe tables, so each table row there is one line of values. Each text is written once to `text/` under its digest and hard-linked into the thread, so threads share one file per version and Defender scans it once; on a disk without hard links (FAT, exFAT) each thread gets a copy, logged once. Anything else in `sources/`, file, folder, link or junction, is removed without following links, empty folders are pruned, and figures and pages of sources no longer in scope go. Anything under `outputs/` named like an instruction file is renamed with a trailing `_`. At most every 10 minutes, cache files older than 10 minutes that no thread links are deleted. Syncs of one thread are serialised; two threads syncing one changed source at once may leave one a turn behind. The first sync in each workspace per run removes the folders every thread once shared, `agent/sources/` and `agent/outputs/previews/` (the rest of `agent/outputs/` stays until the workspace goes), and any `threads//` that is no agent thread of the workspace.
Deleting a thread aborts and deletes its session, disposes its opencode instance with `POST /instance/dispose?directory=`, deletes the row (its artifacts stay), then removes the folder once any sync of it has finished; a turn that reaches its sync after that ends its stream with an error frame and writes nothing.
@@ -162,7 +162,7 @@ The script, not the file, is what a version keeps, so the next edit reads it wit
## Analyses
-[`analyze_data.py`](../../surfsense_local/backend/modules/agent/tool_endpoint/analyze_data.py) answers a question about a table with arithmetic the model would otherwise do in its head. It checks each source against the turn's ticks, takes its original file ([`source_files.py`](../../surfsense_local/backend/modules/agent/data_analysis/source_files.py)), commits, and queues the run on the Studio worker, where the runner gives the script read-only copies and keeps what it saves ([studio](studio.md#analysis-runs)). The source's own file is only ever copied. The call waits up to 190 seconds, as a render does:
+[`analyze_data.py`](../../surfsense_local/backend/modules/agent/tool_endpoint/analyze_data.py) answers a question about a table with arithmetic the model would otherwise do in its head. It checks each source against the turn's ticks, takes its original file ([`source_files.py`](../../surfsense_local/backend/modules/agent/data_analysis/source_files.py)), commits, and queues the run on the Studio worker, where the runner gives the script read-only copies and keeps what it saves ([studio](studio.md#script-documents)). The source's own file is only ever copied. The call waits up to 190 seconds, as a render does:
- **Done.** The result starts `Analysis run : `, then what the script printed, its first 6,000 characters and 200 lines with a note to print less when cut; each of up to five saved `.csv` or `.tsv` tables, while they fit in 24,000 bytes, as `, R rows and C columns:` and its first 20 rows and 8 columns in Markdown, read with the `csv` module since the API ships no pandas ([`table_preview.py`](../../surfsense_local/backend/modules/agent/data_analysis/table_preview.py)); each `.png` chart's name, `analysis--`, with its path; the first six charts as images when the model reads images, else a line saying it cannot; the other files kept and the first 20 not kept, with a count of the rest; and `outputs/analysis//`. These caps hold the result under opencode's cut of 50 KB and 2,000 lines, counted in bytes, so a table of Chinese text does not spill into opencode's shared tool-output folder.
- **Failed.** A tool error with the error line, cut at 2,000 characters, the traceback's last lines that fit in 6,000, what it printed first, "Fix the script and run it again." and the stop rule. Analyses keep their own count of a turn's failed runs ([`failed_renders.py`](../../surfsense_local/backend/modules/agent/tool_endpoint/failed_renders.py)): after three, a further analysis is refused with "3 analysis runs failed in this request, so no more analysis runs run until the user's next message.", while renders and revisions keep theirs.
@@ -201,13 +201,13 @@ The snapshot routes, mounted only beside an opencode ([`previews/router.py`](../
Each but `file` refuses a caller with an `Origin` header, and one without `Authorization: Bearer `, the key Electron made at boot and passed to the API as `SURFSENSE_LOCAL_DOCX_SNAPSHOT_KEY` ([`snapshot_key.py`](../../surfsense_local/backend/modules/agent/previews/snapshot_key.py)), because any process on the machine can reach loopback. The `file` route is fetched by the print window itself, which sends an Origin and holds no key, as it fetches an artifact's file: the request id, a random 128-bit value only the key's holder is handed, lets it in, and only while that request is being printed.
-Electron's side ([`docx-snapshot/`](../../surfsense_local/electron/src/main/docx-snapshot/)) takes the next request at once after taking one, prints up to three at a time, gives each print 25 seconds, under the API's 30, and each call to the API 10. A request names its format, `docx` or `pptx`, and its pages as `printToPDF` page ranges; one without them is a Word file's pages 1 to 4, and a format or range it cannot print is reported as the request's failure without printing. A PDF the API refuses is reported as the request's failure. The page lays a Word file out as above, and a deck with @aiden0z/pptx-renderer, the in-app viewer's library: every slide on a page of the deck's own slide size, read from the file, printed with no margin, once each slide's pictures, backgrounds and charts are drawn ([`features/docx-snapshot/`](../../surfsense_local/frontend/src/features/docx-snapshot/)). The document is untrusted, since a source can steer the script that wrote it, so the hidden window runs sandboxed with no preload, in its own in-memory session whose requests may reach only the page's own files and object URLs and the one file being printed, and it never navigates. The page's policy allows only its own script, inline styles, data-URL and object-URL images, data-URL fonts, and connections to loopback; docx-preview renders no altChunk, whose HTML it would put in an unsandboxed frame, and the deck renderer gets no pdf.js, which would start a worker. A page whose script did not start is reported as such, with the window's first console error.
+Electron's side ([`docx-snapshot/`](../../surfsense_local/electron/src/main/docx-snapshot/)) takes the next request at once after taking one, prints up to three at a time, gives each print 25 seconds, under the API's 30, and each call to the API 10. A request names its format, `docx` or `pptx`, and its pages as `printToPDF` page ranges; one without them is a Word file's pages 1 to 4, and a format or range it cannot print is reported as the request's failure without printing. A PDF the API refuses is reported as the request's failure. The page lays a Word file out as above, and a deck with @aiden0z/pptx-renderer, the in-app viewer's library: every slide on a page of the deck's own slide size, read from the file, printed with no margin, once each slide's pictures, backgrounds and charts are drawn ([`features/docx-snapshot/`](../../surfsense_local/frontend/src/features/docx-snapshot/)). The document is untrusted, since a source can steer the script that wrote it, so the hidden window runs sandboxed with no preload, in its own in-memory session whose requests may reach only the page's own files and object URLs and the one file being printed, and which refuses it every permission, the microphone and the clipboard among them; it never navigates. The page's policy allows only its own script, inline styles, data-URL and object-URL images, data-URL fonts, and connections to loopback; docx-preview renders no altChunk, whose HTML it would put in an unsandboxed frame, and the deck renderer gets no pdf.js, which would start a worker. A page whose script did not start is reported as such, with the window's first console error.
## A turn
The thread routes in [chat](chat.md#agent-threads) hand an agent thread to `agent_threads/`:
-1. The turn's sources are stored and resolved ([the ticked sources](#the-ticked-sources)) and opencode is made ready. A legacy thread is then refused ([folders](#folders)), and so is a model that cannot run the agent. The stream then opens with `agent-preparing`, `{count}`, the number of sources the folder will hold, since a big scope's first sync takes about 30 seconds; the screen shows "Preparing 1,200 sources…" until the next frame. The thread's folder is brought in line inside the stream, and a sync that fails ends it with an error frame. The tools are then added to the thread's opencode instance at the thread's address, and the ready plugins with the session's rules for them ([plugins](#plugins)).
+1. The turn's sources are stored and resolved ([the ticked sources](#the-ticked-sources)) and opencode is made ready. A legacy thread is then refused ([folders](#folders)), and so is a model that cannot run the agent. The stream then opens with `agent-preparing`, `{count}`, the number of sources the folder will hold, since a big scope's first sync takes about 30 seconds; the screen shows "Preparing 1,200 sources…" until the next frame. The thread's folder is brought in line inside the stream, and a sync that fails ends it with an error frame. While it syncs, the tools are added to the thread's opencode instance at the thread's address, and the ready plugins with the session's rules for them ([plugins](#plugins)), so neither waits on the other. One log line per turn says where the wait went, in milliseconds per stage.
2. The thread folder's event stream is opened before the message is sent, so no event of the turn is missed. The message goes with `prompt_async`, naming the selected model, so a thread keeps working after a model change, and with the scope note beside it unless the thread's scope is every source.
3. Events for the thread's session become the chat's frames, plus `agent-step`, `permission-request` and `permission-replied` ([`turn_frames.py`](../../surfsense_local/backend/modules/agent/agent_threads/turn_frames.py)). Each answer part after the first opens a new paragraph; a part's leading line breaks and a part of breaks only never stream, and its trailing breaks are held until text follows, so parts join with one blank line, as the stored reply's stripped parts do ([`replies.py`](../../surfsense_local/backend/modules/agent/agent_threads/replies.py)). Breaks inside a part, a code block's among them, stream as written. A completed `surfsense_render_document` or `surfsense_revise_document` step carries `artifact: {id, title, version, created}`, read from its result's first line, which only a ready version gets; it is null while the call runs and when the script failed ([`steps.py`](../../surfsense_local/backend/modules/agent/agent_threads/steps.py)). A version not ready in time has none in the turn's frames; reading the thread back fills it from the result's `Queued artifact` line once that version is ready ([`ready_renders.py`](../../surfsense_local/backend/modules/agent/agent_threads/ready_renders.py)). `created` is true when no earlier version of the same root has made its file, so a document whose v1 failed is "Created" at v2; it is looked up in the database for the stream and on read-back, and a step sent while the database is locked goes without it and gets it on read-back. The screen then reads "Created *Title* v1" or "Updated *Title* to v2", whatever the format, or "Revised *Title* v2" for a revised copy ("Edited a copy of a source file" until it has a version), and a click opens that version in Studio. A step carries no attachment: the page images opencode keeps on a render's or a source-pages step never reach the thread, live or read back. A completed `surfsense_convert_document` step carries the same `artifact` from its first line, without `created`, and reads "Converted *Title* to PDF", opening the PDF on a click; while it runs, or when it was refused, "Converted a document to PDF". A `surfsense_source_pages` step reads "Looked at pages of *Title*", the title taken from the turn's scope, else, for a turn on every source or past 200, from the first line of the step's result, or "Looked at pages of a source" while the step runs. Text parts of one turn on either side of a tool call are kept as separate paragraphs, live and stored.
4. A stream silent for 20 seconds, twice opencode's heartbeat, is reopened, and so is one that drops; after a reopen, an idle session ends the turn. Twenty failures in a row end it with an error. Any other error once the stream is open, such as the database failing during the sync, ends it with an error frame of kind `unknown` and `[DONE]`.
@@ -231,28 +231,29 @@ The agent has no shell, so it asks for no command. A plugin's tool whose rule is
## Plugins
-A connected plugin is an MCP server opencode calls itself, with SurfSense deciding what it may do there ([proposal](../proposals/plugins/core/03-engines.md#opencode)). Before each turn, after SurfSense's own tools, [`agent_threads/turn.py`](../../surfsense_local/backend/modules/agent/agent_threads/turn.py) runs four steps from [`plugin_tools/`](../../surfsense_local/backend/modules/agent/plugin_tools/):
+A connected plugin is an MCP server opencode calls itself, with SurfSense deciding what it may do there ([plugins](plugins.md)). Before each turn, beside the folder's sync and in a database session of its own, [`agent_threads/turn.py`](../../surfsense_local/backend/modules/agent/agent_threads/turn.py) runs five steps from [`plugin_tools/`](../../surfsense_local/backend/modules/agent/plugin_tools/). SurfSense's own tools are given at the same time, and not given again while the instance holds them connected as they were given. When opencode cannot say which servers it holds, the turn fails rather than run under the last turn's rules, which may still allow a tool switched off since on a server it holds; a rule denying a server it cannot name would deny opencode's own tools too.
1. **Sign-ins.** Each ready plugin signed in with OAuth whose access token expires within 30 minutes, longer than any turn runs, is refreshed ([plugins](plugins.md#signing-in-with-oauth)), so opencode never holds an expired token mid-reply. One whose refresh is refused is no longer ready, and is named unavailable with `reason` `sign_in` ([`refreshed_sign_ins.py`](../../surfsense_local/backend/modules/agent/plugin_tools/refreshed_sign_ins.py)).
-2. **Changed tools.** Each ready plugin last checked more than five minutes ago, connecting included, is listed again with SurfSense's own MCP client and the check recorded ([plugins](plugins.md#checks)), so a tool its server added starts off and one whose description changed or became less safe is switched off before the model sees it ([`changed_tools.py`](../../surfsense_local/backend/modules/agent/plugin_tools/changed_tools.py)). A server that cannot be listed keeps what was recorded, and its check says why; an OAuth plugin's `401` is refreshed and listed once more first.
-3. **The servers.** The plugins the gateway calls ready, with their tools up to 24, the plugins connected first winning, are given to the thread's opencode under their ids, with its sign-in as an `Authorization` header, a pasted key or an OAuth access token from its encrypted credential, or the license key for SurfSense's own ([plugins](plugins.md#surfsenses-own-plugins)), and opencode's own OAuth off. A plugin on SurfSense's plugin host also gets `X-SurfSense-Calls`, a token for the thread derived from the install's secret, so Stop can end its calls there: the host keeps no MCP session, so opencode's own cancel cannot reach them, and Stop calls the host's `POST /cancel` with the token and the license ([`stop_plugin_calls.py`](../../surfsense_local/backend/modules/agent/plugin_tools/stop_plugin_calls.py), [plugin host](plugin-host.md#stop)). No other server is sent the token ([`turn_plugins.py`](../../surfsense_local/backend/modules/agent/plugin_tools/turn_plugins.py)). A server opencode already holds connected, with the same URL and headers, is not given again, since that opens a new MCP session and a stateful plugin loses its state; a refreshed token changes the header, so its plugin is given again. Only a digest of what was given is kept ([`given_servers.py`](../../surfsense_local/backend/modules/agent/plugin_tools/given_servers.py)). A held server no longer ready, disconnected, switched off, removed from the registry, signed out, or a third-party plugin once third-party plugins are refused, is disconnected, and with it the key opencode held.
-4. **The rules.** The session's permission rules are replaced (`PATCH /session/{id}`): `_*: deny` for every plugin server opencode holds, then `allow` or `ask` for each ready tool from the gateway's approval rules. A denied tool is left out of what the model is offered, and an `ask` reaches the thread as a `permission-request` naming the tool, `notes_count` ([approvals](#approvals)). opencode names a tool `_`, turning anything but letters, digits, `_` and `-` into `_`; a custom plugin is never given the id `surfsense`, SurfSense's own server.
+2. **The license.** When the last verdict is stale, the plugin host is asked whether the license unlocks the paid plugins, once for all of them ([plugins](plugins.md#the-license-verdict)); a plugin it newly holds out is named unavailable with `reason` `license`.
+3. **Changed tools.** Each ready plugin last checked more than five minutes ago, connecting included, is listed again with SurfSense's own MCP client and the check recorded ([plugins](plugins.md#checks)), so a tool its server added starts off and one whose description changed or became less safe is switched off before the model sees it ([`changed_tools.py`](../../surfsense_local/backend/modules/agent/plugin_tools/changed_tools.py)). A server that cannot be listed keeps what was recorded, and its check says why; an OAuth plugin's `401` is refreshed and listed once more first. The turn waits on the listings at most 3 seconds: one still running then is recorded when it ends, and the turn keeps what was recorded, as for a server that cannot be listed. A turn waits on a check already running rather than starting another. After the reply, the plugins last checked more than 200 seconds ago are listed in the background, so the next turn rarely waits; checks follow the user's turns, never a timer.
+4. **The servers.** The plugins the gateway calls ready, with their tools up to 24, the plugins connected first winning, are given to the thread's opencode under their ids, all at once while the check runs, each within 25 seconds, so a server that hangs is named unreachable alone and the turn waits for the slowest rather than the sum. A plugin the check made ready or not ready is given or taken back once it ends. Each goes with its sign-in as an `Authorization` header, a pasted key or an OAuth access token from its encrypted credential, or the license key for SurfSense's own ([plugins](plugins.md#surfsenses-own-plugins)), and opencode's own OAuth off, with the 200-second call timeout SurfSense's own tools get, or 300 seconds on SurfSense's plugin host, where a scrape can run minutes ([`give_plugins.py`](../../surfsense_local/backend/modules/agent/plugin_tools/give_plugins.py)). A plugin on SurfSense's plugin host also gets `X-SurfSense-Calls`, a token for the thread derived from the install's secret, so Stop can end its calls there: the host keeps no MCP session, so opencode's own cancel cannot reach them, and Stop calls the host's `POST /cancel` with the token and the license ([`stop_plugin_calls.py`](../../surfsense_local/backend/modules/agent/plugin_tools/stop_plugin_calls.py), [plugin host](plugin-host.md#stop)). It also gets `x-surfsense-text-only: 1`, so the host puts a call's items in the text, the only part opencode reads. When opencode gives up on such a call, its step ending in `MCP error -32001`, the app calls the host's `POST /cancel` as Stop does, once no other call of the turn runs there, since the host ends all of the thread's calls at once. No other server is sent the token or the header ([`turn_plugins.py`](../../surfsense_local/backend/modules/agent/plugin_tools/turn_plugins.py)). A server opencode already holds connected, with the same URL and headers, is not given again, since that opens a new MCP session and a stateful plugin loses its state; a refreshed token changes the header, so its plugin is given again. Only a digest of what was given is kept ([`given_servers.py`](../../surfsense_local/backend/modules/agent/plugin_tools/given_servers.py)). A held server no longer ready, disconnected, switched off, removed from the registry, signed out, or a third-party plugin once third-party plugins are refused, is disconnected, and with it the key opencode held.
+5. **The rules.** Once the check ends, so a tool it switched off is denied before the model is asked, the session's permission rules are replaced (`PATCH /session/{id}`), even when a plugin could not be given: `_*: deny` for every plugin server opencode holds, then `allow` or `ask` for each ready tool from the gateway's approval rules. When they cannot be set while opencode holds any plugin server, the turn fails rather than run under the last turn's rules. A denied tool is left out of what the model is offered, and an `ask` reaches the thread as a `permission-request` naming the tool, `notes_count` ([approvals](#approvals)). opencode names a tool `_`, turning anything but letters, digits, `_` and `-` into `_`; a custom plugin is never given the id `surfsense`, SurfSense's own server.
**`@` mentions.** Typing `@` in an Agentic composer lists the ready plugins, each named with its host, through assistant-ui's composer trigger popover ([`plugin-mentions.tsx`](../../surfsense_local/frontend/src/features/plugins/in-thread/plugin-mentions.tsx)); with none ready it says why: none connected, third-party plugins refused, or all switched off, which it says to turn on from **+ › Plugins** ([plugins](plugins.md#settings--plugins)). Picking one inserts `@notes`, the plugin's id: the composer is a textarea, so the library's `:plugin[label]{name=id}` would show as written. A plugin is named rather than a tool, since the user thinks in apps and the agent picks a plugin's tools better from their descriptions. Before the turn, each `@` in the message that is a ready plugin's is the user choosing it ([`mentions.py`](../../surfsense_local/backend/modules/agent/plugin_tools/mentions.py)): the turn gives every ready tool of it, even past the 24, and a second synthetic note beside the scope note says "The user asked you to use the Notes plugin for this." The rest of the turn's tools stay offered, a tool switched off stays hidden, a plugin that is not ready changes nothing, and approval is unchanged. Reading a thread back drops every synthetic part from the user's words, so neither note shows.
-A ready plugin opencode then reports as `failed`, or as needing sign-in, and an OAuth plugin whose refresh was refused, is named in `agent-plugins-unavailable`, `{plugins: [{title, reason}]}`, `reason` `unreachable` or `sign_in`, and the turn goes on without it. The reply shows "Couldn’t reach Notes" or "Sign in to Notes again" above its steps ([`plugins-unavailable.tsx`](../../surfsense_local/frontend/src/features/agent/plugins-unavailable.tsx)); the notice is live only, and a reopened thread does not show it. A turn whose plugins cannot be given, because opencode failed to answer, goes on without plugins.
+A ready plugin opencode then reports as `failed`, or as needing sign-in, and an OAuth plugin whose refresh was refused, is named in `agent-plugins-unavailable`, `{plugins: [{title, reason}]}`, `reason` `unreachable`, `sign_in`, or `license` for a paid plugin, whose refusal is its license rather than a sign-in, and the turn goes on without it. The reply shows "Couldn’t reach Notes", "Sign in to Notes again" or "Amazon needs a SurfSense license" above its steps ([`plugins-unavailable.tsx`](../../surfsense_local/frontend/src/features/agent/plugins-unavailable.tsx)); the notice is live only, and a reopened thread does not show it. A server that fails to connect, or takes over 25 seconds, is named `unreachable`, and the turn goes on with the others.
The calls, their arguments and results are kept in the thread's opencode session, as every other step is. A plugin step reads "Notes: Search notes", the plugin's name and the tool's title, matched from opencode's `_` against the connected plugins; once its plugin is disconnected it reads "Used notes_search". Opened, it shows the whole result, read from the session by `GET /chat/threads/{thread_id}/steps/{step_id}/result` ([`stored_step.py`](../../surfsense_local/backend/modules/agent/agent_threads/stored_step.py)), since a step's frames carry its first 4,000 characters. **Save to Sources** keeps it as a note through `POST /chat/threads/{thread_id}/plugin-steps/{step_id}/save`: titled "Notes: search", filed at the top of the Library, with `document_metadata` naming `plugin_id`, `tool`, `thread_id`, `step_id`, `fetched_at` and `plugin_call`, a digest of the plugin, tool and inputs. The same call saved again updates its note, and answers `200` rather than `201` ([`saved_results.py`](../../surfsense_local/backend/modules/agent/plugin_tools/saved_results.py)). Only a connected plugin's finished step is saved; SurfSense's own steps are refused with `409`, their results being written for the model ([`plugin-step-line.tsx`](../../surfsense_local/frontend/src/features/plugins/in-thread/plugin-step-line.tsx)).
## Network
-opencode needs no host beyond loopback but a ready plugin's: its model is the model endpoint, its tools are its own and SurfSense's, on the API's port, and a plugin's server is reached through the API's plugin proxy. Its environment is built from a few system variables rather than inherited. It sets `OPENCODE_DISABLE_MODELS_FETCH`, `OPENCODE_DISABLE_SHARE`, `OPENCODE_DISABLE_LSP_DOWNLOAD`, `OPENCODE_DISABLE_AUTOUPDATE`, `OPENCODE_DISABLE_DEFAULT_PLUGINS`, `OPENCODE_PURE`, `OPENCODE_DISABLE_PROJECT_CONFIG`, `OPENCODE_DISABLE_EXTERNAL_SKILLS` and `OPENCODE_DISABLE_CLAUDE_CODE`. Its proxy variables point at the plugin proxy, with loopback exempted. Electron picks its port beside opencode's and gives it to the API as `SURFSENSE_LOCAL_PLUGIN_PROXY_PORT`, and the API runs it while it runs ([`plugin_proxy/`](../../surfsense_local/backend/modules/agent/plugin_proxy/)). It passes only `CONNECT` to a ready plugin's host, on its URL's port or 443 for a host its entry declares, read on each connection and recorded as that host's last call; anything else gets `403`, so a fetch this misses fails rather than leaving the machine, as it did when the variables pointed at `127.0.0.1:9`, where they still point when no port was given. The tunnel carries opencode's TLS untouched, so SurfSense sees the host and never the content, and a remote plugin must be on HTTPS: one added by an `http://` URL off this computer is refused when connected. The npm install opencode would otherwise run at startup is skipped: its config folder holds an empty `node_modules` and a lockfile naming the package. ripgrep is the shipped one, first on its `PATH`. Settings › Network gains nothing: opencode is never a destination, a remote model's host is checked as its connection's, and a plugin's as the plugin's.
+opencode needs no host beyond loopback but a ready plugin's: its model is the model endpoint, its tools are its own and SurfSense's, on the API's port, and a plugin's server is reached through the API's plugin proxy. Its environment is built from a few system variables rather than inherited. It sets `OPENCODE_DISABLE_MODELS_FETCH`, `OPENCODE_DISABLE_SHARE`, `OPENCODE_DISABLE_LSP_DOWNLOAD`, `OPENCODE_DISABLE_AUTOUPDATE`, `OPENCODE_DISABLE_DEFAULT_PLUGINS`, `OPENCODE_PURE`, `OPENCODE_DISABLE_PROJECT_CONFIG`, `OPENCODE_DISABLE_EXTERNAL_SKILLS` and `OPENCODE_DISABLE_CLAUDE_CODE`. Its proxy variables point at the plugin proxy, with loopback exempted. Electron picks its port beside opencode's and gives it to the API as `SURFSENSE_LOCAL_PLUGIN_PROXY_PORT`, and the API runs it while it runs ([`plugin_proxy/`](../../surfsense_local/backend/modules/agent/plugin_proxy/)). It passes only `CONNECT` to a ready plugin's host, on its URL's port or 443 for a host its entry declares; anything else gets `403`. The ready hosts are kept in memory, so a connection takes no database write lock, and read again once a commit changes a row readiness reads, and at least every 2 seconds, since a registry's delisting is a file. A host passed is recorded as that host's last call at most once a minute, off the connection's path. A host that does not accept within 10 seconds, a connect that fails in any other way, or a check that fails, is answered `502` rather than closed unanswered, which a client would retry blindly. On asyncio's own loop, which the API runs on Windows and in tests, a host's next address is tried 0.25 s after the last; uvloop, which it runs on macOS and Linux, has no such option, so there the connect takes the default. A fetch this misses therefore fails rather than leaving the machine, as it did when the variables pointed at `127.0.0.1:9`, where they still point when no port was given. The tunnel carries opencode's TLS untouched, so SurfSense sees the host and never the content, and a remote plugin must be on HTTPS: one added by an `http://` URL off this computer is refused when connected. The npm install opencode would otherwise run at startup is skipped: its config folder holds an empty `node_modules` and a lockfile naming the package. ripgrep is the shipped one, first on its `PATH`. Settings › Network gains nothing: opencode is never a destination, a remote model's host is checked as its connection's, and a plugin's as the plugin's.
A document script is another matter: it runs in the Studio worker's runner, which gives it an environment of a few system variables and no secrets but does not stop it opening connections ([ADR 0039](../adr/0039-document-scripts-run-without-approval.md), [studio](studio.md#script-documents)).
## Tests
-- **Plugins:** `tests/integration/agent/test_plugin_tools.py` connects a plugin server on loopback and runs turns through the real opencode: the agent calls a tool on the server, a blocked tool is not offered, a tool that may destroy asks, two turns keep one MCP session, a disconnected plugin leaves the thread's opencode, a key reaches the server, SurfSense's own plugin gets the license key with no third-party consent, Stop tells SurfSense's plugin host to end the thread's calls and sends no third party the token, a server that is down is named, a changed description is blocked before the turn, a step's whole result is read from its session, saving a plugin step twice keeps one note, SurfSense's own steps are not saved, a mentioned plugin's tools are given past the limit and the plugin named to the agent, a mention never unblocks a tool, a mention of a plugin that is not ready changes nothing, a token expiring within half an hour is refreshed and the plugin given again before the model is asked, an unchanged token is neither refreshed nor given again, and a refused refresh leaves the plugin out of the turn and names it. `test_plugin_proxy.py` speaks `CONNECT` to the proxy, and runs a turn whose plugin is on HTTPS at `localhost`, which opencode there reaches only through the proxy; refusing every connection there names the plugin unreachable.
+- **Plugins:** `tests/integration/agent/test_plugin_tools.py` connects a plugin server on loopback and runs turns through the real opencode: the agent calls a tool on the server, a blocked tool is not offered, a tool that may destroy asks, two turns keep one MCP session, a disconnected plugin leaves the thread's opencode, a key reaches the server, SurfSense's own plugin gets the license key with no third-party consent, Stop tells SurfSense's plugin host to end the thread's calls and sends no third party the token, a server that is down is named, a changed description is blocked before the turn, a step's whole result is read from its session, saving a plugin step twice keeps one note, SurfSense's own steps are not saved, a mentioned plugin's tools are given past the limit and the plugin named to the agent, a mention never unblocks a tool, a mention of a plugin that is not ready changes nothing, a token expiring within half an hour is refreshed and the plugin given again before the model is asked, an unchanged token is neither refreshed nor given again, and a refused refresh leaves the plugin out of the turn and names it. `test_plugin_proxy.py` speaks `CONNECT` to the proxy, and runs a turn whose plugin is on HTTPS at `localhost`, which opencode there reaches only through the proxy; refusing every connection there names the plugin unreachable. It also checks that a plugin made ready is tunnelled to from its next connection, a delisting reaches the proxy once its hosts are old, a host that cannot be reached or a check that fails is answered, and a host's call is recorded at most once a minute. `test_turn_preparation.py` gives plugins at once and names one that hangs alone, sets the rules even when a plugin cannot be given, fails a turn whose rules cannot be set or whose servers opencode cannot list, takes back before the model a plugin its check made not ready, checks plugins after a turn for the next, ends on SurfSense's plugin host a call opencode gave up on, and gives SurfSense's tools again only once opencode forgets them. `test_changed_tools.py` holds a turn to the check's deadline and lists after a turn only plugins checked a while ago; `unit/agent/test_give_plugins.py` covers giving, timeouts and the cancels.
- **Backend:** [`tests/integration/agent/`](../../surfsense_local/backend/tests/integration/agent/) and [`tests/unit/agent/`](../../surfsense_local/backend/tests/unit/agent/). The client, readiness and thread tests start the staged opencode against a scripted model, and the thread tests also run the real API on a port. The tool endpoint tests drive the app in-process, as opencode's MCP client calls it. They skip where `pnpm build:opencode` has not staged a build. Each builds the app as Electron starts it beside an opencode; `test_routes_without_opencode.py` builds it without one. `test_document_flow.py` plays a whole document flow through the tool endpoint with no model: a logo's figure, Word v1 with the logo and a chart, its script read back, v2, and a PDF whose previews come back as images. `test_office_documents.py` renders decks, workbooks and documents made from a source's template through the real runner, with Electron played on the snapshot queue for a deck's slides, `test_source_pages.py` draws sources' pages with Electron played on the snapshot queue and LibreOffice by a stand-in (`tests/office_stand_in.py`; every test starts with Office support off), `test_convert_document.py` converts versions and sources through the stand-in with Studio's worker on a thread, `integration/office_support/test_libreoffice.py` (marker `office`) converts a Word file and recalculates a workbook on the LibreOffice installed on the machine, named explicitly, or skips, `test_documents_skill.py` and `test_data_skill.py` run the skills' examples through the real runner, `test_analyze_data.py` runs analyses on the Studio worker played on a thread and places a chart in a Word file, and `test_compacted_turns.py` reads compacted sessions back. `test_tool_scope.py` holds each tool to a turn's ticked sources in-process, `test_thread_folders.py` lays out, links and sweeps thread folders, `test_thread_isolation.py` runs two threads in the real opencode and checks neither reads, `grep`s nor `glob`s the other's sources, `unit/agent/test_mirror_path.py` checks the mirror's names and length cuts, and `test_agent_scope.py` sends ticked, empty and omitted selections, a ticked folder past 1,000 sources and a thread's stored scope through the real opencode. `test_pdf_tools.py` calls the PDF tools in-process over uploaded PDFs and artifacts, `test_pdf_skill.py` checks the PDF skill against the tools and the range parser, [`tests/unit/pdf_tools/`](../../surfsense_local/backend/tests/unit/pdf_tools/) covers each operation, stamp and form on PDFs built with ReportLab, and `tests/integration/artifacts/test_made_files.py` takes a made file through Studio's job.
- **Live:** [`tests/live/`](../../surfsense_local/backend/tests/live/) drives the real path against Claude Sonnet on a spend budget, skipped unless asked for ([07-dev-setup](../proposals/file-agent/07-dev-setup.md#live-tests)). [`tests/live/sweep/`](../../surfsense_local/backend/tests/live/sweep/) runs its smoke, PDF brief and board pack cases over OpenRouter's tool-calling models for the [capability list](model-capabilities.md#the-list).
- **Electron:** the sidecar tests in [`electron/src/main/sidecars/`](../../surfsense_local/electron/src/main/sidecars/), the snapshot poller, session, print-option and page-answer tests in `electron/src/main/docx-snapshot/`, and the pins, switch and stage-check tests in `electron/scripts/opencode/`.
@@ -275,13 +276,15 @@ A document script is another matter: it runs in the Studio worker's runner, whic
- Bringing a thread's `sources/` in line reads the text of every source in its scope before each turn, to hash it: at 3,000 sources of 40 KB, 0.8 s a turn once synced, 2.8 s for a second thread linking them, and 13 s the first time, writing the cache (`test_thread_folders.py`, Windows with Defender on). The first `grep` over a newly written cache pays Defender's scan of each file once per machine.
- On a disk without hard links each thread holds its own copy of each text, and pays Defender's scan again.
- A configuration rewrite, which a change of model, window or image input causes, ends every running agent turn in every workspace.
-- A plugin's settings reach a turn already running only through the proxy: a tool blocked mid-turn is refused from the next turn, and a plugin switched off or a host revoked is refused at its next connection, while a connection opencode already holds runs until it closes.
+- A plugin's settings reach a turn already running only through the proxy: a tool blocked mid-turn is refused from the next turn, and a plugin switched off or a host revoked is refused at its next connection (a registry's delisting within 2 seconds), while a connection opencode already holds runs until it closes.
- A server that changes a tool's description is caught at the next check, at most five minutes later; until then opencode lists the new words to the model with the tool's old rule.
- opencode holds each ready plugin's key in memory while the thread's instance lives. It wrote none to disk at its default log level on 1.18.34.
- A disconnected plugin's past steps name the tool as opencode does, `notes_search`, and cannot be saved to Sources.
+- A plugin's approval names the plugin and the tool, not its publisher or whether it is Verified or Community.
+- A plugin approval has no deadline of its own: SurfSense never answers an unanswered one as denied.
+- Progress a plugin reports during a call does not show on its running step.
- Opening the first agent thread waits for opencode to start and for a local model to load, up to a minute, with nothing on screen but the thread being created.
- No test runs opencode with every connection beyond loopback refused to check that it opens none.
-- When opencode cannot stay up, each chat opened in Agentic waits the full 60 seconds before its `503`, and Electron starts opencode again every 10 seconds while its configuration stands, each exit showing the generic "A background service stopped" toast again, which names neither the agent nor the cause. A failure to spawn it at all is not reported as a crash ([overview](overview.md#known-gaps)) ([#2228](https://github.com/MODSetter/SurfSense/issues/2228)).
- opencode's tool-output folder is shared by every thread in every workspace and open to each one's agent: a tool result over 50 KB or 2,000 lines is kept there for 7 days (SurfSense's own tools stay under that limit; `surfsense_read_document` pages long scripts), and another thread's agent, in the same workspace or another, can list and read it without asking. Per-thread folders do not change this.
- On macOS and Linux, opencode checks an absolute path as written, so its own allow for its tool-output folder also matches a path that climbs out of it with `..`. The skills folder's allow has a `..` deny after it; the tool-output folder's cannot, since opencode adds that allow after SurfSense's rules. Windows normalises the path first.
- Every page image the agent is shown, each render's previews and each page it opens, stays in its context until a compaction, and Anthropic's OpenAI-compatible API reports no prompt caching, so each request bills them again: about 1,000 tokens a page a request. Nothing drops the images of a version a newer one superseded. Anthropic caps the images in one request, so about 25 four-page renders in one uncompacted thread could reach it.
diff --git a/docs/architecture/chat.md b/docs/architecture/chat.md
index 16de77e82f..15f6b6b7a3 100644
--- a/docs/architecture/chat.md
+++ b/docs/architecture/chat.md
@@ -2,7 +2,7 @@
A chat thread belongs to a workspace. Each user message retrieves its own context from that workspace, the selected generation model streams an answer grounded in those passages, and each claim can cite the passage it came from. The server owns the conversation and every reply: a reply is a run in the API that keeps generating whoever watches it, both turns are stored before it starts and the assistant's text when it ends, citations are resolved to chunk ids before they are stored, and the frontend's live copy of a reply gives way to the stored turns once they catch up. Several threads can be answering at once, on any model.
-**Code:** [`modules/chat/`](../../surfsense_local/backend/modules/chat/), [`modules/chat/runs/`](../../surfsense_local/backend/modules/chat/runs/), [`shared/search.py`](../../surfsense_local/backend/shared/search.py), [`frontend/src/features/chat/`](../../surfsense_local/frontend/src/features/chat/), [`electron/src/main/quit/`](../../surfsense_local/electron/src/main/quit/)
+**Code:** [`modules/chat/`](../../surfsense_local/backend/modules/chat/), [`modules/chat/runs/`](../../surfsense_local/backend/modules/chat/runs/), [`shared/search.py`](../../surfsense_local/backend/shared/search.py), [`frontend/src/features/chat/`](../../surfsense_local/frontend/src/features/chat/), [`frontend/src/features/dictation/`](../../surfsense_local/frontend/src/features/dictation/), [`electron/src/main/quit/`](../../surfsense_local/electron/src/main/quit/)
**Decisions:** [ADR 0006](../adr/0006-hybrid-retrieval.md), [ADR 0011](../adr/0011-llama-cpp-local-runtime.md), [ADR 0034](../adr/0034-vision-is-the-runtimes-answer-stored-nowhere.md), [ADR 0048](../adr/0048-the-api-is-the-only-path-to-a-text-model.md), [ADR 0049](../adr/0049-prompts-grow-at-the-end.md)
## Endpoints
@@ -65,6 +65,7 @@ These are excerpts from the user's knowledge base, selected for this query. ...
- It ships as three markdown files in [`modules/chat/prompts/`](../../surfsense_local/backend/modules/chat/prompts/): `compact.md`, `capable.md` and `frontier.md`. All three carry the same citation rules, except where the [chat eval](../proposals/chat-eval.md) measured `compact.md` on Qwen3 1.7B: when the sources fall short, it answers from its own knowledge only with general knowledge, never guesses a detail of the user's own documents, products or people, labels neither sentence, and shows an example of each. `capable.md` adds a work order and `frontier.md` advice on how to shape an answer. `build_context(hits, tier)` returns it with the excerpts and their citations, loading the one for the selected model's prompt tier, which comes from the fingerprint recorded when the model was chosen, or from the model's name when none was recorded ([`local-models/selection.md`](local-models/selection.md)); it is not a user setting.
- A passage cannot forge a source. ``, ``, `` and `` tags in its text are stripped before it goes between the tags, so a document cannot close its block early or inject a label, and the document title is escaped as an attribute.
- No hits, no context block: the user message is the question alone, and the system message is a short instruction with no citation rules, the same for every tier. It asks the model to say the knowledge base does not cover the question, then answer from general knowledge if it can, never guessing about the user's own documents, products or people.
+- No hits while ticked sources are still being read, when the resolved scope counts at least one `pending` or `processing` source (`counts.indexing`): the system message is `_STILL_READING` instead, also fixed, the same for every tier and with no citation rules. The model starts by saying, in one sentence, how many selected sources are still being read and that the user can ask again once they are ready, then answers from general knowledge if it can, with the same rule against guessing. The count rides with the question, so the system message stays the cached start of the prompt ([ADR 0049](../adr/0049-prompts-grow-at-the-end.md)): the user message is `2 selected sources are still being read.` (`1 selected source is still being read.` for one), a blank line, then `Question: `, and the instruction says that note is the app's, not the user's. With any hit, the tier's instruction is used whatever is still indexing, and with nothing indexing the reply above is unchanged. An explicit `document_ids` list never gets here, since an id not ready is `409` before retrieval. It mirrors the agent's note for the same case ([`scope_note.py`](../../surfsense_local/backend/modules/agent/agent_threads/scope_note.py)), and the [chat eval](../proposals/chat-eval.md)'s `still-reading-general` and `still-reading-own-docs` cases cover it.
## Message assembly
@@ -134,6 +135,7 @@ A chat thread's assistant turn stores how it ended in its content, as `ending`,
| Interrupted | `{"type": "interrupted"}` | a quit, or the API going away, cut it off |
- **A failed turn is kept,** with or without text, so the question stays with the reason it failed. A turn stopped before any text is deleted, both halves, with any image file no other turn of the thread points at: the person chose to stop and there is nothing to show.
+- **An error the endpoint sends inside the stream,** as llama.cpp and OpenRouter do once the first token has gone out, fails the turn the same way rather than ending it as a finished reply.
- **History skips** a failed or interrupted reply with no text, together with its question, so the model never receives an empty answer or two questions in a row ([`history.py`](../../surfsense_local/backend/modules/chat/history.py)). One with text stays, as a partial reply always did.
- **A thread is renamed** only when its first turn ends with answer text.
@@ -179,6 +181,53 @@ What it does not do:
- It does not change how a thinking model samples beyond that. A curated Qwen3 commits a temperature for thinking only, so with thinking off it answers at llama-server's default: a mode with no set of its own is not given the other mode's ([`local-models/runtime.md`](local-models/runtime.md)).
- On a local model that never thinks, such as Gemma 3, the button is enabled and changes nothing: the request carries `thinking: false` and the model answers as it would have.
+## Voice input
+
+The composer's mic turns speech into text on this computer, with the model chosen for speech recognition, through the API's transcription routes; the renderer never reaches the transcription server ([proposal](../proposals/local-speech-to-text.md)). Dictated text is ordinary composer text, the same in Basic and Agentic threads.
+
+- **Capture** ([`capture/`](../../surfsense_local/frontend/src/features/dictation/capture/)). `window.surfsense.microphone.request()` first ([microphone bridge](../../surfsense_local/electron/src/main/microphone/)): macOS shows its prompt, and a system switch that is off reads as `NotAllowedError`. Then `getUserMedia` on the default device, one channel with echo cancellation, noise suppression and automatic gain, into an `AudioContext` at 16 kHz and an AudioWorklet ([`frame-worklet.js`](../../surfsense_local/frontend/src/features/dictation/capture/frame-worklet.js)) loaded with `new URL(..., import.meta.url)`, as pdf.js's worker is. Vite emits it as its own file (`?no-inline`): a `data:` URL is another origin to a worklet. A platform that refuses the 16 kHz context captures at the device's rate, and each phrase is resampled with an `OfflineAudioContext` ([`resample.ts`](../../surfsense_local/frontend/src/features/dictation/capture/resample.ts)). The worklet posts 20 ms frames with their RMS level. Every track stops and the context closes when a recording ends, is cancelled, or its page goes, which clears the system's mic indicator.
+- **Phrases** ([`phrase-cutter.ts`](../../surfsense_local/frontend/src/features/dictation/capture/phrase-cutter.ts)). The noise floor is the 10th percentile of the last 3 s of levels, and a frame is speech at three times it and above 0.005 RMS (about -46 dBFS). A phrase ends after at least 1.0 s of speech and then 700 ms under the threshold, or at 20 s, cut mid-way through the quietest 200 ms of its second half. Each keeps 460 ms (23 frames) before its first speech and after its last; a word or two before a pause stays open until more speech joins it, the stop sends it, or 20 s pass, when it ends at its last word and the quiet after it is never sent. Phrases go up as 16 kHz mono 16-bit WAVs ([`encode-wav.ts`](../../surfsense_local/frontend/src/features/dictation/capture/encode-wav.ts)), held in memory only, until transcribed or for Retry.
+- **The session** ([`dictation-session.ts`](../../surfsense_local/frontend/src/features/dictation/dictation-session.ts), [`api.ts`](../../surfsense_local/frontend/src/features/dictation/api.ts)). Pressing the mic opens `POST /llm/transcription/dictations` beside the microphone, so a memory refusal comes before anyone speaks. Phrases go to `POST …/dictations/{id}/phrases` one at a time, in the order spoken; a connection error or a `503` is asked once more after 2 s. A keepalive goes every 10 s, and `DELETE …/dictations/{id}`, sent with `keepalive` so a closing window still sends it, ends the session. `GET /llm/transcription/status` decides whether the mic listens or sets a model up ([`use-transcription-status.ts`](../../surfsense_local/frontend/src/features/dictation/use-transcription-status.ts)); it is keyed under the models' query, so choosing a speech model reads it again.
+- **How text lands** ([`dictation-adapter.ts`](../../surfsense_local/frontend/src/features/dictation/dictation-adapter.ts), [`recording.ts`](../../surfsense_local/frontend/src/features/dictation/recording.ts)). It is assistant-ui's `DictationAdapter`, in the runtime's `adapters` whatever the model reads, with `disableInputDuringDictation: false`: the message box goes read-only instead, keeping its focus and its keys. `listen()` returns a session in `starting`, `running` from the first real frame. Each answered phrase re-emits every phrase so far as one interim transcript, joined with a space, or with none between two in zh, ja, yue or th ([`join-phrases.ts`](../../surfsense_local/frontend/src/features/dictation/join-phrases.ts)), since assistant-ui puts a space between finals. Stop flushes the phrase in progress, waits for every phrase, emits one final, and only then ends: the runtime drops what an ended session emits. Text is clamped to the room left under the 4,096-character limit, at the last whole word, or the last character in Han, kana and Thai ([`clamp-to-room.ts`](../../surfsense_local/frontend/src/features/dictation/clamp-to-room.ts)), and reaching it stops the recording. A warning shows at 4:30 and recording stops at 5:00. A blank result inserts nothing.
+- **One per chat page.** [`use-chat-runtime.ts`](../../surfsense_local/frontend/src/features/chat/use-chat-runtime.ts) makes one, so a recording outlives the composer's remount on a thread switch. `ComposerDraftLifecycle` ([`thread-panel.tsx`](../../surfsense_local/frontend/src/features/chat/thread-panel.tsx)) cancels it on every switch, never stops it, so no late phrase lands in the next thread; leaving the page and `pagehide` cancel it too. A cancel drops the audio not yet transcribed, and nothing lands after it.
+
+| State | The mic, beside Send ([`mic/`](../../surfsense_local/frontend/src/features/dictation/mic/)) | The strip ([`strip/`](../../surfsense_local/frontend/src/features/dictation/strip/)) |
+|---|---|---|
+| Idle | a ghost icon button; tooltip "Voice input stays on this computer" | none |
+| Not set up | enabled; it opens the set-up popover ([`speech-setup-popover.tsx`](../../surfsense_local/frontend/src/features/speech-setup/speech-setup-popover.tsx)), whose "Start talking" starts listening, with a ring for a speech download under way | none |
+| Arming | pressed | a muted dot and pulsing bars until the first real frame; no timer |
+| Listening | pressed | a red dot, 9 level bars of the last 9 frames, a timer from the first frame, a cancel ✕ |
+| Loading | pressed | "Loading the speech recognition model…", while a phrase waits on a model the session opened `loading` 2 s or more ago |
+| Finishing | pressed | a spinner and "Transcribing…" |
+| Problem | as Idle | one line and one action, until it is acted on, dismissed, or the mic is pressed again |
+
+The strip sits inside the composer, never above it, where the model notice and the scroll button are: in the center placement in place of the source count and model control, in the bottom placement in place of the disclaimer. The mic is disabled whenever the message box is, and at the message limit.
+
+| Input while recording | Effect |
+|---|---|
+| Enter, or Send | stop, wait for the last phrase, insert; nothing is sent. Enter is caught in the composer's `onKeyDown`, before assistant-ui's, and Send's click is prevented, because assistant-ui's `send()` cancels a running session ([`use-dictation-keys.ts`](../../surfsense_local/frontend/src/features/dictation/use-dictation-keys.ts)) |
+| Escape, or the ✕ | cancel: the box returns to its text from before the mic. Escape does not also stop a reply being written meanwhile |
+| Click the mic | stop and keep the text |
+| Hold the mic 300 ms or more | push to talk: release stops |
+| Space or Enter on the focused mic | toggle |
+
+A click on the mic leaves the focus in the message box. Blur and minimising keep recording.
+
+| Problem ([`problem-text.ts`](../../surfsense_local/frontend/src/features/dictation/strip/problem-text.ts)) | Line | Action |
+|---|---|---|
+| `NotAllowedError`, or the system's switch off | "Microphone access is off for SurfSense." | Open settings on Windows and macOS (the bridge's `openSettings`), else Try again |
+| `NotFoundError` | "No microphone found." | Try again |
+| `NotReadableError` | "The microphone is in use by another app." | Try again |
+| the track ended | "The microphone was disconnected. What you said before that is kept." | dismiss |
+| a phrase failed twice, or was refused as malformed | "Couldn’t turn part of your speech into text." | Retry, which opens a new session and lands that phrase and every later one after the box's text |
+| `409 transcription_needs_memory`, on opening or on a phrase (the model unloaded for a podcast's or an image's hold) | "Transcribing needs about 2.9 GB free; this computer has 1.2 GB.", the server's sentence where it sent no sizes | on opening, dismiss; on a phrase or a Retry, Retry, which keeps the audio not yet transcribed as a failed phrase does |
+| `409 needs_speech_model` on opening, or on a phrase | "Set up speech recognition to use voice input." / "The speech recognition model was removed." | Set up, which opens Settings › Speech recognition |
+| `503 transcription_unavailable` on opening | "Speech recognition isn’t available right now." | Try again |
+| `404 dictation_ended` | "Voice input stopped; what was transcribed is kept." | dismiss |
+| the limit, or 5:00 | "Voice input stopped at the 4,096-character message limit." / "Voice input stopped after 5 minutes." | dismiss |
+
+Interface strings are `dictation__`, and a server code is `dictation_error_` ([localization](localization.md)).
+
## Frontend runtime
- [`use-chat-runtime.ts`](../../surfsense_local/frontend/src/features/chat/use-chat-runtime.ts) holds the threads, the open thread's stored turns and the actions. The thread panel hands them to [`live-thread-runtime.tsx`](../../surfsense_local/frontend/src/features/chat/live-thread-runtime.tsx), which adds the open thread's run and builds assistant-ui's `useExternalStoreRuntime`. FastAPI is already the source of truth for threads and messages, and a local-runtime history adapter would add a second persistence path that could write a message twice.
@@ -212,6 +261,7 @@ What it does not do:
- While the turn waits, the thinking header's `role="status"` region says "Thinking" or the reading quarter. A second region (`reply-announcer.tsx`) stays silent until it says "Reply finished" once, for a reply it watched run to completion. It is keyed by message, because messages render by index and an instance would otherwise watch one thread's reply and announce another's. Tokens are never fed to it, since a polite region read on every delta would interrupt without pause; a reply loaded from history, a turn that ended with no answer, and one that failed or was stopped after text arrived say nothing; its alert, or the stop, already does. A stopped reply is marked `incomplete` with reason `cancelled` by the runtime ([`use-chat-runtime.ts`](../../surfsense_local/frontend/src/features/chat/use-chat-runtime.ts)), since assistant-ui would otherwise call it complete once the run ends. A queued reply's header region says "Waiting for another reply" with its place. The Chats button's count is in its accessible name ("Chats, 2 replies being written"), and an unread row carries "New reply" as screen-reader text.
- The conversation has a heading, the thread's title, hidden visually because the visible title is the rename button. Focus does not move to it: on every thread switch the composer keeps the focus assistant-ui gives it, so someone can switch and type, and the composer is described by the heading (`aria-describedby`), so a screen reader names the conversation it writes into. A new chat has no heading until it is a thread.
- The startup loader and the typed-in thread title respect `prefers-reduced-motion`.
+- The mic carries `aria-pressed` and a name that follows its state: "Start voice input", "Stop voice input", or "Set up voice input"; its tooltip is visual only. While voice input runs, a `role="status"` line in the strip says "Starting the microphone…", "Listening", "Loading the speech recognition model…", "Transcribing…" or the 30-second warning; a problem is a `role="alert"`, the two limits a `role="status"`. Under `prefers-reduced-motion` the level bars give way to the still dot beside them.
## Agent threads
@@ -254,4 +304,8 @@ A note that fails to save reads as `interrupted`, which offers Retry rather than
- An agent thread refuses images.
- A thinking model spends the 1,024-token answer cap on its reasoning too: `max_tokens` counts what goes to `reasoning_content`, as the title measurement in [`local-models/runtime.md`](local-models/runtime.md#turning-thinking-off) shows, so on the local runtime a long think can cut the answer short or leave it empty. The trace now shows, so an empty answer is no longer unexplained, but how often it happens is unmeasured.
- Nothing shows progress while a model loads beyond "Thinking": llama-server's prompt progress starts once the model is up ([`local-models/runtime.md`](local-models/runtime.md#prompt-progress)).
+- Voice input's capture is checked only in the desktop app's own Chromium over `http`: the 16 kHz context, the worklet by URL and the `OfflineAudioContext` resample. Packaged builds over `file://` on Windows, macOS and Linux, and the macOS prompt, are unmeasured.
+- The speech threshold's floor (0.005 RMS) and the phrase rule (1.0 s of speech, a 700 ms pause) are not measured against real microphones, nor phrases against whole utterances ([proposal](../proposals/local-speech-to-text.md#open-questions)).
+- Voice input takes the default microphone only; there is no device choice.
+- Text lands a phrase at a time, never word by word: there are no live partials.
- The per-image cost is priced, not measured: Gemma 3, the curated vision model, spends a fixed 256, and 1,400 covers Qwen2.5-VL at the 1,024 px cap. No measurement at the pinned build confirms either.
diff --git a/docs/architecture/chatgpt-subscription.md b/docs/architecture/chatgpt-subscription.md
index d881454bae..4bbad03645 100644
--- a/docs/architecture/chatgpt-subscription.md
+++ b/docs/architecture/chatgpt-subscription.md
@@ -81,7 +81,9 @@ The OpenAI catalog records the plan's models as calling tools, so Agentic mode i
## Frontend
-Every model list reads `serves` rather than deciding: the Settings sections, their empty states and onboarding's server steps leave a ChatGPT connection out of every section but chat. The connection form lists **ChatGPT subscription** beside **Local or custom server**, only when the dialog was opened for a slot the sign-in's `serves` includes, with a hint built from that list ("Chat only"). Choosing it hides the URL and key fields and shows **Sign in with ChatGPT**, which asks about the hosts, opens the browser and waits, with **Open the sign-in page again** and Cancel. Editing a ChatGPT connection shows the account, and offers **Sign in again**, **Sign out**, and **Save changes** for a new name. Its provider cannot be changed, and an API-key connection cannot become one. The model groups show the account's email in place of the URL.
+Every model list reads `serves` rather than deciding: the Settings sections, their empty states and onboarding's server steps leave a ChatGPT connection out of every section but chat. A new ChatGPT connection is made only through **Use your ChatGPT plan**, below. The connection form lists **ChatGPT subscription** only while editing a ChatGPT connection, so a server's provider list never offers it. Editing one hides the URL and key fields, shows the account, and offers **Sign in again**, with **Open the sign-in page again** and Cancel while it waits, **Sign out**, and **Save changes** for a new name. Its provider cannot be changed. The model groups show the account's email in place of the URL.
+
+**Use your ChatGPT plan** sits below **Use a server** on Settings' **Add model** page and on onboarding's text step, wherever the sign-in's `serves` includes the slot ([`chatgpt-option.tsx`](../../surfsense_local/frontend/src/features/models/remote/connections/chatgpt/chatgpt-option.tsx)). One click signs in as **ChatGPT subscription**, with the dialog's progress in the row and Cancel in place of the button, then opens the new connection's models as connecting a server does. A plan already connected reads "Connected as" its email and offers **Show models** instead.
## Known gaps
diff --git a/docs/architecture/connections.md b/docs/architecture/connections.md
index 3018567397..b34a650b3c 100644
--- a/docs/architecture/connections.md
+++ b/docs/architecture/connections.md
@@ -13,7 +13,7 @@ A connection is one named remote endpoint that speaks the OpenAI API: a hosted p
provider a protocol implementation: llamacpp, sdcpp, audiocpp or openai_compatible
provider connection one named remote endpoint and its optional bearer key
model an id the endpoint lists live, or one entered by hand
-model type what a model is for, and the slot one selection fills: text_gen, image_gen, image_edit, video_gen or audio_gen
+model type what a model is for, and the slot one selection fills: text_gen, image_gen, image_edit, video_gen, audio_gen or speech_to_text, which only a model on this computer fills
```
A connection is type-neutral: which slot it fills depends on the model assigned from it and the routes the endpoint implements. One gateway can hold several slots; chat and image models on separate infrastructure are two connections. "OpenAI-compatible" means compatible for a given route, not that every route exists. Core vLLM serves `/chat/completions` but no image output, while vLLM-Omni serves `/images/generations`, so a typical organization has one connection for each.
@@ -94,7 +94,7 @@ Each model's `types` come from the first of three sources that knows it, and not
2. `catalog`: the remote model manifest, [`catalog/remote/manifest/models.json`](../../surfsense_local/backend/modules/llm/catalog/remote/manifest/models.json). `scripts/refresh_remote_manifest.py` builds it offline from models.dev, keyed provider then model, with the evidence the classifier reads rather than a verdict; a person reviews the diff and commits it, and nothing fetches models.dev at runtime. A connection with a `catalog_provider` reads that provider's entry first. Otherwise, and for an id its provider does not carry, the lookup reads the maker's own entry when the id's prefix names one, otherwise the types every provider carrying the id agrees on, trying the full id and then its last path segment ([`lookup.py`](../../surfsense_local/backend/modules/llm/catalog/remote/manifest/lookup.py)). A model found with no types, such as an embedder, is known to fill no slot.
3. `unknown`: neither source knows the id.
-Each listed model also carries `selectable_for`, the slots it can fill, decided by the one rule in [`selectable.py`](../../surfsense_local/backend/modules/llm/selectable.py): the types it is, or every type when it is unknown. The pickers read that field rather than deciding, and choosing a model applies the same rule. A model the manifest knows and cannot call, on a provider that is `unreachable` or served through another protocol, stays in the listing with `selectable_for` empty and its `unusable_reason`: the same row and reason the remote catalog gives, from the one rule in [`lookup.py`](../../surfsense_local/backend/modules/llm/catalog/remote/manifest/lookup.py) (`unusable_reason()`), and choosing it is refused with that reason. Only a connection that names its manifest provider is told this: a `custom` connection reads across providers for types alone, since what some provider cannot call says nothing about what a local Ollama or LM Studio serves, and a model no entry describes is unknown, not unusable. A model the listing does not contain, a connection whose listing fails, or an unusable model the user still wants, needs the choice repeated with `allow_unlisted: true`, the deliberate override the form's Save anyway and Use without testing send and never the default. The remote model list is fetched every time and never stored. It also carries `reads_images`, false for an unusable model and otherwise from the manifest's `modalities.input` through the connection's catalog provider ([`reads_images.py`](../../surfsense_local/backend/modules/llm/catalog/remote/reads_images.py)), even for a model whose types the endpoint declares; the selection routes and chat's send check read the same function, so a model's Vision chip never disagrees with the composer ([`chat.md`](chat.md#images)). An id the manifest lacks answers no.
+Each listed model also carries `selectable_for`, the slots it can fill, decided by the one rule in [`selectable.py`](../../surfsense_local/backend/modules/llm/selectable.py): the types it is, or every slot but `speech_to_text` when it is unknown, since no remote speech client exists ([selection](local-models/selection.md)). The pickers read that field rather than deciding, and choosing a model applies the same rule. A model the manifest knows and cannot call, on a provider that is `unreachable` or served through another protocol, stays in the listing with `selectable_for` empty and its `unusable_reason`: the same row and reason the remote catalog gives, from the one rule in [`lookup.py`](../../surfsense_local/backend/modules/llm/catalog/remote/manifest/lookup.py) (`unusable_reason()`), and choosing it is refused with that reason. Only a connection that names its manifest provider is told this: a `custom` connection reads across providers for types alone, since what some provider cannot call says nothing about what a local Ollama or LM Studio serves, and a model no entry describes is unknown, not unusable. A model the listing does not contain, a connection whose listing fails, or an unusable model the user still wants, needs the choice repeated with `allow_unlisted: true`, the deliberate override the form's Save anyway and Use without testing send and never the default. The remote model list is fetched every time and never stored. It also carries `reads_images`, false for an unusable model and otherwise from the manifest's `modalities.input` through the connection's catalog provider ([`reads_images.py`](../../surfsense_local/backend/modules/llm/catalog/remote/reads_images.py)), even for a model whose types the endpoint declares; the selection routes and chat's send check read the same function, so a model's Vision chip never disagrees with the composer ([`chat.md`](chat.md#images)). An id the manifest lacks answers no.
## The remote catalog
@@ -153,7 +153,7 @@ Loading a connection runs the egress check for its host. The bundled sd-server s
## Trying a model before assigning it
-- `chat-test` streams one answer from the chosen model, by default to a prompt asking for one short sentence, capped at 1,024 tokens and 600 characters, so a model whose capability is `unknown` can be seen answering before it becomes the chat model. An empty reply is a `502`, which is what a reasoning model returns when it spends the whole budget thinking.
+- `chat-test` streams one answer from the chosen model, by default to a prompt asking for one short sentence, capped at 1,024 tokens and 600 characters, so a model whose capability is `unknown` can be seen answering before it becomes the chat model. An empty reply is a `502`, which is what a reasoning model returns when it spends the whole budget thinking. An error the model sends inside its stream is a `502` as well, with its message, like a request the endpoint refuses, rather than the text before it passed off as the answer.
- `image-test` generates one image, by default a blue circle on white, through the real image client and returns the bytes with `Cache-Control: no-store`. It creates no artifact.
- `speech-test` voices the `prompt` sent, the Try dialog's line in the interface's language, through the podcast's speech client, in the `voice` sent, and returns the audio with `Cache-Control: no-store`. Without one the request carries no `voice`, so the server speaks in its own default where it has one, and says so where it has none; no voice is assumed. The Try dialog has an optional voice field for audio models and plays the clip.
- `GET`, `POST` and `DELETE /llm/selection/audio_gen/voices` read the audio selection's voices (the server's list, else the user's), add one once the server has voiced the `text` sent (`502` with the server's words when it refuses), and remove one. A model on this computer is a `409`.
@@ -177,8 +177,8 @@ Keys are protected by envelope encryption ([ADR 0018](../adr/0018-keychain-envel
## Frontend
-- Each model section in Settings shows, as a group under the local models, every connection whose `serves` includes that section's slot. `serves` comes from one backend rule ([`serves.py`](../../surfsense_local/backend/modules/llm/connections/serves.py)): every slot for a key connection, `text_gen` alone for a ChatGPT one. The section's empty state counts only those connections. A group loads its models only when opened, so a slow or failed endpoint does not hold up the others, and lists only those whose `selectable_for` includes that section's slot; the model in use, if it comes from that server, stays shown above the list whether the group is open or closed. Open, its list scrolls inside its own capped-height area rather than lengthening the page. A model's row shows its type chips, and a Vision chip when `reads_images` is true. A model is assigned after an optional test; an exact ID the listing lacks can be typed in. A new connection is added from **Use a server** on the section's **Add model** page. Adding and editing a connection happen in a dialog over the page. Saving a new connection returns to the list with its group open on its models, since choosing one is why it was added; saving an edit only refreshes.
-- Edit and Disconnect sit on each group. Edit opens the same form in that dialog. A connection serves every slot, so Disconnect names each model it will clear, Chat, Image or both, whichever section it is disconnected from.
+- Each model section in Settings shows, as a group under the local models, every connection whose `serves` includes that section's slot. `serves` comes from one backend rule ([`serves.py`](../../surfsense_local/backend/modules/llm/connections/serves.py)): every slot but `speech_to_text` for a key connection, `text_gen` alone for a ChatGPT one. The section's empty state counts only those connections. A group loads its models only when opened, so a slow or failed endpoint does not hold up the others, and lists only those whose `selectable_for` includes that section's slot; the model in use, if it comes from that server, stays shown above the list whether the group is open or closed. Open, its list scrolls inside its own capped-height area rather than lengthening the page. A model's row shows its type chips, and a Vision chip when `reads_images` is true. A model is assigned after an optional test; an exact ID the listing lacks can be typed in. A new connection is added from **Use a server** on the section's **Add model** page. Adding and editing a connection happen in a dialog over the page. Saving a new connection returns to the list with its group open on its models, since choosing one is why it was added; saving an edit only refreshes.
+- Edit and Disconnect sit on each group. Edit opens the same form in that dialog. A connection can fill every slot but `speech_to_text`, so Disconnect names each model it will clear, Chat, Image or both, whichever section it is disconnected from.
- Onboarding's model steps use the same groups and the same dialog; with nothing connected yet, their **Connect** opens the dialog directly, and saving opens the server page on the new connection's models.
- The connection form picks a provider from the remote manifest, through `GET /llm/catalog/remote`, or "Local or custom server". A ready provider fills its URL, which stays editable so a proxy in front of it still works; a provider that needs account details asks for each field and builds the URL from its template; a provider that needs a URL leaves it to the user; an unreachable one is listed, disabled, with its reason. A provider that takes no key, a loopback server, hides the key field. "Local or custom server" leaves the URL to the user, since its port is whatever its owner set, with `http://localhost:11434/v1` as placeholder text only; a loopback server the manifest lists, such as LM Studio, fills the manifest's URL like any ready provider. The save sends `catalog_provider`.
diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md
index 15107b51b2..fa55d25830 100644
--- a/docs/architecture/data-model.md
+++ b/docs/architecture/data-model.md
@@ -62,7 +62,7 @@ The API seeds one workspace, "My Workspace", at startup when none exists. Deleti
| `content` | the markdown body: extracted text, the note itself, or an artifact's body |
| `content_hash` | SHA-256 of a file's bytes, equal to `dedup_key` for an uploaded or imported file; `0025` copied it from `dedup_key`; null for notes and artifacts |
| `dedup_key` | SHA-256 of an uploaded or imported file's bytes; null for notes and artifacts |
-| `document_metadata` | JSON: `mime_type`, `size_bytes`, `suffix` for files; import adds `folder_path`, `source` and the hosted ids |
+| `document_metadata` | JSON: `mime_type`, `size_bytes`, `suffix` for files; import adds `folder_path`, `source` and the hosted ids; a recording's ingest adds `transcription`, `{language, duration_s, model}` once transcribed or `{awaiting: true}` while it waits for a speech model ([`documents.md`](documents.md#recordings)) |
| `embedding_index_id` | foreign key to `embedding_indexes`: the index its chunks were embedded into; null until its first ingest |
| `folder_id` | foreign key to `folders`, `ON DELETE SET NULL`, from `0025`: every `FILE` and `NOTE` is filed; null is an unfiled artifact, or a source the backstop unfiled, which scopes and the tree treat as the top of the Library |
| `created_at`, `updated_at` | |
@@ -141,10 +141,10 @@ An artifact's searchable body is a `Document` with `document_type = ARTIFACT`; `
| Table | Columns | Notes |
|---|---|---|
| `provider_connections` | `id`, `label`, `provider`, `base_url`, `catalog_provider`, `api_key_ciphertext`, timestamps, and since `0023` `auth_kind`, `oauth_ciphertext` and `token_version` | `label` unique case-insensitively; `provider` must be `openai_compatible`; `catalog_provider` is a remote manifest provider id or `custom`, since `0014`; the key is Fernet ciphertext since `0007`; since `0023`, `auth_kind` is `api_key` or `chatgpt`, `oauth_ciphertext` the Fernet-encrypted token set of a `chatgpt` row (`NULL` when signed out), and `token_version` the counter that keeps refreshes from racing ([`chatgpt-subscription.md`](chatgpt-subscription.md)) |
-| `selected_models` | `model_type`, `provider`, `connection_id`, `name`, `params_b`, `vendor`, `line`, `settings`, `updated_at` | one row per model type: `text_gen`, `image_gen`, `image_edit`, `video_gen` or `audio_gen`; `settings` is JSON for what the user set that no endpoint states, keyed by the slice that owns each entry, and cleared when the slot takes another model, all but `chat_modes`: the mode last picked for each model, keyed `//`, which stays so a model's pick comes back with it ([model capabilities](model-capabilities.md#the-modes)) |
+| `selected_models` | `model_type`, `provider`, `connection_id`, `name`, `params_b`, `vendor`, `line`, `settings`, `updated_at` | one row per model type: `text_gen`, `image_gen`, `image_edit`, `video_gen`, `audio_gen` or `speech_to_text`; `settings` is JSON for what the user set that no endpoint states, keyed by the slice that owns each entry (a server voice model's `voices`, the speech model's `transcription` language), and cleared when the slot takes another model, all but `chat_modes`: the mode last picked for each model, keyed `//`, which stays so a model's pick comes back with it ([model capabilities](model-capabilities.md#the-modes)) |
| `onboarding_completion` | `id`, `completed_at` | a singleton (`CHECK id = 1`) whose presence means onboarding is done |
-- A CHECK on `selected_models` allows `llamacpp`, `sdcpp` and `audiocpp` only without a connection and `openai_compatible` only with one, and a second, `local_runtime_type`, lets `llamacpp` hold only `text_gen`, `sdcpp` only `image_gen`, `image_edit` and `video_gen`, and `audiocpp` only `audio_gen`. `connection_id` cascades, so deleting a connection clears exactly the selections that used it.
+- A CHECK on `selected_models` allows `llamacpp`, `sdcpp` and `audiocpp` only without a connection and `openai_compatible` only with one, and a second, `local_runtime_type`, lets `llamacpp` hold only `text_gen`, `sdcpp` only `image_gen`, `image_edit` and `video_gen`, and `audiocpp` only `audio_gen` and `speech_to_text`. No CHECK keeps a connection out of `speech_to_text`; selection does ([selection](local-models/selection.md)). `connection_id` cascades, so deleting a connection clears exactly the selections that used it.
- `params_b`, `vendor` and `line` (`flagship` or `small`) are the model's fingerprint, recorded when it is chosen. They feed the prompt tier, which is computed on read, so retuning a threshold needs no migration.
- Choosing a model never writes `onboarding_completion`; `POST /llm/onboarding` does, once a `text_gen` selection exists.
- Remote `/models` answers, the local catalog, hardware profiles and fit estimates are not stored; they are recomputed or fetched live.
@@ -184,7 +184,12 @@ One row per run of a plugin's action. What the run produced is not here: the plu
└── data/
└── workspaces//
├── documents//
- │ └── an uploaded or imported file, under its own name
+ │ ├── an uploaded or imported file, under its own name
+ │ ├── figures/ the pictures ingest kept, and figures.json
+ │ │ └── work/ the runs a figures-only pass that gave way resumes from
+ │ ├── docling/work/ a long Docling reading's batches, until it ends
+ │ └── transcript/ a recording's: segments.json once transcribed;
+ │ └── work/ the decoded audio, plan and windows a job resumes from
├── chats//
│ └── . an image a turn carried, normalised to PNG or JPEG
├── artifacts//
@@ -317,6 +322,7 @@ erDiagram
real params_b
text vendor
text line
+ json settings
datetime updated_at
}
onboarding_completion {
@@ -379,13 +385,14 @@ erDiagram
| `0025` | `0025_source_roots_and_folders.py` | `source_roots` and `folders`; a Library root and root folder per workspace; `documents.folder_id` by a plain `ALTER TABLE`; every `FILE` and `NOTE` filed, an imported `folder_path` becoming a chain of folders (case and normalization variants merged, levels past 8 joined with " / " into the eighth); `content_hash` filled from `dedup_key`; the dedup index swapped to per folder by `DROP` and `CREATE INDEX` |
| `0026` | `0026_chat_history_start.py` | `chat_threads.history_start_message_id`, by a plain `ALTER TABLE`; no row is backfilled, so a thread sends its whole history until it first outgrows its budget |
| `0027` | `0027_retire_pre_host_egress_grants.py` | egress rows under `model_download`, `model_search` and `image_model_pull` removed; `host:huggingface.co` allowed only where search and a download were both allowed and the host had no row; downgrading changes nothing |
-| `0029` | `0029_chat_thread_cloud_id.py` | `chat_threads.cloud_id`, nullable and unique; marks workspaces with older unkeyed imported threads |
+| `0029` | `0029_chat_thread_cloud_id.py` | `chat_threads.cloud_id`, nullable and unique; `workspaces.has_unkeyed_imported_threads`, marking workspaces with older unkeyed imported threads |
+| `0030` | `0030_speech_to_text_slot.py` | `selected_models` rebuilt so `speech_to_text` is a type and `audiocpp` may hold it beside `audio_gen`, carrying every column, `0019`'s `settings` included, which `0018`'s rebuild predates; downgrading drops a `speech_to_text` selection |
- Before the API applies a pending revision to an existing database, `upgrade_to_head` writes a `VACUUM INTO` copy to `/backups/-.db` (via `.partial`, renamed once complete) and keeps the newest two ([`migration_snapshot.py`](../../surfsense_local/backend/shared/migration_snapshot.py)). A new database or one at head writes none. If the copy fails, nothing migrates and the API start fails with the reason. To restore: quit SurfSense, replace `surfsense.db` with the snapshot, and start the app.
- Migrations run on every API start and are idempotent. Autogenerate is off: it renders a rename as a drop plus an add, which deletes a column's data silently, and `env.py` carries no `target_metadata`, so it cannot be used by accident.
-- SQLite cannot alter a CHECK constraint in place, so `0004`, `0009`, `0012` and `0013` copy `selected_models` into a new table.
+- SQLite cannot alter a CHECK constraint in place, so `0004`, `0009`, `0012`, `0013`, `0016`, `0018` and `0030` copy `selected_models` into a new table. A copy names every column the table has by then: one written from an older rebuild drops what later revisions added.
- A revision that touches a table already holding rows should read the live schema first (`op.get_bind()`, `sa.inspect`) rather than assume its shape.
-- [`tests/integration/test_migrations.py`](../../surfsense_local/backend/tests/integration/test_migrations.py) fails when the models and the migration history disagree, when a second upgrade is not a no-op, and when a failed migration leaves anything behind. `test_migration_0012.py`, `test_migration_0013.py`, `test_migration_0014.py`, `test_migration_0027.py` and `test_migration_0029.py` beside it test what those revisions change.
+- [`tests/integration/test_migrations.py`](../../surfsense_local/backend/tests/integration/test_migrations.py) fails when the models and the migration history disagree, when a second upgrade is not a no-op, and when a failed migration leaves anything behind. `test_migration_0012.py`, `test_migration_0013.py`, `test_migration_0014.py`, `test_migration_0027.py`, `test_migration_0029.py` and `test_migration_0030.py` beside it test what those revisions change.
## Known gaps
diff --git a/docs/architecture/documents.md b/docs/architecture/documents.md
index 18dff26660..9ac677c79e 100644
--- a/docs/architecture/documents.md
+++ b/docs/architecture/documents.md
@@ -1,9 +1,9 @@
# Documents and ingest
-A workspace holds a library of sources: uploaded files, notes written in the app, and the bodies of Studio artifacts. Every source ends up the same way, as a markdown body on its `documents` row cut into passages that are embedded and indexed, so chat and search treat them alike. The API only stores bytes and enqueues a job; parsing, chunking and embedding happen in the ingest worker, one document at a time, and a document is `ready` only once it is searchable. Ingest also keeps a file's pictures beside its original, as [figures](#figures) the agent can place in a document. Studio indexes an artifact's body itself, in its own worker, with the same chunking and embedding code.
+A workspace holds a library of sources: uploaded files, notes written in the app, and the bodies of Studio artifacts. Every source ends up the same way, as a markdown body on its `documents` row cut into passages that are embedded and indexed, so chat and search treat them alike. The API only stores bytes and enqueues a job; parsing, chunking and embedding happen in the ingest worker, one document at a time, and a document is `ready` only once it is searchable. Ingest also keeps a file's pictures beside its original, as [figures](#figures) the agent can place in a document, and turns a recording into a timestamped transcript through the speech model ([Recordings](#recordings)). Studio indexes an artifact's body itself, in its own worker, with the same chunking and embedding code.
-**Code:** [`modules/workspaces/`](../../surfsense_local/backend/modules/workspaces/), [`modules/documents/`](../../surfsense_local/backend/modules/documents/), [`worker/ingestion/`](../../surfsense_local/backend/worker/ingestion/), [`worker/jobs.py`](../../surfsense_local/backend/worker/jobs.py), [`shared/queue.py`](../../surfsense_local/backend/shared/queue.py), [`frontend/src/features/sources/`](../../surfsense_local/frontend/src/features/sources/)
-**Decisions:** [ADR 0003](../adr/0003-artifacts-as-documents.md), [ADR 0007](../adr/0007-bundled-embeddings.md), [ADR 0036](../adr/0036-the-index-records-its-embedder.md), [ADR 0008](../adr/0008-two-job-queues.md), [ADR 0009](../adr/0009-freshness-by-invalidation.md)
+**Code:** [`modules/workspaces/`](../../surfsense_local/backend/modules/workspaces/), [`modules/documents/`](../../surfsense_local/backend/modules/documents/), [`worker/ingestion/`](../../surfsense_local/backend/worker/ingestion/), [`worker/audio_decode/`](../../surfsense_local/backend/worker/audio_decode/), [`worker/jobs.py`](../../surfsense_local/backend/worker/jobs.py), [`shared/queue.py`](../../surfsense_local/backend/shared/queue.py), [`frontend/src/features/sources/`](../../surfsense_local/frontend/src/features/sources/)
+**Decisions:** [ADR 0003](../adr/0003-artifacts-as-documents.md), [ADR 0007](../adr/0007-bundled-embeddings.md), [ADR 0036](../adr/0036-the-index-records-its-embedder.md), [ADR 0058](../adr/0058-the-bundled-embedder-runs-at-fp32-on-x86-64.md), [ADR 0059](../adr/0059-a-born-digital-pdf-is-read-from-its-text-layer.md), [ADR 0008](../adr/0008-two-job-queues.md), [ADR 0009](../adr/0009-freshness-by-invalidation.md)
## Workspaces
@@ -43,7 +43,8 @@ Chunks have no router of their own.
- Filters are `?document_type=` and `?status=`, both repeatable, and `?folder_id=`. Paging is `?limit=` (default 50, at most 200) and `?offset=`; the sources panel reads pages of 200 until one comes short, so it holds every source, not the newest 50. Rows come newest first, by `created_at` and then id, since one upload batch shares a second, so a fresh upload leads the sources list while it is still indexing.
- `ARTIFACT` rows are included and the caller filters them out. The sources panel asks for `document_type=FILE&document_type=NOTE`, so Studio output does not look like something the user uploaded.
-- A row carries `id`, `title`, `document_type`, `status`, `error_message`, `folder_id` and timestamps, but no `content`. The list is polled while ingest runs, and a body per row would ride along on every poll; `GET .../documents/{document_id}` returns one row with it.
+- A row carries `id`, `title`, `document_type`, `mime_type`, `status`, `error_message`, `folder_id`, timestamps and `transcription`, but no `content`. The list is polled while ingest runs, and a body per row would ride along on every poll; `GET .../documents/{document_id}` returns one row with it.
+- `transcription` is null for everything but a recording (`mime_type` `audio/*`), which carries `{language, progress, awaiting}`: the language its transcript was heard in once it is done; `{done_s, total_s}` read from the job's `progress.json` while it is `pending` or `processing`, so a reload or a late listener sees how far it has come; and whether it failed only for want of a speech model ([Recordings](#recordings)).
## What is editable
@@ -64,20 +65,22 @@ The sources panel shows the Library as a tree ([`features/sources/tree/`](../../
- **Rows.** Folders come first, by name, then sources newest first. The list is an ARIA `tree` with one `treeitem` per row: arrow keys move and open, Space ticks, Enter opens, F2 renames and Delete asks to delete. A name filter, opened from the search button in the header, shows matches with the folders holding them opened; Escape or closing it clears it. A name the column cuts off shows whole in a tooltip on hover, or while its row has keyboard focus ([`overflow-tooltip.tsx`](../../surfsense_local/frontend/src/components/ui/overflow-tooltip.tsx)). Rows are memoized, and a re-read of the list keeps the object of every unchanged source ([`reuse-unchanged.ts`](../../surfsense_local/frontend/src/features/sources/reuse-unchanged.ts)), so a poll or an event renders only the rows whose source changed.
- **Actions.** New folder (from the header's **+** menu, or inside a folder from its menu), Rename, Move to… and Delete. Move to… is a picker of folders and the keyboard's path; a row dragged onto a folder, or onto a source inside one, moves there too. A folder cannot be moved inside itself. Deleting a folder names how many sources, and filed Studio outputs, go with it, as `GET /folders/{folder_id}/summary` counts them, and deletes them for good.
+- **Recordings.** A recording, an `audio/*` source, carries `transcription` on its row, and its row has a second line ([`transcription/`](../../surfsense_local/frontend/src/features/sources/transcription)). While it transcribes, "Transcribing · 23 of 58 min · about 7 min left" over a thin bar: the minutes are `transcription.progress`, and the time left is the speed of the windows the panel has heard of since this run started; until two have arrived there is no estimate. A recording that made way for other sources reads "Queued · 40% transcribed". One waiting for a speech recognition model reads "Waiting for speech recognition" with **Set up**, which opens the speech set-up popover ([catalog](local-models/catalog.md)), and shows no error and no Retry: choosing a model queues it again, and the popover stays open until it is closed, so it can say speech recognition is ready. A ready transcript in a language the chosen speech model detected but is not listed for (`GET /llm/selection/speech_to_text/language`) says "Estonian (detected) · the transcript may be less accurate"; with one curated speech model, there is no other to offer. A ready transcript that is not in English, or whose language was not identified, says search finds it best with questions in its own language and that a chat reads all of it, while the active index's embedder is bge-small. The frontend knows bge-small by its id, `bge-small-en-v1.5`, since no route says which languages an embedder reads. A recording has no preview: viewers are keyed by MIME type, and none takes `audio/*` yet.
- **Ticks.** Each row's tick is tri-state. Ticking a folder ticks everything under it, now and added later; the nearest tick up the tree decides ([`scope-state.ts`](../../surfsense_local/frontend/src/features/sources/tree/scope-state.ts)). Chat and Studio send the ticks as `source_scope`, so every ticked source counts, not only the ones loaded.
## Upload
- The request is multipart, one `files` part per file. Each file streams to a temporary file inside the workspace directory in 1 MB reads, hashed with SHA-256 on the way past and never held whole in memory. A file over 500 MB is refused with `413`. The temporary file sits on the same filesystem as its destination, because a rename is only atomic within one filesystem.
- Only a plain extension, a dot and 1 to 16 letters or digits, is validated from the client's filename. The file is stored alone in a directory built from row ids, `data/workspaces//documents//`, under the client's filename made safe for every platform ([`original_file.py`](../../surfsense_local/backend/modules/documents/original_file.py)): the last path part only, with [pathvalidate](https://github.com/thombashi/pathvalidate) removing characters no filesystem accepts and suffixing Windows reserved names, leading dots dropped so the file is never hidden, cut before the extension so the whole name fits in 120 bytes, which keeps a typical Windows path under its 260-character limit, and ending in the validated extension lowercased. A name with nothing left becomes `untitled`, and one that would be `extracted.md` becomes `extracted (1).md` (see [The original file](#the-original-file)). The name reaches the disk; the path never does.
-- Accepted extensions are `.pdf`, `.docx`, `.pptx`, `.xlsx`, `.html`, `.htm`, `.csv`, `.md`, `.markdown`, `.txt`, `.text`, `.png`, `.jpg`, `.jpeg`, `.tif`, `.tiff`, `.bmp` and `.webp`. The bytes must match the extension: the PDF signature; an OOXML zip holding its main part, with at most 10,000 entries and 2 GB unpacked; UTF-8 text with no NUL and few control bytes; or an image's magic number. The MIME type stored is the server's, never the client's. The frontend mirrors the list to filter its file picker and to skip unsupported files, dropped ones included, before uploading.
+- Accepted extensions are `.pdf`, `.docx`, `.pptx`, `.xlsx`, `.html`, `.htm`, `.csv`, `.md`, `.markdown`, `.txt`, `.text`, `.png`, `.jpg`, `.jpeg`, `.tif`, `.tiff`, `.bmp` and `.webp`, and the recordings `.wav`, `.mp3`, `.flac`, `.ogg` and `.oga`. The bytes must match the extension: the PDF signature; an OOXML zip holding its main part, with at most 10,000 entries and 2 GB unpacked; UTF-8 text with no NUL and few control bytes; an image's magic number; or a recording's: RIFF with a WAVE form, an ID3 tag or an MPEG audio frame header (not AAC's), `fLaC`, or an Ogg page whose first packet is Vorbis's. An Ogg Opus file is rejected on its own with "Opus support comes in a later update", since the decoder reads Vorbis only. The MIME type stored is the server's, never the client's: `audio/wav`, `audio/mpeg`, `audio/flac` and `audio/ogg` for recordings. The frontend mirrors the list to filter its file picker and to skip unsupported files, dropped ones included, before uploading.
- The sources panel takes files two ways: its **+** menu, and files dropped onto the panel, which shows "Drop files to add them as sources" while files are dragged over it ([`use-sources-drop.ts`](../../surfsense_local/frontend/src/features/sources/use-sources-drop.ts)). Both go through the same upload. Dropped files and rows dragged between folders go through [Pragmatic drag and drop](https://atlassian.design/components/pragmatic-drag-and-drop), which also scrolls the list while a drag nears its top or bottom, and draws a dragged row as a small chip. The panel takes no drop while an upload runs, as uploading is disabled then, and a drag carrying no files, such as selected text, does not light it up. A file dropped anywhere else in the window is refused ([`file-drop-guard.ts`](../../surfsense_local/frontend/src/app/file-drop-guard.ts)): unhandled, Chromium opens it in the window, and in Electron that replaces the app, since the main process lets `file:` navigation through.
- A whole folder comes in two ways: **+ › Upload folder…**, an `` whose files carry `webkitRelativePath`, and a dropped folder, which the panel walks with `webkitGetAsEntry()` ([`dropped-files.ts`](../../surfsense_local/frontend/src/features/sources/folder-upload/dropped-files.ts)). Either sends each file's path under the folder as `relative_paths`, and a drop on a folder row, or on a source inside one, adds `folder_id`. Before sending, the client skips paths through `.git`, `node_modules`, `__pycache__`, `.DS_Store`, `.obsidian` and `.trash`, sets aside unsupported files and any file over 500 MB, which it lists as rejected rather than letting one fail its request with `413`, and sends the rest in requests of at most 50 files or 200 MB ([`upload-plan.ts`](../../surfsense_local/frontend/src/features/sources/folder-upload/upload-plan.ts)).
- Files go into the `folder_id` form field's folder, else the top of the Library. `relative_paths`, a JSON list index for index with `files`, carries an added folder's tree: each file lands in the folders its path names below the target (`Research/2024/a.pdf` makes `Research` and `2024`), made as needed with `ensure_folder_path`. Levels past the depth cap are joined with " / " into the deepest level there is room for, so a deep tree arrives whole. A list of the wrong length is `422`; a folder from another workspace is `404`.
- `dedup_key` is the SHA-256 of the bytes and is unique per folder. Keying on the bytes rather than the filename means the same report saved twice in one folder is one document, while two different files both called `report.pdf` are two. A file whose bytes sit in another folder is created in the new one too, so a copied folder has no holes, and copying it again adds only what is new.
- A batch is split, not rejected, except that a file over 500 MB fails the whole request with 413 and nothing in it is created. The response lists `created`, `duplicates` (each with the existing document's id and its `folder_id`) and `rejected` (each with a reason), so a dropped folder holding one known file keeps the rest.
- An upload of more than 20 files enqueues at `PRIORITY_BULK` (10); every other ingest runs at `PRIORITY_INTERACTIVE` (100), so a note written during a long folder copy is read first ([`tasks.py`](../../surfsense_local/backend/modules/documents/tasks.py)).
-- Accepted files become `FILE` rows in `pending`, with `mime_type`, `size_bytes` and `suffix` in `document_metadata`. The files move into place, the transaction commits, and only then is one ingest job enqueued per file, because the worker is another process and would look for rows this request had not yet written.
+- Accepted files become `FILE` rows in `pending`, with `mime_type`, `size_bytes` and `suffix` in `document_metadata`. A recording is accepted with or without a speech model chosen; with none, its ingest waits for one ([Recordings](#recordings)). The 500 MB cap holds for recordings too, with no cap on length: at 64 kbps that is about 18 hours. The files move into place, the transaction commits, and only then is one ingest job enqueued per file, because the worker is another process and would look for rows this request had not yet written.
+- The jobs are enqueued cheapest first ([`upload_order.py`](../../surfsense_local/backend/modules/documents/upload_order.py)): text files (`.md`, `.markdown`, `.txt`, `.text`, read straight off disk), then every other file by size, smallest first, then recordings by size; ties keep upload order. Equal priorities run first in, first out ([The queue](#the-queue)), so this is the order a dropped batch becomes ready in. It holds within one request, and the frontend sends at most 50 files or 200 MB in each. On two PDFs and a 4.8 KB `.md` at FP16, the first source was ready after 4.2 s instead of 35.0, and the three after 29.2 s on average instead of 50.4. Size only stands in for the work, so a small file that parses slowly can still hold larger ones behind it.
## Folders
@@ -103,13 +106,14 @@ Sources sit in folders of the workspace's Library ([data model](data-model.md#so
## Retry, cancel and delete
- **Retry** accepts a `failed` or `cancelled` document: it sets `pending`, clears `error_message` and enqueues. Without it those states would be terminal, since re-uploading the same bytes is a duplicate.
-- **Cancel** accepts a `pending` or `processing` document and answers `409` otherwise. It marks the row `cancelled`, clears `error_message` and revokes any queued copy of the job (`revoke_pending`). A running job is not killed: the pipeline checks between steps and unwinds at the next one, so a long Docling parse finishes first.
+- **Cancel** accepts a `pending` or `processing` document and answers `409` otherwise. It marks the row `cancelled`, clears `error_message` and revokes any queued copy of the job (`revoke_pending`). A running job is not killed: the pipeline checks between steps and unwinds at the next one, and a long Docling reading between batches of 8 pages, so the batch in flight finishes first ([Long Docling readings](#long-docling-readings)).
- **Delete** refuses a `processing` document with `409`. Otherwise the row goes, its chunks cascade, the triggers clear both index tables, and the document's directory is removed after the commit; removing it first would leave a row describing a missing file if the transaction rolled back.
+- A ready source can be deleted while its figures-only pass reads it, and on Windows the delete cannot remove the file Docling holds open. `documents` has no `AUTOINCREMENT`, so SQLite gives the highest id out again once its row is gone, and the next document would find that file in its directory. So an upload or an import empties its new document's directory before moving the file in ([`original_file.py`](../../surfsense_local/backend/modules/documents/original_file.py)), and the pass removes what the delete left once Docling lets go ([Figures](#figures)).
- Delete also accepts an `ARTIFACT` document, and removes the artifact's files under `artifacts//` as well as `documents//`, since a Studio output keeps its rendered blobs under the artifact's own id rather than the document's. `DELETE /artifacts/{id}` removes the same two ([`studio.md`](studio.md)).
## The original file
-No filename is stored in a row: the document's directory is the record. The original is the one file in it, so not the `figures/` folder, that is not hidden, not `Thumbs.db` or `desktop.ini`, and not `extracted.md`. Any other count, an empty or absent directory included, means the file is missing. Directories written before uploads kept their names hold `original.` beside an `extracted.md` nothing reads. They are read as they are and deliberately never rewritten: nothing in the index depends on a file's name, so renaming would only change what the file manager shows, and a bulk rename can fail part way on a file that is open or already deleted. Because `extracted.md` is never taken for the original, a legacy directory whose original was deleted reads as missing rather than serving Docling's text as the user's file.
+No filename is stored in a row: the document's directory is the record. The original is the one file in it, so not the `figures/` folder, a recording's `transcript/` or a long Docling reading's `docling/`, that is not hidden, not `Thumbs.db` or `desktop.ini`, and not `extracted.md`. Any other count, an empty or absent directory included, means the file is missing. Directories written before uploads kept their names hold `original.` beside an `extracted.md` nothing reads. They are read as they are and deliberately never rewritten: nothing in the index depends on a file's name, so renaming would only change what the file manager shows, and a bulk rename can fail part way on a file that is open or already deleted. Because `extracted.md` is never taken for the original, a legacy directory whose original was deleted reads as missing rather than serving Docling's text as the user's file.
`GET .../original` serves the stored bytes with the document's title as the filename and the stored MIME type, always as an attachment: an HTML or SVG file rendered inline would run its script against the app's origin. It answers `404` when there is no file behind the document, as for a note.
@@ -120,17 +124,63 @@ The desktop app previews an original in place of the left sidebar when its MIME
`ingest_document(document_id)` is declared in [`modules/documents/tasks.py`](../../surfsense_local/backend/modules/documents/tasks.py), because Huey binds a task to its queue when it is decorated and the API is what enqueues it. The task body imports the pipeline lazily and Docling is imported only when the converter is first built, so the API never loads Docling or torch. Each job opens its own database engine.
1. **Start.** `begin_job` marks the row `processing` unless it was cancelled, and the worker notifies the API. The job then reads the active embedding index; with none, because onboarding has not chosen an embedder, it fails with `no embedding model has been chosen yet`.
-2. **Parse** ([`parsing.py`](../../surfsense_local/backend/worker/ingestion/parsing.py)). A `NOTE` or `ARTIFACT` already has its markdown in `content`. A `FILE` with a text extension (`.md`, `.markdown`, `.txt`, `.text`) is read as UTF-8; anything else goes to Docling. A `.pptx` with no slides, such as a template, skips Docling, which refuses a deck with no pages: its text is `PowerPoint template with no slides.`, its slide size and its slide layout names in order, one line each, read with python-pptx ([`slideless_deck.py`](../../surfsense_local/backend/worker/ingestion/slideless_deck.py)), so search finds it and the agent can start a deck from it as a template ([studio](studio.md)). A blank Word or Excel file goes to Docling, which reads it as empty, and is ready with no text. OCR and table structure are switched on, so a scanned PDF is readable and a table survives as a table. RapidOCR's per-line orientation classifier is off: on noisy scans it turned upright lines 180 degrees and they read back as garbage, and dropping it cut the character error rate on generated scans about eightfold. An image is first turned upright by its EXIF orientation, which Docling ignores, and given a DPI that reads it as one A4 page, never finer than the 216 dpi OCR renders at nor coarser than its own DPI ([`image_page.py`](../../surfsense_local/backend/worker/ingestion/image_page.py)); with none, Docling takes it as 72 dpi and OCR upsampled a photo threefold. Images and PDFs share one set of options, so they share one pipeline and its models. Inference gets one thread per physical core, at most 8, as audio.cpp does, instead of Docling's 4: on an 8-core CPU that parsed generated scans, photos and PDFs 1.4 times faster. The converter is built once per process and its models load on the first conversion, so the first document a worker parses pays for the import and the load. `HF_HOME` defaults to the models directory, because Docling would otherwise write weights into `site-packages`, which is read-only in a frozen bundle. When the bundled parser pack is present Docling uses it with RapidOCR, and `HF_HUB_OFFLINE=1` is set before Docling is imported, because huggingface_hub reads it only then, so a PDF or an image converts with networking off. The markdown is kept only in `content`. Beside the original, ingest writes only the pictures Docling found, as [figures](#figures).
+2. **Parse** ([`parsing.py`](../../surfsense_local/backend/worker/ingestion/parsing.py)). A `NOTE` or `ARTIFACT` already has its markdown in `content`. A recording, a `FILE` whose `mime_type` is `audio/*`, is transcribed before Docling is reached, which must never read one ([Recordings](#recordings)). A `FILE` with a text extension (`.md`, `.markdown`, `.txt`, `.text`) is read as UTF-8. A `.pdf` is read from its own pdfium text layer when every page passes the triage, and Docling reads only the pages that fail it when they are at most half the file ([PDFs with a text layer](#pdfs-with-a-text-layer)); a `.docx` is read without Docling (below); any other PDF, and anything else, goes to Docling, a PDF longer than 8 pages a batch of pages at a time ([Long Docling readings](#long-docling-readings)). A `.pptx` with no slides, such as a template, skips Docling, which refuses a deck with no pages: its text is `PowerPoint template with no slides.`, its slide size and its slide layout names in order, one line each, read with python-pptx ([`slideless_deck.py`](../../surfsense_local/backend/worker/ingestion/slideless_deck.py)), so search finds it and the agent can start a deck from it as a template ([studio](studio.md)). A `.docx` is read as markdown with python-docx and lxml ([`word_markdown/`](../../surfsense_local/backend/worker/ingestion/word_markdown/)): 0.13 s for the longest reference contract, which Docling reads in 5.1 s warm, and 0.05 s for a fresh worker's first Word file, which costs 4.4 s through Docling, its import mostly. The body comes in order: headings as Docling decides them (a style id or name with "heading", or the style's own `w:outlineLvl`; Title is `#`, Heading N is N+1 `#`s), list items and numbered headings with the numbers Word renders from `numbering.xml`, tables one compact line per row with a merged cell's text once and a nested table flattened into its cell, links as markdown links to their address, a space in it as `%20` and an unmatched parenthesis escaped, tabs and breaks as spaces and line breaks, tracked changes as if accepted, a deleted table row with them, `mc:AlternateContent` as its first choice and ruby as its base text, then footnotes and endnotes as `[^1]: …`. Headers, footers and comments are left out, as Docling's markdown leaves them. The numbers agree with Word 16's ListString on all 1,876 numbered paragraphs of 41 reference files, where Docling's agree on 31%: it restarts each `w:num`, ignores `startOverride` and numbering a style inherits along `w:basedOn`, and writes letters and roman numerals as decimals. [`test_word_numbering.py`](../../surfsense_local/backend/tests/unit/worker/test_word_numbering.py) rebuilds a fixture for each rule and expects the ListString Word gave. A counter and a `w:lvlText` come from the file, so a letter, numeral or dagger repeated past 32 times is written in decimal, a start past a 32-bit integer is ignored, and a marker is cut at 255 characters; the longest in the reference files is 10. On the 791 reference Word files Docling reads, the reader keeps a median 100% of Docling's words, and 100% at the 5th percentile, with numbers compared apart and a merged cell counted once. `SURFSENSE_LOCAL_DOCX_READER=docling` turns it off. A file it cannot read, one with native charts, whose data Docling writes as tables, and one where it finds no text go to Docling as before, so a blank Word or Excel file is still ready with no text. While Docling reads a `.docx`, for ingest and the figures-only pass alike, python-docx's style lookups are remembered for that one conversion, per document part and per style id and type ([`docx_style_cache.py`](../../surfsense_local/backend/worker/ingestion/docx_style_cache.py)): Docling asks for a paragraph's style once per run, and python-docx scans every style in the file for each answer, which was 80% of reading a long contract, 25.8 s against 5.4 s with the cache, idle. The Markdown and the whole `DoclingDocument` came out identical on 804 Word files; a file that defines few styles gains little. Only the converting thread reads the cache, and python-docx's own `get_style` is put back once the last conversion running ends, failed or not, so `revise_document` and Studio's writers never see a remembered style. OCR and table structure are switched on, so a scanned PDF is readable and a table survives as a table. RapidOCR's per-line orientation classifier is off: on noisy scans it turned upright lines 180 degrees and they read back as garbage, and dropping it cut the character error rate on generated scans about eightfold. An image is first turned upright by its EXIF orientation, which Docling ignores, and given a DPI that reads it as one A4 page, never finer than the 216 dpi OCR renders at nor coarser than its own DPI ([`image_page.py`](../../surfsense_local/backend/worker/ingestion/image_page.py)); with none, Docling takes it as 72 dpi and OCR upsampled a photo threefold. Images and PDFs share one set of options, so they share one pipeline and its models. Inference gets one thread per physical core, at most 8, as audio.cpp does, instead of Docling's 4: on an 8-core CPU that parsed generated scans, photos and PDFs 1.4 times faster. The converter is built once per process and its models load on the first conversion, so the first document a worker gives Docling pays for the import and the load, 11 to 17 s on the measuring machine; a worker given only notes, text files, text-layer PDFs and Word files imports it only once a figures-only pass runs. `HF_HOME` defaults to the models directory, because Docling would otherwise write weights into `site-packages`, which is read-only in a frozen bundle. When the bundled parser pack is present Docling uses it with RapidOCR, and `HF_HUB_OFFLINE=1` is set before Docling is imported, because huggingface_hub reads it only then, so a PDF or an image converts with networking off. The markdown is kept only in `content`. Beside the original, ingest writes only the pictures Docling found, as [figures](#figures); a PDF read from its text layer, wholly or but for some pages, gets its figures from the figures-only pass, and a `.docx` read without Docling drops those an earlier reading kept and leaves its pictures to that pass.
3. **Chunk** ([`chunking.py`](../../surfsense_local/backend/worker/ingestion/chunking.py)). Chonkie's `RecursiveChunker` splits at the coarsest boundary that fits: a heading, a paragraph, a line, a sentence, a word, then a bare split so an over-long line still ends. Passages are at most 480 tokens by bge-small's tokenizer, under its 512-token limit with room for the two tokens it adds. That tokenizer is pinned to the chunker rather than taken from the active embedder, so changing the embedder would not re-cut documents and move every chunk id. Pieces under 24 characters are merged into a neighbour while splitting, so only a document shorter than that yields a shorter passage. The tokenizer is passed as an object, because one named by a string would reach the network even offline. Each passage records the lines its first and last characters fall on.
-4. **Embed** ([`encoder.py`](../../surfsense_local/backend/modules/embedding/encoder.py)). The active index's model, as its spec says, on onnxruntime's CPU provider: the document prefix, tokenize (truncated at the spec's `max_tokens`), pool, normalise, in batches of 32. For bge-small that is no prefix, 512 tokens, CLS pooling and L2 normalisation. No network and no model server. A vector whose width is not the spec's is refused rather than stored.
+4. **Embed** ([`encoder.py`](../../surfsense_local/backend/modules/embedding/encoder.py)). The active index's model, as its spec says, on onnxruntime's CPU provider: the document prefix, tokenize (truncated at the spec's `max_tokens`), pool, normalise, in batches of 32. For bge-small that is no prefix, 512 tokens, CLS pooling and L2 normalisation, and on x86-64 its FP16 file runs upcast to FP32 in memory, about six times as fast ([embedding](embedding.md#precision)). No network and no model server. A vector whose width is not the spec's is refused rather than stored.
5. **Index** ([`indexing.py`](../../surfsense_local/backend/worker/ingestion/indexing.py)). The document's old chunks are deleted, which clears both index tables through the delete trigger, and the new chunks are inserted with their float32 embeddings, followed by one row per chunk in the active index's vector table, and the document records that index in `embedding_index_id`. The keyword index follows by trigger; the vector cannot, because only ingest holds it. Editing a note therefore stops its old text being findable.
6. **Finish.** `finish_job` writes `ready` unless a cancel won the race, and beside it only what the job produced: a file's extracted markdown on `documents.content`. A note's content is the user's and is only read, and ingest never writes `title`, so a rename or a note edit committed while the job ran stands, and the ingest that edit enqueued indexes the new text.
-Cancellation is checked after parsing and after embedding. On any other failure the worker rolls back and re-reads the row. If the document was deleted or cancelled meanwhile it stops; otherwise it writes `failed` with `error_message` set to the exception type and message, cut to 500 characters, notifies, and re-raises so Huey runs the job again, up to two more times. A file whose bytes Docling cannot load (a `ConversionError` caused by a `DocumentLoadError`, such as a damaged Office file) is not run again, because the same bytes are refused the same way: parsing raises `UnreadableFileError` and the task turns it into Huey's `CancelExecution(retry=False)`. Docling raises `ConversionError` too for a file it cannot open yet, such as one another program holds, and for a model that fails on every page, such as running out of memory; those are tried again. A later success clears the message. The sources panel shows it and offers Retry.
+Cancellation is checked after parsing and after embedding, between a recording's windows and between a long Docling reading's batches. A recording or a long Docling reading that gives the queue up rolls back and goes back to `pending` (`pause_job`, unless a cancel won), queued again at `PRIORITY_BULK` for a recording and at the priority it came with for a reading: neither a failure nor a retry. On any other failure the worker rolls back and re-reads the row. If the document was deleted or cancelled meanwhile it stops; otherwise it writes `failed` with `error_message` set to the exception type and message, cut to 500 characters (a recording's failures are sentences written for the person, stored as they are), notifies, and re-raises so Huey runs the job again, up to two more times. A file whose bytes Docling cannot load (a `ConversionError` caused by a `DocumentLoadError`, such as a damaged Office file) is not run again, because the same bytes are refused the same way: parsing raises `UnreadableFileError` and the task turns it into Huey's `CancelExecution(retry=False)`. Docling raises `ConversionError` too for a file it cannot open yet, such as one another program holds, and for a model that fails on every page, such as running out of memory; those are tried again. A recording that would fail the same way again is not run again either ([Recordings](#recordings)). A later success clears the message. The sources panel shows it and offers Retry.
+
+### PDFs with a text layer
+
+A born-digital PDF already holds its text, and pdfium reads it in milliseconds a page, where Docling takes about 3 s ([ADR 0059](../adr/0059-a-born-digital-pdf-is-read-from-its-text-layer.md)). [`pdf_text_layer/`](../../surfsense_local/backend/worker/ingestion/pdf_text_layer/) reads it with pypdfium2 while holding `shared.pdfium.lock`, since pdfium is not thread-safe. It decides page by page ([`pdf_reading/`](../../surfsense_local/backend/worker/ingestion/pdf_reading/)):
+
+- **Every page passes:** the file is its text layer.
+- **Some pages fail, at most half of them:** Docling reads only the runs of consecutive failed pages, each run as one conversion with `page_range` and the usual options (a run longer than 8 pages in batches, [below](#long-docling-readings)), and the text layer the rest. A run's Markdown takes its pages' place, a blank line apart, as the text layer's pages are ([`spliced_text.py`](../../surfsense_local/backend/worker/ingestion/pdf_reading/spliced_text.py)). Such a PDF keeps no figures at ingest, as a text-read one. The log names the pages and the first three reasons, as `Docling reads pages 5, 9 of mixed.pdf: page 5 is ocr:no-text; page 9 is turned 90 degrees`.
+- **Docling reads the whole file**, as before and keeping its figures as it reads, when more than half the pages fail (a scan), when a form field holds an answer, when pdfium cannot read the file, and when no page that passes is `ok:text`, since covers and stamps alone leave the content in pixels ([`docling_pages.py`](../../surfsense_local/backend/worker/ingestion/pdf_reading/docling_pages.py)). It logs `Docling reads : `.
+
+- **Forms.** Before any page is read, a form field holding an answer sends the file to Docling. That is a text, combo or list field with a value that is not blank, or a ticked box. The answer lives in the field, which the text layer never shows, while Docling reads it off the page ([`filled_fields.py`](../../surfsense_local/backend/worker/ingestion/pdf_text_layer/filled_fields.py)). A form nobody filled in is read from its text layer; 11 of the 19 Boston budget PDFs carry one.
+- **Turned pages.** A page with a `/Rotate` fails, because pdfium returns a turned page's lines last to first.
+- **The triage.** [`page_triage.py`](../../surfsense_local/backend/worker/ingestion/pdf_text_layer/page_triage.py) classes every other page by its text, its image objects and, where needed, its drawn path segments. It finds images inside nested forms too, maps each into page space, and clips it to the page's visible box, the media box within the crop box. The tests run in this order:
+
+ | Kind | The page | Read by |
+ |---|---|---|
+ | `ocr:no-text` | under 20 non-space characters, over images covering more than 5% of it | Docling |
+ | `ok:blank` | no characters, no image and at most 40 path segments; with an image or more segments it is `ocr:no-text` | text layer |
+ | `ocr:no-text` | all its live text in the top or bottom 12% of the page box, over images covering more than 5% of it or more than 40 path segments. This catches a scan stamped with a header, a Bates number or a page number, and lettering drawn as outlines | Docling |
+ | `ok:sparse` | under 20 non-space characters otherwise | text layer |
+ | `ocr-layer:scanner` | at least half its text objects invisible, over images covering more than half the page: a scanner's OCR, which may be wrong | Docling |
+ | `ocr:garbled` | more than max(6, 3% of its characters) that its fonts cannot map to Unicode, or more than that many meaningless ones (U+FFFD, private use, control codes), or under 30% letters and digits once dot leaders are set aside | Docling |
+ | `mixed:big-image` | at least 20 characters over images covering more than 65% of it, such as a section cover with its title, where OCR of the photo added only noise | text layer |
+ | `ok:text` | anything else | text layer |
+
+ A file whose pages all pass still goes to Docling when its text is empty, or when no page is `ok:text`, since covers, titles and stamps alone leave the real content in pixels. Any pdfium error also sends the file to Docling, with a warning.
+- **The text** ([`reading.py`](../../surfsense_local/backend/worker/ingestion/pdf_text_layer/reading.py)). Each page's text is pdfium's `get_text_range()`, with line breaks as `\n`. Pages are joined with a blank line, the representation the A/B measured. Within a page, [`paragraph_breaks.py`](../../surfsense_local/backend/worker/ingestion/pdf_text_layer/paragraph_breaks.py) adds a blank line where a paragraph ends, so the chunker cuts there and not at an arbitrary line, as it once cut a paper's Table 1 from its caption and header. Each line's baseline comes from pdfium's character origins, under the same lock: a line starts a paragraph when the gap above it is more than 1.3 times the wider line spacing on either side of it (Word's 8 pt after a paragraph and LaTeX's parskip measured 1.49 to 1.5), so a table's evenly spaced rows and the rule under its header stay one block, and when it jumps up to the next column. A caption line (`Table 1.`, `Figure 2:`, `Exhibit 3 –`) goes with the nearer of the blocks around it, the table under it in a paper or the one above it in a spec. A line on its own, such as a heading, goes with the block after it. Only `\n` is ever added: the text is otherwise the A/B's, character for character. Where a word is hyphenated at a line's end, pdfium removes the line break and leaves U+FFFE where the hyphen was. [`printable_text.py`](../../surfsense_local/backend/worker/ingestion/pdf_text_layer/printable_text.py) joins the word again when a lowercase letter follows, and writes `-` otherwise. It also drops C0 control codes, which python-docx refuses in a draft, after the triage has counted them. The text has no Markdown headings and no pipe tables. A table is one row to a line with its values in order, and an empty cell leaves no trace. Two- and three-column pages read down each column in turn.
+- **Figures.** Ingest keeps none for a PDF read from its text layer, wholly or but for some pages; the figures-only pass does ([Figures](#figures)).
+- **The switch.** `SURFSENSE_LOCAL_PDF_TEXT_LAYER` is read for each PDF ([`switch.py`](../../surfsense_local/backend/worker/ingestion/pdf_text_layer/switch.py)) and is on by default. `off`, or any false value pydantic reads, such as `0` or `no`, in any case, sends every PDF to Docling as before. A value that is neither true nor false logs a warning and means off.
+- **Measured** on the Ryzen 7 5800X with the embedder at FP32. Each file went through the upload route and the pipeline to `ready`, one at a time, in fresh processes with Docling warm:
+
+ | File | Docling | Text layer | CPU-s, Docling / text layer | Passages, Docling / text layer |
+ |---|---:|---:|---:|---:|
+ | Boston Executive Summary, 12 pp | 16.7 s | 1.4 s | 176 / 10 | 13 / 11 |
+ | Boston Operating Budget, 46 pp | 125.6 s | 9.0 s (7.1 s on a repeat) | 1,327 / 63 | 93 / 62 |
+ | hermes spec, LaTeX, 33 pp | 73.1 s | 3.8 s | 863 / 29 | 51 / 35 |
+
+ Reading and triage average 7.1 ms a page; Finance's 85 pages take 0.65 s. Paragraph breaks add 0.36 ms a page on the 19 Boston PDFs and 0.29 ms on the gate's second corpus, 1 ms at most; the passage counts above are with them. They packed short paragraphs across pages: the 16 Boston PDFs read from their text layer gave 601 passages instead of 651. In the answer-quality gate's retrieval, the answer was in the top 5 passages for 44 of 50 Boston questions instead of 41, and for 22 of 23 on the second corpus instead of 20, where both Table 1 questions now find its caption, header and rows in one passage. A gap threshold of 1.2 or 1.4 retrieved the same; 1.5 times the page's median spacing, with no caption or heading rule, found 39 of 50. Chunking and embedding take the rest, about 0.11 s a page in all. Of 36 PDFs from the Boston budget, the agent's live tests and that spec, 32 are read from their text layer. Two Boston volumes send Docling a cover photo with a title under 20 characters, Budget Organization an org chart whose page holds only its running header, and a filled site-visit form goes to Docling whole for its answers.
+- **Measured, failed pages only.** Through the upload route, the queue and a real `worker.py ingest` process, Docling warmed in it first, against the whole file in Docling as before. The machine was not idle: other sessions ran tests, and their CPU is the last column.
+
+ | File | Docling whole | Failed pages only | Worker CPU-s, before / after | Other jobs' CPU-s, before / after |
+ |---|---:|---:|---:|---:|
+ | Boston Education, 12 pp, cover photo | 13.2 s (15.3 s on a first run) | 3.5 s (3.9 s on a first run) | 131 / 23 | 34 / 13 |
+ | Boston Capital Planning, 12 pp, cover photo | 28.6 s (32.2 s on a first run) | 3.4 s (4.9 s on a first run) | 315 / 22 | 78 / 7 |
+ | Generated report, 10 pp, a scan at page 5 and a turned table page at 9 | 17.7 s | 12.7 s | 143 / 95 | 50 / 39 |
+
+ The two volumes' text was exactly the text layer's pages 2 to 12, paragraph breaks included, after Docling's Markdown of page 1 read alone, and the report's its text-layer pages with Docling's Markdown of pages 5 and 9 in their places. In the report, the turned table page is the expensive part.
## Figures
-Ingest keeps the pictures in each source, so the agent can place a source's chart or logo in a document it makes ([agent](agent.md#documents-the-agent-makes)). Chat and search do not use them.
+Ingest keeps the pictures in each source, so the agent can place a source's chart or logo in a document it makes ([agent](agent.md#documents-the-agent-makes)); a Word file read without Docling gets them from the figures-only pass, which its ingest queues as it ends. Chat and search do not use them.
- **Where.** Beside the original, in the document's own folder: `figures/.png`, numbered from 1 in the order the source shows them, and `figures/figures.json`, written last and replaced whole ([`source_figures/layout.py`](../../surfsense_local/backend/modules/documents/source_figures/layout.py)). Deleting the source removes its folder and so its figures, and no table or migration is needed.
@@ -138,16 +188,20 @@ Ingest keeps the pictures in each source, so the agent can place a source's char
{"version": 1, "figures": [{"n": 1, "caption": "Figure 1: Projected yearly costs …", "page": 2, "width": 1104, "height": 620}]}
```
-- **Which pictures** ([`figures/pictures.py`](../../surfsense_local/backend/worker/ingestion/figures/pictures.py)), from the `DoclingDocument` the parse step already made, before it is exported to Markdown:
+- **Which pictures** ([`figures/pictures.py`](../../surfsense_local/backend/worker/ingestion/figures/pictures.py)), from a `DoclingDocument`: the one the parse step made, before it is exported to Markdown, or, for a PDF read wholly or partly from its text layer, the one the figures-only pass makes:
- **PDF:** each picture Docling's layout model finds, drawn charts and pictures in scans included, cropped from its page with pypdfium2 at 2x (144 dpi) after the conversion, with its caption and page. Docling's own `generate_picture_images` stays off, because it keeps every page's render until the conversion ends: about 7 MiB a page, 574 MiB for 80 A4 pages ([`figures/pdf_crops.py`](../../surfsense_local/backend/worker/ingestion/figures/pdf_crops.py)).
- **Word and PowerPoint:** the pixels the file embeds. Docling links no caption for them; PowerPoint gives the slide as the page, Word gives none.
- **An image source** is its own one figure, turned upright by its EXIF orientation.
- Pictures in page headers and footers count, since a letterhead logo is the picture a user asks for. A picture repeated with the same pixels is kept once. A picture that refers to a path or a URL rather than carrying its pixels is skipped, so an uploaded page cannot pull an image from the machine into the library. Every figure is a PNG in RGB, or RGBA when it has transparency, which python-docx and ReportLab both read.
- - A text upload (`.md`, `.txt`, `.csv`, `.html`) keeps none.
+ - A text upload (`.md`, `.txt`, `.csv`, `.html`) keeps none, and neither does a recording: the figures-only pass would hand it to Docling.
- **Never fails an ingest.** A failure while keeping figures is logged and recorded as `figures.json` with no figures and an `error`, and the text is indexed as usual.
- **Names.** A figure is `-`, built from the document when it is listed, never stored ([`source_figures/`](../../surfsense_local/backend/modules/documents/source_figures/)). `list_figures()` answers a source's figures from its `figures.json`; `figure_file()` resolves a name to its PNG only for a source in the caller's workspace.
-- **The figures-only pass.** A source ingested before figures were kept has no `figures.json`. The first `list_figures()` on it, when it is a ready `FILE`, queues `extract_figures(document_id)` on the ingest queue, once, and answers that its figures are being extracted; one still pending or processing gets them from its ingest. The pass reads the original with Docling again, without holding a transaction, and writes the figures, leaving the text and chunks alone ([`figures/backfill.py`](../../surfsense_local/backend/worker/ingestion/figures/backfill.py)). It is not retried, since its failure is recorded in the index.
-- **Cost.** [`scripts/measure_figure_ingest.py`](../../surfsense_local/backend/scripts/measure_figure_ingest.py) converts a file without and with figures, each in a fresh process. On generated files: an 80-page A4 PDF peaked at 2,519 MiB with figures and 2,517 MiB without, and cropping 20 vector charts from a 20-page PDF took 0.1 seconds.
+- **A Word file read without Docling** ([`figures/word_figures_pass.py`](../../surfsense_local/backend/worker/ingestion/figures/word_figures_pass.py)). Its package says where Docling would look: an image related from the main part, a header or a footer, or a `w:drawing`, which Docling draws only with LibreOffice on `PATH` ([`figures/word_pictures.py`](../../surfsense_local/backend/worker/ingestion/figures/word_pictures.py)). With neither, ingest indexes no figures, so asking queues no pass; on 791 reference files that is 519, none of which Docling found a picture in. With an image, ingest drops any earlier figures and, once the file is ready, queues the figures-only pass at bulk priority (10), behind the ingests already queued: text first. With only drawings, nothing is queued until someone asks.
+- **The figures-only pass.** A ready `FILE` with no `figures.json` is one that ingest left without figures. That is every PDF read wholly or partly from its [text layer](#pdfs-with-a-text-layer), a Word file read without Docling whose pass has not run, and any source ingested before figures were kept. The first `list_figures()` on it queues `extract_figures(document_id)` on the ingest queue at `PRIORITY_ASKED` (30), unless a copy waits at that priority or above already. That is ahead of a folder import, since someone waits on it, and behind a note or an upload at 100, since each pass is a Docling run and one Studio draft or refine asks for every source it is grounded on. It then answers that the figures are being extracted. Until the pass has run, the agent hears "its images are being extracted. Ask again in a minute.", and Studio's shelf offers no figures from that source, so a draft places none ([studio](studio.md)). A source still pending or processing gets the same answer and queues nothing.
+ - When a PDF read from its text layer, wholly or but for some pages, has image objects on its pages, a turned page's included, and at most 40 pages, ingest queues the pass itself at `PRIORITY_IDLE` (-10) ([`figures/idle_pass.py`](../../surfsense_local/backend/worker/ingestion/figures/idle_pass.py)). That is below every ingest and every pass someone asked for, so it runs once nothing else waits. Drawing alone does not queue it, since no count of path segments tells a chart drawn as paths from a table's rules: of 183 agent-run PDFs with no image object, more than 30 segments on a page caught the figures of 2 distinct documents (28 files, 27 of them copies of one report) and queued 97 files where the pass finds none, and more than 40 caught none and queued 66. The pass loads Docling's models into the ingest worker, which a text-read PDF otherwise never does. A longer PDF, about 6 CPU-s a page for the pass, and one with no image wait to be asked. An ask while only the idle or bulk copy waits queues one at 30, so it does not wait out a folder import. Whichever copy runs second finds `figures.json` and reads nothing ([`listing.py`](../../surfsense_local/backend/modules/documents/source_figures/listing.py)).
+ - The pass reads the original with Docling again, without holding a transaction, and writes the figures, leaving the text and chunks alone ([`figures/backfill.py`](../../surfsense_local/backend/worker/ingestion/figures/backfill.py)). Once Docling has let go of the file, it checks that the same row, by its `created_at` and `dedup_key`, is still ready with the same original, and writes nothing, not even a failure, when it is not ([`figures/read_source.py`](../../surfsense_local/backend/worker/ingestion/figures/read_source.py)). A source deleted meanwhile has its directory removed; when a later upload has taken its id and directory, only the file the pass read goes, if it is still that file. On a PDF, each page that shows characters from its text layer is read with Docling's layout model alone, without OCR or TableFormer. A scan (no such character, or raster images over half the page, as on a scan stamped with a digital page number) keeps the full reading, since only OCR reads its captions. Two kinds of page the layout reading finds are then read again in full, alone: one with a picture and a caption only OCR could read, with no characters in the text layer because it is drawn as outlines or pixels, by Docling's own rule for where OCR reads ([`figures/captions_for_ocr.py`](../../surfsense_local/backend/worker/ingestion/figures/captions_for_ocr.py)), and one with a picture inside a table, which TableFormer can move in the order. Each page is taken from its last reading, in page order ([`figures/pictures_reading.py`](../../surfsense_local/backend/worker/ingestion/figures/pictures_reading.py), [`figures/run_reading.py`](../../surfsense_local/backend/worker/ingestion/figures/run_reading.py), [`figures/scanned_pages.py`](../../surfsense_local/backend/worker/ingestion/figures/scanned_pages.py)). A page a run's reading lacks is kept as an empty page, so joining the runs leaves every later picture on its own page, as one reading of the whole file would lack that page too ([`docling_batches/lost_pages.py`](../../surfsense_local/backend/worker/ingestion/docling_batches/lost_pages.py)). Any failure on the way, in the scan check too, and a layout reading Docling only partly made fall back to the full reading of the whole file, which Word and PowerPoint always get. It is not retried, since its failure is recorded in the index. A run is at most 8 pages, so the pass can stop between any two, and a scan of more than 8 pages is read the same way, in full runs, rather than in one conversion.
+ - **Giving way** ([`figures/queue_turn.py`](../../surfsense_local/backend/worker/ingestion/figures/queue_turn.py)). Between two runs, once the pass has run 20 s and a pending file or note, a recording included, has its ingest queued at the pass's priority or above, it keeps the runs it has read under `figures/work/` and queues itself again at the priority it was queued with, through the same once-per-priority rule. An idle pass so gives way to every upload, and an asked-for one, at 30, to notes and uploads but not to a folder import, which it would otherwise run ahead of again and give way to again; a pass that gave way is queued again only while its source is still ready; `figures.json` is still written last, whole, and emptying the folder for it removes `work/`. The next copy reads only the runs not kept. The kept runs are named by kind and pages and stamped as a [long Docling reading's](#long-docling-readings) are, so another file's, or a reading with or without the parser pack, are read again; a source deleted meanwhile does not get its folder back. Resumed after every run, the pass kept the same `figures.json` and PNG bytes as one run straight through, and as Docling's whole-file reading, on Education, Capital Planning and a 20-page scan.
+- **Cost.** [`scripts/measure_figure_ingest.py`](../../surfsense_local/backend/scripts/measure_figure_ingest.py) converts a file without and with figures, each in a fresh process. On generated files: an 80-page A4 PDF peaked at 2,519 MiB with figures and 2,517 MiB without, and cropping 20 vector charts from a 20-page PDF took 0.1 seconds. On 12 born-digital PDFs (city budget volumes and sample business reports; 185 pages, 28 figures) the figures-only pass kept the same `figures.json` and the same PNG bytes as the full reading on every one, in 155 s and 1,191 CPU-seconds against 660 s and 6,895: 0.8 s and 6 CPU-seconds a page against 3.6 s and 37. An 85-page budget volume, mostly tables, took 64 s against 453 s, and a 10-page one whose cover and one more page are raster, 18.5 s against 19.6 s. One of the 185 pages was read again for a caption the text layer holds but a drawn shape overlaps, which OCR reads too; that cost it 2 s. The pass loads a second, layout-only pipeline: about 144 MiB more resident memory in a worker that already holds the full one, and the ingest worker keeps both once it has run a pass. Layout alone loses the caption of a generated scan stamped with a digital page number, which is why such a page keeps the full reading.
**Embedding index.** Which model embeds is a property of the library, not a preference: a row in `embedding_indexes` holds the model's spec and names its vector table, which is built at the spec's width when onboarding locks the choice ([`data-model.md`](data-model.md)). `upgrade_to_head()` refuses to start when the table's width differs from the spec's. The per-batch check in `embed()` is the second line of defence. Studio indexes an artifact's body through the same index.
@@ -158,11 +212,74 @@ Ingest keeps the pictures in each source, so the agent can place a source's char
## The queue
- `ingest` is a `SqliteHuey` queue in `huey.db`, a file of its own so the consumer's constant polling never contends for the write lock on `surfsense.db`.
-- `worker-ingest` drains it with one thread. Ingest saturates a CPU and writes to the database the API is serving from, so a second thread would spend its time behind the first one's lock. Studio has its own queue so an import never sits in front of a summary ([ADR 0008](../adr/0008-two-job-queues.md)).
+- `worker-ingest` drains it with one thread. Ingest saturates a CPU and writes to the database the API is serving from, so a second thread would spend its time behind the first one's lock. Studio has its own queue so an import never sits in front of a summary ([ADR 0008](../adr/0008-two-job-queues.md)). An idle consumer polls its queue at least every 0.5 s for ingest and every second for Studio and plugins ([`consumer.py`](../../surfsense_local/backend/worker/consumer.py)): Huey's own default backs off to 10 s, which a packaged worker idle for a minute took to start an upload that its text layer then made ready in about a second.
- The queue survives restarts. Electron starts the workers beside the API and only the API migrates, so each consumer waits until the database is at the latest revision before taking a job ([`wait_for_schema.py`](../../surfsense_local/backend/worker/wait_for_schema.py)); a job queued before an update would otherwise run against the old schema and fail. A job carries only its task's name, so each consumer imports every task before it starts (`import_tasks()`), and [`tests/integration/test_registration.py`](../../surfsense_local/backend/tests/integration/test_registration.py) fails when that list falls behind the task modules on disk.
-- Jobs are enqueued by upload, note creation, a note content edit and retry, and by import ([`import.md`](import.md)). The queue takes the highest priority first: interactive work at 100, a bulk copy and an import at 10. The figures-only pass, `extract_figures`, shares the queue ([Figures](#figures)).
+- Jobs are enqueued by upload, note creation, a note content edit and retry, and by import ([`import.md`](import.md)). The queue takes the highest priority first: interactive work at 100, a bulk copy and an import at 10. Equal priorities run in the order they were queued, since Huey's SQLite storage takes `order by priority desc, id`. The figures-only pass, `extract_figures`, shares the queue: at 30 when someone asks for figures, behind every note and upload but ahead of an import; at 10 when a Word file's ingest queues it as it ends; and at -10 when ingest queues it for a PDF read from its text layer ([Figures](#figures)). After 20 s it gives way to a source whose ingest is queued at its priority or above.
- A source the app quit in the middle of ingesting is failed with `interrupted when the app closed` when the ingest worker next starts, before it takes a job ([`interrupted_documents.py`](../../surfsense_local/backend/worker/interrupted_documents.py)). Huey drops a job as it starts it, so nothing else would end it, and Retry accepts only a failed or cancelled document. A source still `pending` is still queued and is left alone.
-- Each transition the ingest worker makes, to `processing`, `ready` or `failed`, sends a `documents` event keyed by document id, and so does each change the API makes itself: a note written, an upload, a rename or edit, a retry, a cancel, and a delete, whose status is `deleted`. The sources panel reloads its list on each event and whenever a dropped stream is back, so a note a plugin writes shows as it is written; it still refetches every 10 seconds while any row is `pending` or `processing`, in case a notice was lost ([`overview.md`](overview.md#freshness)).
+- A recording's job gives the queue up between windows once it has run a minute and a source that is not a recording is pending ([Recordings](#recordings)), and a long Docling reading between batches once an upload or note is queued at its own priority or above ([Long Docling readings](#long-docling-readings)), so a long file never holds every upload and note behind it. The ingest task is handed its own Huey task (`context=True`), so the job knows the priority it was queued at.
+- Each transition the ingest worker makes, to `processing`, `ready` or `failed`, sends a `documents` event keyed by document id, and a recording's job sends one per transcribed window carrying a transient `progress` `{done_s, total_s}`, never stored, and so does each change the API makes itself: a note written, an upload, a rename or edit, a retry, a cancel, and a delete, whose status is `deleted`. The sources panel reloads its list on each event and whenever a dropped stream is back, except for a transcribed window's event, whose `progress` it keeps on the row when the row already has the event's status ([the sources tree](#the-sources-tree)), so a note a plugin writes shows as it is written; it still refetches every 10 seconds while any row is `pending` or `processing`, in case a notice was lost ([`overview.md`](overview.md#freshness)).
+
+## Long Docling readings
+
+A PDF Docling reads whole, and a run of failed pages, longer than 8 pages is converted a batch of 8 pages at a time with `page_range` ([`docling_batches/`](../../surfsense_local/backend/worker/ingestion/docling_batches/)); 8 pages or fewer is one conversion, as before. A PDF with failed runs reads all their batches as one sequence, so what follows also holds between two short runs. A scan reads at 3 to 5 s a page, so once the reading should give way, a waiting upload waits only for the batch in flight. The figures-only pass reads in runs of at most 8 pages too, kept under `figures/work/` ([Figures](#figures)).
+
+- **Joined.** The batches join into one `DoclingDocument` with `DoclingDocument.concatenate`, after a paragraph that runs on past a batch's last page is joined again by Docling's own rule: an open end, then a word going on, past headers, footers, tables, pictures, captions and footnotes ([`edge_paragraphs.py`](../../surfsense_local/backend/worker/ingestion/docling_batches/edge_paragraphs.py)). Without it a budget volume read in 7-page batches split a paragraph at page 7. A whole-file reading keeps its figures from the joined document. Joining numbers each batch's pages on from where the last ended, so a page Docling could not read is first put back into its batch as an empty page, as one conversion of the file lacks it; otherwise every later picture would land on the page before its own ([`lost_pages.py`](../../surfsense_local/backend/worker/ingestion/docling_batches/lost_pages.py)).
+- **Checkpoint.** Each batch read is kept as its `DoclingDocument`'s JSON, `pages--.json`, replaced whole, in `docling/work/` in the document's folder: a folder, so it is never taken for the original. A stamp names the format, the original's SHA-256, Docling's version and whether the parser pack is there, since the converter reads OCR without RapidOCR's line classifier only with it; a checkpoint whose stamp differs is dropped ([`checkpoint.py`](../../surfsense_local/backend/worker/ingestion/docling_batches/checkpoint.py)). Hashing costs 0.26 s per 100 MiB, once a run. A source deleted meanwhile does not get its folder back.
+- **Giving way** ([`docling_batches/queue_turn.py`](../../surfsense_local/backend/worker/ingestion/docling_batches/queue_turn.py)). Before each batch but the first this job reads, it checks for a cancel and, once the reading has run 60 s, gives the queue up as a recording does, `JobYieldedError` and `pause_job`, and is queued again at the priority it came with, behind the source it gave way to. It gives way to a pending file or note whose ingest is queued at its own priority or above ([`queued_ingests.py`](../../surfsense_local/backend/worker/ingestion/queued_ingests.py)), so an upload's scan is not sent behind a folder copy started after it, and a pending row with no job queued holds nothing up. That source must not be a recording, and must have no `docling/work/` of its own: long readings do not take turns, which would leave all of them waiting until the last batch ([`waiting_work.py`](../../surfsense_local/backend/worker/ingestion/docling_batches/waiting_work.py)). It gives way only when every batch it read is on disk.
+- **Resume.** The next run reads only the batches not kept. A reading that gave way is `pending`, so the startup sweep leaves it alone and its queued copy carries on after a restart.
+- **End.** Read to the last batch, failed or cancelled, the job removes `docling/`. A cancel of a reading waiting in the queue after giving way removes it in the route, the document's or its folder's ([`ended_in_queue.py`](../../surfsense_local/backend/modules/documents/docling_batches/ended_in_queue.py)), and so does the job when a cancel lands as it gives way; a delete removes it with the folder. A worker killed mid-reading leaves it on purpose and the sweep fails the row, so Retry carries on from the batches kept; it stays until the source is retried or deleted. Huey's own retry after a failure starts over.
+- **Proved** with real Docling: on a generated 20-page scan and on Education, read in batches straight through and resumed after every batch, the Markdown, `figures.json` and every PNG were identical to one conversion's.
+- **Measured** on that scan through the upload route, the queue and a real ingest worker, Docling warm, against one conversion, while other sessions ran tests on the machine; Docling took 4.5 to 5 s a page under that load:
+
+ | | One conversion | Batches |
+ |---|---:|---:|
+ | The scan alone, ready | 108.2 s | 94.9 s |
+ | Its worker CPU-s | 889 | 827 |
+ | Other jobs' CPU-s meanwhile | 647 | 372 |
+ | A note written 10 s into the scan, ready after | 81.0 s, after the scan (ready at 91.0 s) | 61.4 s, before the scan (ready at 90.4 s) |
+
+ The gap in the scan's own time is the other jobs' load, not a speedup: batches cost nothing measurable. With the note waiting, the scan gave way once, after its second batch, some 70 s in. The reading runs a minute before it may give way, most of a 20-page scan, so the gain grows with the scan; a note waits at most that minute and the batch in flight, some 40 s on this machine.
+
+## Recordings
+
+A recording becomes a source through the speech model chosen for `speech_to_text` ([selection](local-models/selection.md)), on the second audio.cpp server, which the worker reaches only through the API's gate ([admission](local-models/admission.md#speech-recognition)). The transcript is the recording's `content`, so chunking, embedding, search, citations and the agent's `sources/` mirror take it unchanged ([`worker/ingestion/audio/`](../../surfsense_local/backend/worker/ingestion/audio/), [proposal](../proposals/local-speech-to-text.md#audio-files-as-sources)).
+
+Everything the job keeps lives under the document's `transcript/` folder, since the original must stay the only file beside it ([`recordings/layout.py`](../../surfsense_local/backend/modules/documents/recordings/layout.py)): `transcript/work/` holds `audio16k.raw`, `plan.json`, `windows.jsonl` and `progress.json`, and `transcript/segments.json` is written when the transcript is done.
+
+1. **Ready?** The job asks `GET /llm/transcription/status`. With no speech model it stops before decoding ([below](#no-speech-model-yet)). With the server unavailable, as in the seconds after a model is chosen or a crash, it asks again every 5 s for up to 2 minutes, giving the queue up as a window does, then fails with "The speech recognition engine stopped." and Huey tries again.
+2. **Decode** ([`worker/audio_decode/`](../../surfsense_local/backend/worker/audio_decode/)). The worker's own binary, started as `worker --decode-audio ` ahead of the consumer's imports, as `--run-document-script` is, reads the file's length from its headers (an Ogg file's from its last page, since miniaudio's Vorbis reader opens a path with `fopen`, which on Windows cannot open a name outside the ANSI code page) and checks the data volume for room at 115.2 MB an hour plus 64 MiB, then streams it through pyminiaudio (MIT; WAV, MP3, FLAC, Ogg Vorbis) to 16 kHz mono 16-bit PCM in `audio16k.raw.part`, renamed to `audio16k.raw` once every sample is written. It prints its outcome on one line and exits when the worker's end of its stdin closes. A malformed file kills the child, not the consumer, and the job checks for a cancel every second while it waits. An hour of MP3 decodes in 3.6 s. miniaudio's linear resampler cost 0.08 points of word error over scipy's `resample_poly` on an 8.6-minute 44.1 kHz file (5.91% against 5.83%, under the proposal's 2% relative), so it stays.
+3. **Speech** ([`speech_regions.py`](../../surfsense_local/backend/worker/ingestion/audio/speech_regions.py)). Silero VAD, through the API's own reader ([`voice_activity/silero.py`](../../surfsense_local/backend/modules/llm/transcription/voice_activity/silero.py)) fed a minute at a time off disk, finds speech by its rule (0.5 threshold, at least 250 ms); each span is padded by 200 ms and joined to the next across gaps under 300 ms. It ran at 207 times real time on one thread. Without a staged Silero, or should it fail, all of the recording counts as speech, as a dictated phrase does.
+4. **Windows** ([`windows.py`](../../surfsense_local/backend/worker/ingestion/audio/windows.py)). Speech packs into windows of at most 28 s, which bounds a request's memory, ending at the longest pause once a window holds 20 s, the one nearest 24 s among equals. A single run past 28 s is cut at its quietest 100 ms, by loudness, between 20 and 28 s. Silence between windows is never sent, and every window keeps its place in the recording. The plan goes to `plan.json` once; a job that finds it skips the speech pass, and decodes again only while windows remain to send.
+5. **The loop** ([`window_loop.py`](../../surfsense_local/backend/worker/ingestion/audio/window_loop.py)). For each window not yet in `windows.jsonl`: check for cancel, send it as an in-memory WAV to `POST /internal/transcription/windows` (`last` when it is the final window and no other recording is pending or processing), append `{n, start_s, end_s, text, language, model_id, intent}`, write `progress.json` and send the window's `progress` event. A dropped connection or `503 transcription_unavailable` is asked again after 1, 2, 4, 8 and 16 s, then fails with "The speech recognition engine stopped." `503 waiting` is asked again after the cancel and yield checks: a hold or a dictation has no bound, and memory waits are summed in `progress.json` across yields, failing past 10 minutes with the API's sentence. A window answered by another model than the job began with starts the recording over under the status the API reports then, so one model writes the whole transcript and the language set for the last model is not carried to it.
+6. **Finish.** The transcript becomes `content` through `finish_job`. `transcript/segments.json` keeps `{model_id, language, duration_s, windows: [{line, start_s, end_s, language}]}`, the line being where the window's paragraph is, counted from 1 as chunks count theirs. The row's metadata gains `transcription: {language, duration_s, model}`, written with `merge_metadata`, which reads and writes the one key in a transaction so every other key stands. `transcript/work/` is deleted.
+
+On the measuring machine ([proposal](../proposals/local-speech-to-text.md#measurements)), an 8.6-minute 16 kHz file went from `pending` to `ready` in 60 s in 22 windows, at 5.83% word error.
+
+### The transcript
+
+One header line, then one paragraph per window that holds text, each starting with its time ([`transcript_text.py`](../../surfsense_local/backend/worker/ingestion/audio/transcript_text.py)):
+
+```markdown
+Transcript of meeting.mp3 · 42:10 · Japanese (detected) · Nemotron 3.5 ASR Streaming 0.6B. [hh:mm:ss] marks where each passage starts.
+
+[00:00:00] …
+
+[00:00:26] …
+```
+
+The chunker splits at blank lines, so while a window's paragraph stays under a chunk's 480 tokens, every chunk opens with its time. Measured on Kokoro's sentences at its pace, a 28 s window runs from 78 tokens in English to 214 in Hindi. The header names the language the user set, or else the one most windows were heard in, then the one heard longest, as "Japanese (detected)"; one the model may detect but is not listed for adds "lower accuracy with this model", and none at all reads "language not identified". The model is named as Settings names it, from its curated entry.
+
+### Yielding
+
+The ingest queue has one thread, and an hour at the measured 11.5 times real time holds it for some 5 minutes. After each window but the last, and after each `503 waiting`, once 60 s have passed in this run and a `FILE` or `NOTE` that is not a recording is pending, the job raises `JobYieldedError`, which `pipeline._ingest` catches before its failure handler: the row goes back to `pending` unless cancelled, and the job is queued again at `PRIORITY_BULK`. Recordings never give way to each other. A yielded recording survives a restart, since the startup sweep fails only `processing` rows, and resumes from its checkpoint with its decoded audio. A long Docling reading takes its turn through the same code, by its own rule, and never gives way to a recording ([Long Docling readings](#long-docling-readings)).
+
+### Cancel and retry
+
+Cancel is checked between windows without holding the write lock, and lands within one window; the window in flight finishes. A yielded job's queued copy is revoked as any is. No speech model, a memory wait past its limit, no room on disk ("Transcribing needs about 2.5 GB of free disk space."), no speech ("No speech was found in this recording.", from Silero or from a model that wrote nothing), no audio ("This file has no audio.") and a file the decoder cannot read ("This recording could not be decoded.") fail without retry: `RecordingRefusedError` becomes `CancelExecution(retry=False)` in [`tasks.py`](../../surfsense_local/backend/modules/documents/tasks.py). Anything else uses Huey's two retries, and every retry, Huey's or the user's, resumes from `windows.jsonl` when the model id and the language intent match; another model or language starts over. A run that ends the job, a cancel included, deletes `audio16k.raw` and `progress.json` and keeps the plan and the windows; so does a cancel of a recording waiting in the queue after giving way, which has no run left to do it ([`recordings/ended_run.py`](../../surfsense_local/backend/modules/documents/recordings/ended_run.py)).
+
+### No speech model yet
+
+The upload is accepted. Finding no speech model, the job fails without retry with "Waiting for a speech model." and records `transcription: {awaiting: true}` in the row's metadata, which the next run removes as it starts; a model deleted between windows does the same and keeps the windows written. Choosing a speech model, by `PUT /llm/selection/speech_to_text` or an install with `select: true`, sets every failed row with the flag back to `pending`, clears the flag and its message, queues each at `PRIORITY_BULK` and sends each workspace a `documents` event ([`recordings/awaiting.py`](../../surfsense_local/backend/modules/documents/recordings/awaiting.py)), so nothing is uploaded twice. A row's `transcription.awaiting` reads true only while it is `failed`.
## Known gaps
@@ -172,5 +289,21 @@ Ingest keeps the pictures in each source, so the agent can place a source's char
- The tree's name filter runs on the client over the full listing, folding case with `toLocaleLowerCase`, which is close to the server's `name_key` but not identical; there is no `GET /folders/search`.
- There is no multi-select with Shift or Ctrl, so Move to… and Delete act on one row at a time.
- Parsing runs on the CPU. Windows and Linux ship CPU-only torch and no CUDA payload ([ADR 0012](../adr/0012-vulkan-only-gpu-backend.md)).
+- A PDF read from its text layer holds only what that layer holds. Content drawn only in pixels on a page that also has running text is lost. That covers raster org charts under 65% of the page, such as Finance's seven at 12 to 35%, a generated bar chart whose values exist only as pixels, and a picture under a cover title of 20 characters or more. Docling's Markdown lost these too, as ``.
+- A wrong ToUnicode map is not detected. A generated PDF whose map shifts every letter by one reads as fluent `ok:text` and is indexed as it is; Docling read it no better.
+- A filled form field sends the whole file to Docling, and so do failed pages over half the file. Text spliced from the two readers meets at a page break: a paragraph running from a text-layer page into a page Docling read is cut there, and only Docling's pages carry Markdown headings and pipe tables.
+- A cover photo whose title is under 20 characters, or a picture page whose only live text is its running header, is read by Docling, which adds OCR of the photo. Education's and Capital Planning's covers are (titles of 11 and 18 characters), and Budget Organization's org chart on page 3, which Docling's OCR reads as 272 mostly misspelled words.
+- A text-read table is lines of values with no column boundaries. On a second corpus the text layer answered 19 of 30 table questions to Docling's 27. Most misses came where the chunker cut a paper's Table 1 from its caption and header, which paragraph breaks now keep in one passage: measured again, it answered 28 of 30 to Docling's 27. The rest came where a table's rows begin with a number, as in the spec's roadmap, `5 Dispatcher …` on one line and `6 Gateway slash command …` on the next, which the breaks do not change.
+- A table whose rows are spaced like paragraphs, as when its cells wrap to two lines, gets a blank line between rows, so a long one can still be cut between rows. A sentence that wraps so that `Table 2:` starts a line is taken for a caption.
+- The figures-only pass gives way to a waiting source, but not to a chat turn, so a local model shares the CPU with it.
+- `documents` has no `AUTOINCREMENT`, so a deleted source's id goes to the next document. Upload and the figures pass remove what a delete leaves ([Retry, cancel and delete](#retry-cancel-and-delete)), but a file another process holds open through both, such as an original streaming to a download, can still be found in the new document's directory. `sqlite_autoincrement` needs a migration that rebuilds the table.
+- A chart drawn as paths in a text-read PDF with no image object is kept as a figure only once someone asks for figures, since no signal measured yet tells it from a table's rules. Filled or curved path segments, counted on distinct documents, are the next to measure.
+- Long Docling readings queued together do not finish in the order queued: each gives way once to the next, which has not given way yet, so the last one finishes first. In a bulk copy, a long reading gives way after its minute to the next file of the copy, queued at the same priority, and goes back behind the rest of the copy, so it is ready after the copy's other files. A long reading sends no progress, which the sources panel shows for a recording only.
+- The Word style cache replaces python-docx's `DocumentPart.get_style` during a conversion, as Docling 2.125.0 and python-docx 1.2.0 read styles. A release that reads them another way makes long Word files slow again without changing their text; `STYLE_LOOKUPS` in [`tests/integration/worker/test_docx_style_cache.py`](../../surfsense_local/backend/tests/integration/worker/test_docx_style_cache.py) pins the lookups on a generated contract to catch it. The fix is not yet offered upstream.
- A picture in a Word or PowerPoint file is kept with no caption, even under a paragraph in Word's Caption style, because Docling links none; only a PDF's figures carry theirs.
+- The figures-only pass reads a born-digital page without OCR unless a picture there may have a caption only OCR reads. Other text missing from the text layer, such as a paragraph drawn as outlines between a picture and its caption, only the full reading sees, and it can change which caption a picture gets; the pass matched the full reading on every file measured ([Figures](#figures)).
+- The Word reader writes an equation's characters where Docling writes LaTeX, and fences no code. A Word file's figures still wait on Docling's import, in the figures-only pass.
+- A Studio draft or refine from a Word source whose figures pass has not run yet places none of its pictures: it offers only the figures on disk ([`figure_shelf.py`](../../surfsense_local/backend/worker/studio/office/document/figure_shelf.py)), and the pass runs behind the ingests queued before it, a whole folder import included. Asking queues a copy ahead of the import, so a later draft has them.
+- Recordings take WAV, MP3, FLAC and Ogg Vorbis only; Opus, M4A, AAC and audio-only WebM wait for a decode-only LGPL FFmpeg ([proposal](../proposals/local-speech-to-text.md#order-of-work), step 9).
+- A recording cannot be played, nor a citation of one opened at its time; `GET .../original` does not commit before streaming, which a player would need ([proposal](../proposals/local-speech-to-text.md#order-of-work), step 10).
- OCR reads one script family per converter: PP-OCRv6 covers Latin, Chinese and Japanese, so scanned Korean, Cyrillic, Devanagari or Arabic text is misread.
diff --git a/docs/architecture/embedding.md b/docs/architecture/embedding.md
index 382565ac08..35e8de4ec0 100644
--- a/docs/architecture/embedding.md
+++ b/docs/architecture/embedding.md
@@ -3,7 +3,7 @@
Which model turns passages and questions into vectors. It is chosen once, in onboarding, and fixed for the whole library: every vector in it was made by that model, and changing it means embedding everything again, which is not built ([proposal](../proposals/embedding-model-change.md)).
**Code:** [`modules/embedding/`](../../surfsense_local/backend/modules/embedding/), [`engines/onnxruntime/`](../../surfsense_local/backend/modules/llm/catalog/local/engines/onnxruntime/), [`frontend/src/features/onboarding/model-step/use-embedding-step.ts`](../../surfsense_local/frontend/src/features/onboarding/model-step/kinds/use-embedding-step.ts), [`frontend/src/features/embedding/`](../../surfsense_local/frontend/src/features/embedding/)
-**Decisions:** [ADR 0007](../adr/0007-bundled-embeddings.md), [ADR 0036](../adr/0036-the-index-records-its-embedder.md), [ADR 0037](../adr/0037-embedding-is-a-type-not-a-slot.md)
+**Decisions:** [ADR 0007](../adr/0007-bundled-embeddings.md), [ADR 0036](../adr/0036-the-index-records-its-embedder.md), [ADR 0037](../adr/0037-embedding-is-a-type-not-a-slot.md), [ADR 0058](../adr/0058-the-bundled-embedder-runs-at-fp32-on-x86-64.md)
## The index
@@ -23,6 +23,29 @@ The bundled bge-small is `model_optimized.onnx` from `Qdrant/bge-small-en-v1.5-o
An int8 export of the same model (`model_quantized.onnx` from `Xenova/bge-small-en-v1.5`, 34.0 MB) is half the size and embeds chunk-sized text 1.6 times as fast on that machine, but it is not the same vector space: against the bundled file its vectors have a mean cosine of 0.997 and a minimum of 0.978 over the retrieval eval's 66 queries (0.998 and 0.992 over 119 short passages of its corpus), and one of those queries changes rank by one place, both on a fresh index and when only the query side changes. The top-5 rate is 91% for both, which that eval cannot tell apart. Every library that embeds with bge-small was built with the bundled file, which is pinned by hash, so another build of it can replace it there only through a re-embed ([proposal](../proposals/embedding-model-change.md)).
+## Precision
+
+On x86-64, onnxruntime's CPU provider runs the bundled file's FP16 MatMuls through a slow kernel, so there the encoder runs it upcast to FP32 ([ADR 0058](../adr/0058-the-bundled-embedder-runs-at-fp32-on-x86-64.md)). When the active spec is the bundled bge-small (same repo, revision and pinned weights) and the switch below says to, [`encoder.py`](../../surfsense_local/backend/modules/embedding/encoder.py) reads `model_optimized.onnx`, checks its sha256 against the pin, rewrites every FLOAT16 initializer, Constant tensor, Cast target and declared type as FLOAT in memory, and opens the session from those bytes. The rewrite is stdlib and numpy over the protobuf wire format, since the frozen app has no `onnx` package, and it refuses any part it cannot rewrite whole ([`fp32_upcast.py`](../../surfsense_local/backend/modules/embedding/fp32_upcast.py)). A hash mismatch, a refusal or an onnxruntime error logs one warning for that session load and opens the file as shipped. Nothing on disk changes, nor the index's recorded spec or its vector table: the pinned file still defines the vector space, and the runtime only executes it at higher precision.
+
+`SURFSENSE_LOCAL_EMBED_PRECISION`, in any case ([`precision.py`](../../surfsense_local/backend/modules/embedding/precision.py)):
+
+| Value | Does |
+|---|---|
+| `auto`, the default | upcasts on an x86-64 build, `sysconfig.get_platform()` ending in `amd64` or `x86_64`: the Windows and Linux x64 builds, and the Windows one under emulation on ARM, which `platform.machine()` reports as ARM64. macOS ships arm64 only and stays FP16 |
+| `fp16` | the file as shipped, as before |
+| `fp32` | upcasts on any architecture |
+| anything else | logs a warning and means `fp16` |
+
+Curated and Hugging Face embedders are never upcast, whatever the switch says.
+
+Measured on a Ryzen 7 5800X under Windows with onnxruntime 1.29.0, through `embed()` in a fresh process for each precision, on 64 passages cut by the app's chunker (a mean of 345 tokens, batches of 32), with other work on the machine: 1.88 passages a second as shipped, 11.73 upcast, 6.2 times as fast. FP16 barely scales with threads: an earlier run found 1.86 a second at one thread and 2.04 at 8, against 4.01 and 11.6 upcast. At 4 threads the upcast was 4.1 times as fast, so a 4-core laptop should gain about that. The vectors differ only in their last digits, a cosine of at least 0.99999958 between the two, and the retrieval eval through the app's hybrid search, on a fresh index for each, put the answer at the same rank on all 266 queries (260 in the top 5 both ways).
+
+Each process that embeds, `api`, `worker-ingest` and `worker-studio`, pays for it ([overview](overview.md#processes)):
+
+- The first load takes 0.40 s instead of 0.18: reading 0.02 s, hashing 0.03 s, the rewrite 0.18 s and the session 0.15 s.
+- Resident memory is 52 MB higher after the load (264 MB against 212) and 18 MB higher after embedding (1,180 against 1,162), and peaks 288 MB higher during the load (511 against 223).
+- onnxruntime's wrapper keeps the model's bytes to rebuild the session on a provider fallback, which a CPU-only session never takes, so the encoder clears its private `InferenceSession._model_bytes`; holding them cost 126 MB. [`test_encoder_fp32.py`](../../surfsense_local/backend/tests/unit/embedding/test_encoder_fp32.py) fails when a session keeps any bytes, so an onnxruntime upgrade that renames it is caught, and the encoder logs a warning when the attribute is gone.
+
## Hugging Face
`GET /embedding/huggingface/search?q=` lists repos tagged `sentence-similarity` or `feature-extraction`, most downloaded first. `GET /embedding/huggingface/repo/{repo}` opens one, without downloading weights, as a catalog row with one build, or none and why. Both answer in the GGUF search's shapes (`SearchRead`, `RepoRead`), so onboarding renders them through the same search as the chat step:
@@ -43,3 +66,5 @@ A model that fails is deleted and the install says why. One that passes keeps it
- A Hugging Face repo deleted or made private after it was chosen cannot be downloaded again; nothing can repair the library until changing the model is built.
- A remote embedder is not offered yet ([proposal](../proposals/embedding-model-choice.md)).
+- The FP32 upcast is measured on one machine, a Ryzen 7 5800X under Windows. Linux x64, a 4-core laptop and the x64 build emulated on Windows on ARM are not measured.
+- macOS runs the bundled file at FP16: the upcast has not been measured on Apple silicon.
diff --git a/docs/architecture/license/app.md b/docs/architecture/license/app.md
index 2c9b504caa..1b248ba0a4 100644
--- a/docs/architecture/license/app.md
+++ b/docs/architecture/license/app.md
@@ -1,6 +1,6 @@
# License in the desktop app
-The desktop app imports a Keygen license file and verifies it offline, against Ed25519 public keys compiled into the binary, and never contacts Keygen. A license is meant to unlock paid plugins, which are not built yet, and priority support, and a trial the same for its term; in the app today it changes only what Settings and the sidebar show. No state of the license ever disables the app. The check on the machine is honesty for the interface, not enforcement: the app is public and can be rebuilt without it, so enforcement belongs to the servers a paid plugin calls.
+The desktop app imports a Keygen license file and verifies it offline, against Ed25519 public keys compiled into the binary, and never contacts Keygen. A license unlocks SurfSense's paid plugins and priority support, and a trial the same for its term; in the app it changes only what Settings and the sidebar show, and its key is sent to SurfSense's plugins. No state of the license ever disables the app. The check on the machine is honesty for the interface, not enforcement: the app is public and can be rebuilt without it, so enforcement belongs to the servers a paid plugin calls.
**Code:** [`surfsense_local/backend/modules/license/`](../../../surfsense_local/backend/modules/license/), [`surfsense_local/frontend/src/features/license/`](../../../surfsense_local/frontend/src/features/license/)
**Decisions:** [ADR 0019](../../adr/0019-offline-licenses.md)
@@ -9,9 +9,9 @@ The file format is [contract 1](../../contracts/01-license-file.md); the hosted
## What the license gates
-Paid plugins and priority support, nothing else, and neither is in the app yet: plugins are a proposal, and priority support is offered in the license email. Expiry stops those two and leaves the app, its data and its updates alone. Updates are free for everyone and the updater has no license logic: with public installers, an expired user could delete the license file and become a free user, so gating updates would be theater. `maxUsers` is shown, never enforced.
+Paid plugins and priority support, nothing else: priority support is offered in the license email. Expiry stops those two and leaves the app, its data and its updates alone. Updates are free for everyone and the updater has no license logic: with public installers, an expired user could delete the license file and become a free user, so gating updates would be theater. `maxUsers` is shown, never enforced.
-The [plugins proposal](../../proposals/plugins/README.md) specifies that a paid plugin installs and runs only with an unexpired license, a trial included. Inside the app, Settings and the sidebar are what read the license state.
+SurfSense's paid plugins run on its plugin host, and the app sends every request to them the imported key as `Authorization: License `, without checking its expiry ([plugins](../plugins.md#surfsenses-own-plugins)); the host checks it with Keygen on every call ([plugin host](../plugin-host.md#the-license-check)). Importing, replacing or removing a license asks the plugin host straight away whether it unlocks them, so Settings › Plugins, `@` and the agent follow at once ([plugins](../plugins.md#the-license-verdict)). Inside the app, Settings and the sidebar are what read the license state.
## Verifying a file
diff --git a/docs/architecture/local-models/admission.md b/docs/architecture/local-models/admission.md
index bd07cf6d76..39b660e99a 100644
--- a/docs/architecture/local-models/admission.md
+++ b/docs/architecture/local-models/admission.md
@@ -1,9 +1,9 @@
# Admission and parallel slots
-The local runtime serves up to four replies at once from one shared cache, and every request that generates on it is admitted first, by one gate in the API. Remote models are never admitted: each provider has its own servers.
+The local runtime serves up to four replies at once from one shared cache, and every request that generates on it is admitted first, by one gate in the API. Remote models are never admitted: each provider has its own servers. Speech recognition has a gate of its own, for the one request its server runs at a time ([below](#speech-recognition)).
-**Code:** [`modules/llm/admission/`](../../../surfsense_local/backend/modules/llm/admission/), [`modules/llm/model_route/`](../../../surfsense_local/backend/modules/llm/model_route/), [`modules/llm/fit/plan_load.py`](../../../surfsense_local/backend/modules/llm/fit/plan_load.py), [`providers/llamacpp/preset.py`](../../../surfsense_local/backend/modules/llm/providers/llamacpp/preset.py)
-**Decisions:** [ADR 0048](../../adr/0048-the-api-is-the-only-path-to-a-text-model.md), [ADR 0011](../../adr/0011-llama-cpp-local-runtime.md)
+**Code:** [`modules/llm/admission/`](../../../surfsense_local/backend/modules/llm/admission/), [`modules/llm/model_route/`](../../../surfsense_local/backend/modules/llm/model_route/), [`modules/llm/fit/plan_load.py`](../../../surfsense_local/backend/modules/llm/fit/plan_load.py), [`providers/llamacpp/preset.py`](../../../surfsense_local/backend/modules/llm/providers/llamacpp/preset.py), [`modules/llm/transcription/`](../../../surfsense_local/backend/modules/llm/transcription/)
+**Decisions:** [ADR 0048](../../adr/0048-the-api-is-the-only-path-to-a-text-model.md), [ADR 0011](../../adr/0011-llama-cpp-local-runtime.md), [ADR 0056](../../adr/0056-speech-recognition-runs-on-a-second-audiocpp-server.md)
## Slots
@@ -45,6 +45,20 @@ One pool per loaded model, because in router mode each is its own child with its
Before a local image is drawn, the text models leave the graphics card. `POST /internal/models/text/yield`, held open by the Studio worker for the whole image ([`runtime_hold.py`](../../../surfsense_local/backend/modules/llm/model_route/runtime_hold.py)), pauses every pool, waits for running generations to finish, unloads every loaded text model, then answers `yielded`. Local text sent meanwhile waits in line; when the worker closes the request, or dies and drops it, the pools resume and the router loads the chat model again on its next request.
+The same hold, which a podcast also takes while it voices, pauses speech recognition's recordings: no new window starts, the one in flight finishes, then the speech model is unloaded by id before `yielded` is sent. Dictation goes on during the hold, behind the memory check, and its phrases share the CPU with the voicing ([below](#speech-recognition)).
+
+## Speech recognition
+
+The transcription server, audio.cpp run a second time, holds one model and runs one request at a time, and only the API reaches it ([ADR 0056](../../adr/0056-speech-recognition-runs-on-a-second-audiocpp-server.md)). Every request goes through one gate, [`gate.py`](../../../surfsense_local/backend/modules/llm/transcription/gate.py), so a speaker never waits behind a recording.
+
+- **Dictation first.** A phrase waits at most for the request in flight, a recording's window of up to 28 s: about 2.5 s at the measured 11.5x real time. No window starts while a phrase waits.
+- **An open dictation holds the server.** From `POST /llm/transcription/dictations` until its `DELETE`, 20 s without a phrase or keepalive, or 6 minutes ([`sessions.py`](../../../surfsense_local/backend/modules/llm/transcription/sessions.py)), no window starts. Opening one ends any other; phrases already sent for it still answer, and later ones are `404 dictation_ended`.
+- **A window waits at most 30 s** at `POST /internal/transcription/windows`, the ingest worker's route, then answers `503` with the code `waiting` and its reason: `dictation`, `hold`, `memory` with both sizes, or `busy` behind another window. The worker asks again.
+- **Memory** ([`memory_admission.py`](../../../surfsense_local/backend/modules/llm/transcription/memory_admission.py)). Before a request that would load the model, the server's `GET /v1/models` is read: a model already `loaded` needs no check, since the operating system's figure leaves it out. Otherwise the entry's measured `peak_mb` plus 1 GiB must be available, the podcast's rule ([studio](../studio.md#voicing-a-podcast)). Opening a dictation or sending a phrase short of it is `409 transcription_needs_memory`, with `needed_bytes`, `available_bytes` and the sentence: "Transcribing needs about 2.9 GB free; this computer has 1.8 GB." A window short of it waits off the gate, looking again every 5 s, so no phrase waits behind it. Speech never unloads or holds the chat model: reloading one costs 10 to 26 s, and dictated text is about to go to it.
+- **Residency.** Opening a dictation sends an unloaded model 0.5 s of silence, so it loads while the speaker starts. A recording's last window, sent with `last`, unloads the model by id unless a dictation is open, as the podcast does when it ends. The hold above unloads it too. The server's 5-minute idle unload is the backstop.
+- **Each request** ([`providers/audiocpp/transcription.py`](../../../surfsense_local/backend/modules/llm/providers/audiocpp/transcription.py), behind the `Transcriber` protocol) is the clip as a WAV from memory, multipart to `/v1/audio/transcriptions/details`, with the model's id and the language's locale, or none to detect it. Its deadline is the longer of 120 s and 20 times the audio's length, so a hung server fails one window, not the job. A refused or dropped connection, or a `503`, as while the server restarts, is asked once more after 2 s; then the route answers `503 transcription_unavailable`, as it does at once for a request past its deadline or a reply with no text to read. The request holds the model in use ([`activity.py`](../../../surfsense_local/backend/modules/llm/activity.py)), so a delete during it is refused, and a phrase after the model is deleted ends its dictation with `409 needs_speech_model`.
+- **A phrase** is a 16 kHz mono 16-bit WAV of at most 30 s, checked with the standard library (`422 unfit_audio` otherwise). Silero VAD ([`voice_activity/`](../../../surfsense_local/backend/modules/llm/transcription/voice_activity/)), on the onnxruntime already in the API and on one thread, answers `no_speech` for under 250 ms of speech without asking the model. The text loses Nemotron's locale tags, the last of which names the language; a sentence with no closing punctuation comes back untagged, as French and Hindi did in the packaging test, and then the language is the one the user set, or under auto the one a script names when only one listed language writes it (Devanagari, Han, kana, Hangul, Arabic), else none ([`languages.py`](../../../surfsense_local/backend/modules/llm/transcription/languages.py)). It then goes through the cleanup the [proposal](../../proposals/local-speech-to-text.md#audio-files-as-sources) sets, which also joins a comma or full stop written apart from its word, as Canary writes German ([`cleanup.py`](../../../surfsense_local/backend/modules/llm/transcription/cleanup.py)). `GET /llm/transcription/status` says whether dictation can start: `ready`, or `needs_speech_model` or `transcription_unavailable`.
+
## Known gaps
- Studio's image and audio calls still reach their servers directly, so the worker keeps the key-decryption secret for remote ones.
diff --git a/docs/architecture/local-models/catalog.md b/docs/architecture/local-models/catalog.md
index 883c34d409..ddf43f4f25 100644
--- a/docs/architecture/local-models/catalog.md
+++ b/docs/architecture/local-models/catalog.md
@@ -28,8 +28,12 @@ Every row is a `LocalRow` ([`rows.py`](../../../surfsense_local/backend/modules/
its types (for an image model, the `tasks` its entry names: `generate` is `image_gen`, `edit` is `image_edit`), to which the route adds `selectable_for` ([`selection.md`](selection.md)), its support
(`context`, `reads_images`, `tools`, `reasoning`), whether the bundled runtime can
run it and why not, the `engine` that offered it, its builds, the build it leads
-with and why, and the star. An audio row adds `voicing`: the memory measured while
-voicing, the voice count and the languages, from its manifest entry. Each build is a set of files with roles, and carries
+with and why, and the star. A voice model's row adds `voicing`: the memory measured while
+voicing, the voice count and the languages, from its manifest entry. A speech
+recogniser's adds `transcription` instead: `peak_mb`, the memory of a first
+request with the load; `window_s`, the longest window a request carries;
+`detects_language`; and `languages`, the ISO 639-1 codes it is listed for, best
+first. Each build is a set of files with roles, and carries
its fit, badge, whether it is installed, whether it is recommended, and whether it
reads images. An image or audio build's `fit` and `badge` are `null`: neither
sd.cpp nor audio.cpp has a fit estimate, so the row states the download size and
@@ -40,8 +44,9 @@ of them.
sd.cpp's are the six curated image models and two video models, only when Electron handed the API an
images folder, which it does only when it staged sd-server, in dev or packaged
([`sidecars/boot.ts`](../../../surfsense_local/electron/src/main/sidecars/boot.ts); [packaging](../packaging.md)).
-audio.cpp's are the three curated audio models, only when Electron handed the API
-an audio folder, which it does only when it staged audio.cpp's server.
+audio.cpp's are the three curated voice models and the two speech recognisers,
+Nemotron 3.5 ASR Streaming 0.6B and Canary 180M Flash, only when Electron handed the API an audio
+folder, which it does only when it staged audio.cpp's server.
onnxruntime's are the curated embedders, then any Hugging Face embedder
downloaded and checked ([embedding](../embedding.md)), always: they run in the API and the
workers, so no runtime has to be staged. An embedder row adds `description`, what
@@ -78,21 +83,25 @@ file of a build lands in it (`landing`), its rows, whether it `holds` an install
`after_install` (llama.cpp rewrites the preset, waits for the router and warms
the model; sd.cpp has nothing to do, since sd-server takes its model at launch,
for the Studio job that needs it;
-audio.cpp rewrites `server.json`), what to settle `after_remove` (audio.cpp
-rewrites `server.json`), and what to do `on_startup` (llama.cpp writes the
-preset; sd.cpp records legacy downloads; audio.cpp writes `server.json`).
+audio.cpp rewrites `server.json` for a voice model, and writes
+`transcription.json` for either), what to settle `after_remove` (audio.cpp
+rewrites `server.json` and writes `transcription.json`), and what to do
+`on_startup` (llama.cpp writes the preset; sd.cpp records legacy downloads;
+audio.cpp writes both).
When a catalog read finds a readable llama.cpp model copied into the folder
that the preset does not name, it rewrites the preset once; later reads with no
new model leave it alone, so Electron does not restart the router repeatedly.
-The selection an install fills is the engine's `model_type` and `provider`.
+The selection an install fills is the row's first slot, or the type the request
+names, under the engine's `provider`; audio.cpp fills `audio_gen` with a voice
+model and `speech_to_text` with a recogniser.
| | [`engines/llamacpp/`](../../../surfsense_local/backend/modules/llm/catalog/local/engines/llamacpp/) | [`engines/sdcpp/`](../../../surfsense_local/backend/modules/llm/catalog/local/engines/sdcpp/) | [`engines/audiocpp/`](../../../surfsense_local/backend/modules/llm/catalog/local/engines/audiocpp/) | [`engines/onnxruntime/`](../../../surfsense_local/backend/modules/llm/catalog/local/engines/onnxruntime/) |
|---|---|---|---|---|
| Evidence | GGUF metadata keys | tensor names, as sd.cpp dispatches on them (`evidence.py`) | the family key, read from the header's front (`evidence.py`) | the repo config's `model_type`, and its pipeline tag |
-| Manifest fields | `context` (required), `shape`, `template`, `sampling` | `image` defaults, or `video` ones for a video model: one of them (required) | `audio`: voices, languages, sample rate, measured memory (required) | `embedding`: width, pooling, normalisation, prefixes, `max_tokens`, `semantic_weight`, `batch`, and `parity` for a converted build (required) |
+| Manifest fields | `context` (required), `shape`, `template`, `sampling` | `image` defaults, or `video` ones for a video model: one of them (required) | `audio` for a voice model: voices, languages, sample rate, measured memory; or `transcription` for a recogniser: languages, locales, request options, window, measured memory (one of them, required) | `embedding`: width, pooling, normalisation, prefixes, `max_tokens`, `semantic_weight`, `batch`, and `parity` for a converted build (required) |
| Which files make a build | every build a repo offers, each with the repo's projector (`builds/in_repo.py`) | a GGUF at the repo's root, with the VAE and text encoder the entry names from their own repos (`builds/in_repo.py`) | every GGUF in the model's own folder of a shared repo, alone (`builds/in_repo.py`) | the ONNX weights the entry names, with the repo's `tokenizer.json`, which every build must carry |
| Default build | the first in a preference order led by `UD-Q4_K_XL` (`builds/choice/`) | the first the entry pins, in its reviewed order (`builds/choice.py`) | the first the entry pins, in its reviewed order (`builds/choice.py`) | its only build: another would be another vector space |
-| Its folder | `models_folder/`: the scan, the preset, readiness | `images_folder/`: its files, where each lands, legacy downloads, the installed image; `launch.py`: its flags | `audio_folder/`: its files, the installed models, `server.json` | `embeddings//` under the data directory, not the models pack, which a packaged app ships read-only; bge-small is read in place from the pack |
+| Its folder | `models_folder/`: the scan, the preset, readiness | `images_folder/`: its files, where each lands, legacy downloads, the installed image; `launch.py`: its flags | `audio_folder/`: its files, the installed voice models and recognisers, `server.json` and `transcription.json` | `embeddings//` under the data directory, not the models pack, which a packaged app ships read-only; bge-small is read in place from the pack |
| Also | support, pricing, the recommended build, the lead build, the star, search | | | `spec_for`: an entry as the spec an index stores |
A slice holds what the catalog knows about a runtime. Running it stays outside
@@ -151,8 +160,9 @@ preferred first, and nothing in it is a score.
- **An entry carries its engine's fields and no others.** The classifier reads
`evidence`, the registry names the engine, and the engine names the fields it
reads and the ones it needs. A text model needs `context`; an image model
- needs `image` defaults, a video model `video` ones, never both, and an audio
- model needs `audio`, and none of them carries
+ needs `image` defaults, a video model `video` ones, never both; an audio.cpp
+ voice model needs `audio` and a recogniser `transcription`, never both, the
+ one its type names; and none of them carries
`context`, `shape`, `template` or `sampling`, because sd.cpp and audio.cpp read
none of them. A field no engine reads would be reviewed and trusted while doing
nothing. `validated` records `llama_cpp`, `sd_cpp` or `audio_cpp`, the runtime
@@ -167,12 +177,28 @@ preferred first, and nothing in it is a score.
a request's longest chunk, with `chunk_steps`, smaller chunk sizes and their
peaks, where one was measured. A model needs at least two voices, since a podcast has two
speakers; ids do not repeat, and a voice speaks only listed languages.
+- **A recogniser commits its languages and its memory**, which the server states
+ neither. `transcription` holds the sample rate windows are sent at; `peak_mb`,
+ measured on the first request with the load; `window_s`, the longest window a
+ request carries; `detects_language`; `languages`, the ISO 639-1 codes the
+ screen lists, those measured usable, best first; `family_codes`, the locale
+ the family takes for each (Nemotron takes `ja-JP`); `detected_only`, locales
+ it may detect without being listed for them; and `request_options`, written
+ as the server entry's `default_request_options`, since the multipart routes
+ take none. The schema refuses a language listed twice, a `family_codes` key
+ outside `languages`, a `detected_only` key inside it, a listed language with
+ no `family_codes` locale for a family that takes locales, since pinning it
+ would send the bare code, and a locale or request
+ option the family does not take at the pinned server: `RECOGNISER_FAMILIES`
+ in [`manifest_fields.py`](../../../surfsense_local/backend/modules/llm/catalog/local/engines/audiocpp/manifest_fields.py)
+ copies each family's locales from its model spec and its options from its
+ `loader.cpp`, audio.cpp v0.8.2's.
- **`aliases` fold a download into its curated row.** A downloaded file shows as
a curated build when its install record names the model's `source_repo`, an
alias or a build's repo with the same quantization, or, with no record, when
its file name is the build's own.
-The shipped nineteen, most preferred first within each type. Eight
+The shipped twenty, most preferred first within each type. Eight
chat models, all from `unsloth/*-GGUF`: Qwen3.8 27B (8 builds, reads images)
leads, as the one local model whose remote runs passed the agent ladder; then,
with 18 builds each, Qwen3 32B, 14B, 8B, 4B, Gemma 3 4B (reads images), Qwen3
@@ -195,8 +221,16 @@ not cite) leads while no clip is measured, then Wan2.2 TI2V 5B (`Q4_0` then
yet. Three audio models, one file per build from their folders of
`audio-cpp/audio.cpp-gguf`: Kokoro 82M (`Q8_0`, then `BF16`), Supertonic 3 (`F16`,
then `orig`; its `q8_0` file is the `orig` file under another name) and KittenTTS
-Mini 0.8 (`orig`). Only the three audio defaults are validated, each voiced on
-audio.cpp v0.8.2. Each engine's catalog offers its own entries and skips the rest. `LocalManifest` rejects a wrong version, a repeated id or
+Mini 0.8 (`orig`). One speech recogniser from the same repo, Nemotron 3.5 ASR
+Streaming 0.6B (`Q8_0` alone, 930.6 MB), listed for 26 languages, measured at
+1,776 MiB on its first request and transcribing 28 s windows
+([proposal](../../proposals/local-speech-to-text.md#measurements)); its licence,
+OpenMDW-1.1, is `other` on the card. Then Canary 180M Flash (`Q8_0` alone,
+249.5 MB), the light one for English, listed for English, German and French,
+measured at 783 MiB on its first request on an Apple M2; it cannot detect the
+language, and its licence is CC-BY-4.0. Spanish is in its family but not
+listed: v0.8.2 drops every "ñ" it writes. Only the three voice defaults and
+the two recognisers' `Q8_0` are validated, each run on audio.cpp v0.8.2. Each engine's catalog offers its own entries and skips the rest. `LocalManifest` rejects a wrong version, a repeated id or
quantization, an unpinned or unhashed file, a build without weights or with two
projectors, an entry no bundled engine runs, and any field it does not declare. A manifest that fails is replaced
at startup by an empty one, so downloaded models and search still work.
@@ -229,7 +263,10 @@ entries: `local_manifest/llamacpp/`, `local_manifest/sdcpp/`,
- **An audio entry's evidence is its family**, read from the default build's first
64 KiB: audio.cpp writes it before the files it embeds, which make Kokoro's
header 38.6 MB and Supertonic's 57 MB. The entry names the model's folder in
- the shared repo, its builds most preferred first, and the reviewed `audio` block.
+ the shared repo, its builds most preferred first, and the reviewed `audio`
+ block, or a recogniser's `transcription` one. The shared repo is tagged
+ `text-to-speech`, so Nemotron's evidence carries that tag too; its family,
+ read first, is what types it.
- **An embedder entry pins the weights it names with the repo's tokenizer**, and
the reviewed `embedding` block: the ranking weight the retrieval eval measured
for that model, and for a build converted by someone else, how close its
@@ -245,7 +282,11 @@ entries: `local_manifest/llamacpp/`, `local_manifest/sdcpp/`,
anything: every entry's licence tag must be on the reviewed allowlist in
[`local_manifest/licence.py`](../../../surfsense_local/backend/scripts/local_manifest/licence.py).
An OpenRAIL or Gemma licence passes, since its use restrictions pass on to the
- user rather than limit commercial use. A unit test holds the committed
+ user rather than limit commercial use. A custom licence, tagged `other` on
+ Hugging Face, is listed by its card's `license_name`, with the clause that
+ passes it quoted beside it, as `openmdw-1.1` is. `cc-by-4.0` passes because it
+ allows any use, even commercial, with credit, and the app downloads the file
+ from its repo rather than redistributing it. A unit test holds the committed
manifest to the same rule, so a hand edit cannot slip one in.
- **It refuses to write** a build without a hash or size, a projector that does not
see images or is not as wide as the model, and a refresh that drops a model or
@@ -253,7 +294,7 @@ entries: `local_manifest/llamacpp/`, `local_manifest/sdcpp/`,
- **`VALIDATED`**, one in each engine's `refresh.py`, names builds somebody ran,
with the runtime build they ran it on: a chat build downloaded, chatted with
and its citations confirmed to resolve. llama.cpp's and sd.cpp's are empty;
- audio.cpp's names the three default builds.
+ audio.cpp's names the three default voice builds and Nemotron's `Q8_0`.
The script's assembly is tested over recorded input, with no network
([`test_local_manifest.py`](../../../surfsense_local/backend/tests/unit/scripts/test_local_manifest.py),
@@ -307,9 +348,11 @@ screen states that size until the build is on disk.
An audio row leads the same way, with the first build its entry pins as the
default. Its builds are installed only by the record: the audio folder's
-`installs.json` must name the build's own file, still on disk. All three audio
+`installs.json` must name the build's own file, still on disk. All four audio
models share one repo, and Supertonic and Kitten both have an `orig` build, so a
-repo and a label would mark the wrong model installed.
+repo and a label would mark the wrong model installed. audio.cpp fills two
+slots, so its rows lead as in use by every name chosen for either: the voice
+podcasts use and the speech model each lead their own row.
**The star** goes to the first curated model, in manifest order, with a
recommended build, so a machine short of the preferred model's default gets a
@@ -337,7 +380,9 @@ recognisers, labellers, OCR, drafters and projectors are known and typeless,
except embedders, which are `EMBEDDING`, a type onnxruntime runs and no slot holds;
diffusion architectures are `IMAGE_GEN`, video `VIDEO_GEN`, text to speech
`AUDIO_GEN`, audio.cpp's voice families among them (`kokoro_tts`, `supertonic`,
-`kitten_tts`). Every audio.cpp file declares `general.architecture = audiocpp`,
+`kitten_tts`). A curated recogniser's family, `nemotron_asr` or `canary_asr`, is
+`SPEECH_TO_TEXT`, with the speech recognisers' sentence and code; other
+recognisers stay typeless until one is curated. Every audio.cpp file declares `general.architecture = audiocpp`,
speech recognisers included, so that name alone has its own typeless group,
"This model runs on audio.cpp. SurfSense runs only the voices in its list.", even
beside a text-to-speech tag. The tag only refuses or refines, never admits, and the tags that
@@ -567,16 +612,31 @@ does, and without it looks for a system eSpeak most computers lack. Electron
hands the API those paths as `SURFSENSE_LOCAL_AUDIO_ESPEAK_LIBRARY` and
`SURFSENSE_LOCAL_AUDIO_ESPEAK_DATA`, the same ones it gives the server.
+A recogniser lands in the same folder and is named in `transcription.json`
+instead, which Electron starts the second, speech recognition server from
+([packaging](../packaging.md)): `lazy_load`, then per installed recogniser its
+`id`, `family`, `path`, `task: "asr"`, `mode: "offline"` and the entry's
+`request_options` as `default_request_options`, which the server merges under
+every request, since its multipart routes take no options; Nemotron's,
+`keep_language_tags`, makes the text carry the detected locale. The file is
+written only when its bytes change, so an API start, a voice install or a
+delete never restarts that server and cuts off a transcription; it is gone when
+no recogniser is installed. A recogniser's install leaves `server.json` alone,
+which a podcast may be voicing from
+([`transcription_config.py`](../../../surfsense_local/backend/modules/llm/catalog/local/engines/audiocpp/audio_folder/transcription_config.py)).
+With `select`, a voice model becomes the `audio_gen` selection and a recogniser
+the `speech_to_text` one.
+
Deleting a model removes every file its install record names that no other
installed build's record still names, every part of a split build and its
projector included, and forgets it: a shared VAE or text encoder goes with the
last model that uses it, worked out from the records at delete time. A file with no record, one copied
in by hand, loses only `.gguf` and `mmproj-.gguf`. `DELETE /llm/models/{name}`
finds the name in any engine's folder, and clears the selection of that engine's
-type that named it. Deleting an audio model rewrites `server.json`; deleting the
-last removes the file, since the server refuses an empty model list, and Electron
-stops the server. At startup `warm()` rewrites `server.json` from the install
-records, so a stale or missing file heals.
+type that named it. Deleting an audio model rewrites `server.json` and
+`transcription.json`; deleting the last of a kind removes its file, since the
+server refuses an empty model list, and Electron stops that server. At startup
+`warm()` writes both from the install records, so a stale or missing file heals.
The files go before the record. Windows refuses to delete a file a server still
has open, so there deleting the model in use can fail at the file. The API then
@@ -585,8 +645,10 @@ delete again in a few seconds. What lets go differs by server:
- **sd-server** runs from the image selection, so the API clears the slots that
named the model and Electron stops the server on its next poll.
-- **audiocpp_server** is restarted only when `server.json` is rewritten, so the
- API rewrites it unchanged. The new server opens a model on its first request.
+- **audiocpp_server** is restarted only when its config is rewritten, so the
+ API rewrites it unchanged: `server.json` for a voice model, and
+ `transcription.json`, whose bytes have not changed, for a recogniser. The new
+ server opens a model on its first request.
- **llama-server** never unloads a model on its own and is restarted only by a
changed preset, so the API asks the router to unload the model.
- **The embedding encoder** is not a server: ONNX Runtime holds a model's files
@@ -631,17 +693,18 @@ on search and repo reads, and the job's generic error during an install.
## On the screen
-Settings has one section per model type, **Chat**, **Image**, **Image editing**, **Video** and **Audio** in the nav, each headed **Text generation models**, **Image generation models**, **Image editing models**, **Video generation models** or **Audio generation models** on its own page.
+Settings has one section per model type, **Text gen**, **Image gen**, **Image edit**, **Video**, **Audio** and **Speech recognition** in the nav, each headed **Text generation models**, **Image generation models**, **Image editing models**, **Video generation models**, **Audio generation models** or **Speech recognition models** on its own page.
Each names the model in use at the top, then groups the slot's models by source:
**This computer**, every build on disk, curated or not, with Use (when its type
can fill the slot) or In use and Delete after confirmation; then one group per
connected server ([`../connections.md`](../connections.md)). **Add model** opens
one page with both ways in, laid out alike, no card or border around either:
-**Use a server**, collapsed to a Connect button until opened, because it is
-short, then **On this computer**, the catalog below. Both sections read the
+**Use a server**, collapsed to a Connect button until opened, then **Use your
+ChatGPT plan** where a plan fills the slot ([ChatGPT subscription](../chatgpt-subscription.md#frontend)),
+both short, then **On this computer**, the catalog below. Both sections read the
one `GET /llm/catalog/local`: chat takes llama.cpp's rows, image and image
editing and video take sd.cpp's that can fill their slot (`selectable_for`), and audio
-takes only audio.cpp's, each curated model with its size and a
+takes only audio.cpp's voice models, the rows with `voicing`, each curated model with its size and a
Download. Once a chat or image model is on disk its row offers Delete after
confirmation, the same as **This computer**'s list; an audio row reads
Downloaded. An audio row reads on one line: its build, its size, the memory it
@@ -714,6 +777,29 @@ Rules the screen holds:
audiocpp_server on its next poll. The voice the app ships is the exception:
it is part of the install, read in place from the models pack, so it has no
Delete, and the API refuses one.
+- The speech recognition section is local only: no server can fill
+ `speech_to_text`, so neither its list nor its Add model page offers one
+ (`ModelSlotSettings`' `localOnly`). It takes every row whose
+ `selectable_for` names the slot, described by the row's `transcription`, so
+ recognisers never reach the Audio page, which drops rows without `voicing`.
+ A row reads its build, its size, and "Detects the language" when it does
+ ("Q8_0 · 931 MB · Detects the language"), then its languages, counted, which
+ open to their names in the interface's language, alphabetically
+ (`Intl.DisplayNames`). A download installs with `select: true`: one speech
+ model serves recordings and voice input, so downloading one is choosing it.
+ Under the model in use, **Spoken language** reads and writes
+ `/llm/selection/speech_to_text/language`: "Detect automatically", only when
+ the model detects the language, then each language it lists.
+- The speech set-up popover ([`speech-setup/`](../../../surfsense_local/frontend/src/features/speech-setup/))
+ is the same download where speech is needed, for the mic and a recording
+ waiting for a model to open: one card per curated recogniser with its size
+ and "Works well in:" its listed languages.
+ **Download and set up** starts the same install job with `select: true`, so
+ egress consent is the usual refused-request prompt, nested in the popover;
+ the card then shows the job's progress, and once the catalog marks the model
+ in use the popover reads "Speech recognition is ready", with **Start
+ talking** where its opener can listen. **More choices** opens this section.
+ `useSpeechSetup()` holds that state, so an opener can show the download too.
## How it is tested
@@ -723,14 +809,15 @@ cover the manifest, builds, the build choice and lead, the classifier, support,
pricing through the catalog (the badge matches the load on every budget shape; a
recommended build never warns), reprice, search against mocked transports, and
the service's installs, install jobs (`test_install_jobs.py`), the audio.cpp slice's evidence and rows, and each
-engine's refresh assembly; the routes, audio's `server.json` included, are
-covered in
+engine's refresh assembly; the routes, audio's `server.json` and
+`transcription.json` included (`test_audio_catalog_routes.py`,
+`test_speech_catalog_routes.py`), are covered in
[`surfsense_local/backend/tests/integration/llm/`](../../../surfsense_local/backend/tests/integration/llm/), the feed over a real socket in `test_install_feed.py`,
-and the screen in `download-chat-models.test.tsx`, `install-view.test.tsx`, `install-text.test.ts` and the settings sections' `chat-models-settings.test.tsx`, `image-models-settings.test.tsx` and `audio-models-settings.test.tsx`.
+and the screen in `download-chat-models.test.tsx`, `install-view.test.tsx`, `install-text.test.ts`, the settings sections' `chat-models-settings.test.tsx`, `image-models-settings.test.tsx`, `audio-models-settings.test.tsx` and `speech-models-settings.test.tsx`, and the speech set-up in `speech-setup-popover.test.tsx`.
## Known gaps
-- Only the three audio defaults are validated; `validated` is empty on every other build.
+- Only the three voice defaults and Nemotron's `Q8_0` are validated; `validated` is empty on every other build.
- Qwen3.8 27B is a hybrid model (`qwen35`): the fit estimate charges a KV cache on all 65 layers where 16 keep one, about four times too much, and does not size its recurrent state ([runtime](runtime.md)).
- Qwen3.8 27B commits no `sampling`: its repo has no `params` file, so it runs on llama-server's defaults rather than the model card's.
- `template.system_role` and llama.cpp's `run.args` are committed but unread: chat asks the loaded template for its system role, and the router ignores per-model load arguments. sd.cpp's `image` defaults and `run.args` reach sd-server as launch flags. `template.tools` and `template.reasoning` reach a row's support, which the screen does not show.
diff --git a/docs/architecture/local-models/selection.md b/docs/architecture/local-models/selection.md
index 3735f815aa..951787330b 100644
--- a/docs/architecture/local-models/selection.md
+++ b/docs/architecture/local-models/selection.md
@@ -7,13 +7,14 @@ computed on read and never stored, so a threshold can move on evidence without a
migration or a re-selection. Finishing onboarding is a separate marker that
choosing or clearing a model never touches.
-**Code:** [`surfsense_local/backend/modules/llm/selection.py`](../../../surfsense_local/backend/modules/llm/selection.py), [`surfsense_local/backend/modules/llm/model_type.py`](../../../surfsense_local/backend/modules/llm/model_type.py), [`surfsense_local/backend/modules/llm/selectable.py`](../../../surfsense_local/backend/modules/llm/selectable.py), [`surfsense_local/backend/modules/llm/models.py`](../../../surfsense_local/backend/modules/llm/models.py), [`surfsense_local/backend/modules/llm/profile/`](../../../surfsense_local/backend/modules/llm/profile/), [`surfsense_local/backend/modules/llm/prompting/`](../../../surfsense_local/backend/modules/llm/prompting/), [`surfsense_local/backend/modules/llm/resolution.py`](../../../surfsense_local/backend/modules/llm/resolution.py), [`surfsense_local/backend/modules/llm/router.py`](../../../surfsense_local/backend/modules/llm/router.py)
+**Code:** [`surfsense_local/backend/modules/llm/selection.py`](../../../surfsense_local/backend/modules/llm/selection.py), [`surfsense_local/backend/modules/llm/model_type.py`](../../../surfsense_local/backend/modules/llm/model_type.py), [`surfsense_local/backend/modules/llm/selectable.py`](../../../surfsense_local/backend/modules/llm/selectable.py), [`surfsense_local/backend/modules/llm/models.py`](../../../surfsense_local/backend/modules/llm/models.py), [`surfsense_local/backend/modules/llm/profile/`](../../../surfsense_local/backend/modules/llm/profile/), [`surfsense_local/backend/modules/llm/prompting/`](../../../surfsense_local/backend/modules/llm/prompting/), [`surfsense_local/backend/modules/llm/resolution.py`](../../../surfsense_local/backend/modules/llm/resolution.py), [`surfsense_local/backend/modules/llm/router.py`](../../../surfsense_local/backend/modules/llm/router.py), [`surfsense_local/backend/modules/llm/transcription/`](../../../surfsense_local/backend/modules/llm/transcription/)
**Decisions:** [ADR 0011](../../adr/0011-llama-cpp-local-runtime.md), [ADR 0015](../../adr/0015-openai-compatible-connections.md)
## One row per model type
`ModelType` is what a model is for: `text_gen`, `image_gen`, `image_edit`,
-`video_gen` or `audio_gen`. There are no separate roles; the type is the slot.
+`video_gen`, `audio_gen` or `speech_to_text`. There are no separate roles; the
+type is the slot.
`SelectedModel` holds one row per type in `selected_models`, keyed by
`model_type`, so choosing again updates in place.
@@ -24,6 +25,7 @@ choosing or clearing a model never touches.
| `image_edit` | `sdcpp`, a model whose entry names `edit` | `openai_compatible`, with a `connection_id` | nothing yet |
| `audio_gen` | `audiocpp`, the bundled audio.cpp server | `openai_compatible`, with a `connection_id` | Studio's `podcast` |
| `video_gen` | `sdcpp`, a model whose entry has a `video` block | `openai_compatible`, with a `connection_id` | nothing yet |
+| `speech_to_text` | `audiocpp`, a recogniser, run on a second audio.cpp server | none: local only | the transcription routes ([admission](admission.md#speech-recognition)), which a recording's ingest calls ([documents](../documents.md#recordings)) |
A type no feature reads can still be chosen; the feature that first reads one
brings the client that calls it.
@@ -36,12 +38,19 @@ belongs to the library's index, fixed when onboarding finishes
`selectable_for` never offers `embedding`, not even to a model nothing
recognises, and both `/llm/selection/{model_type}` routes answer `422` for it.
+`speech_to_text` is a slot, since speech models change freely as voices do, but
+a local-only one: no remote speech client exists to call a recogniser.
+`LOCAL_ONLY` in the same file names it, so a model nothing recognises fills
+every slot but it, and a connection serves `REMOTE_SLOTS`, every slot but it,
+which a confirmed unlisted model does not get round.
+
A row stores the provider, the connection when remote, the exact model id, and
three fingerprint facts. A check constraint requires a `connection_id` exactly
when the provider is `openai_compatible`, a second (`local_runtime_type`) lets
`llamacpp` hold only `text_gen`, `sdcpp` only `image_gen`, `image_edit` and
-`video_gen` (revision `0018`), and `audiocpp` only `audio_gen`, and deleting a
-connection cascades to the rows that name it. `provider` is the SurfSense inference provider, never the
+`video_gen` (revision `0018`), and `audiocpp` only `audio_gen` and
+`speech_to_text` (revision `0030`), and deleting a connection cascades to the
+rows that name it. `provider` is the SurfSense inference provider, never the
model's publisher.
`GET /llm/selection/{model_type}` returns the row with its computed `tier`, or
@@ -60,16 +69,18 @@ fingerprints and stores:
`edit`, and `video_gen` for a model with a `video` block. FLUX.2 klein fills
both image slots from the same files; SD 1.5 is refused for editing, and
every image model for video.
-- **Local audio** (`audiocpp`): the type must be `audio_gen`, there is no
- connection, and the name must be a curated audio build installed in the
- audio folder or shipped in the models pack, named by its weights file as a
- chat build is.
+- **Local audio** (`audiocpp`): there is no connection, and the name is a
+ curated build named by its weights file as a chat build is. For `audio_gen`
+ it must be a voice model, whose entry has an `audio` block, installed in the
+ audio folder or shipped in the models pack; for `speech_to_text`, a
+ recogniser, whose entry has a `transcription` block, installed in the audio
+ folder. Nemotron is refused for voices and Kokoro for speech recognition.
- **Remote** (`openai_compatible`): a connection is required, and the model is
checked against the endpoint's live `/models`. When the listing cannot be read
or does not include the id, `allow_unlisted` is what lets a user save an exact
id anyway. Otherwise the model must be able to fill the slot, by one rule in
`selectable.py`: a model fills the slots of the types it is, and one nothing
- recognises fills every slot. The connection's model listing sends the same
+ recognises fills every slot but `speech_to_text`. The connection's model listing sends the same
answer as `selectable_for`, so every picker offers what selection accepts. Remote
inventory is discovered live and never synchronized into SQLite
([`../connections.md`](../connections.md)).
@@ -80,9 +91,15 @@ and choosing a model is when the user has said they are about to use it
([`runtime.md`](runtime.md#warming-listed-is-not-loaded)). Any other provider
loads nothing.
+Choosing a `speech_to_text` model, through the route or an install with
+`select: true`, queues again every recording that failed for want of one, at
+`PRIORITY_BULK` ([documents](../documents.md#no-speech-model-yet)).
+
Installing with `select: true` goes through the same `choose_model()`
-([`catalog.md`](catalog.md)). Deleting a local model clears every row of the
-types its engine fills that named it, `image_gen` and `image_edit` both for a
+([`catalog.md`](catalog.md)) and fills the row's own first slot unless the
+request names another, so Nemotron's install fills `speech_to_text` though
+audio.cpp's first type is `audio_gen`. Deleting a local model clears every row
+of the types its engine fills that named it, `image_gen` and `image_edit` both for a
model chosen for each, and reports
`selection_cleared`; nothing chooses another
model in its place, except that at the next start an empty `audio_gen` row takes
@@ -225,7 +242,31 @@ bundled runtime ([`runtime.md`](runtime.md)). An `openai_compatible` row resolve
to its connection, and `egress.require()` checks the connection's host, which is
a no-op for a loopback host, so a local LM Studio or Ollama endpoint never
prompts. `resolve_image_generation()` does the same for sd-server or a
-connection.
+connection. `resolve_speech_to_text()` reads the `speech_to_text` row and
+returns it with its installed recogniser: the file, the family the server
+dispatches on, and the entry's languages and locales. With no row, or one whose
+model is gone, it raises `NoSpeechModelError`, which a route answers as `409`
+with the code `needs_speech_model`.
+
+## The speech model's language
+
+`GET /llm/selection/speech_to_text/language` answers `{language, languages,
+detects_language}`: the language the user says they speak, `auto` or an ISO
+639-1 code; the codes the model is listed for, best first; and whether `auto`
+detects. `PUT` takes `{language}` and answers the same. A code the model is not
+listed for is `422`, Estonian included, which Nemotron detects at lower
+accuracy. A model that cannot detect, Canary, has no `auto`: told no language
+it translates into English, so with nothing set its intent is its first
+language, English, and `auto` is `422`. Both answer `409` `needs_speech_model` without a speech model.
+
+The intent lives in the row's `settings` under `transcription`, as
+`{"language": "ja"}`, read and written only by
+[`language_intent.py`](../../../surfsense_local/backend/modules/llm/transcription/language_intent.py).
+A kept language the model no longer lists reads as `auto`, and nothing rewrites
+it from a detection. A request names the language's locale from the entry's
+`family_codes` (`ja-JP` for Nemotron), and `auto` names none, so the model
+detects it. Like voices, it is dropped when the slot takes another
+model; choosing the same model again keeps it.
## Capabilities and the system role
@@ -297,7 +338,9 @@ covers `classify()` and fingerprinting, and
[`surfsense_local/backend/tests/unit/llm/prompting/`](../../../surfsense_local/backend/tests/unit/llm/prompting/)
asserts every case ships all three tiers and that slots fill.
`tests/integration/llm/test_routes.py` covers selection, onboarding and delete
-over HTTP.
+over HTTP; `test_speech_catalog_routes.py` and `test_speech_language_routes.py`
+beside it cover the speech recognition slot, which model fills it and its
+language, and `test_connections.py` that no connection fills it.
## Known gaps
diff --git a/docs/architecture/localization.md b/docs/architecture/localization.md
index c83479f68a..ddd72e827f 100644
--- a/docs/architecture/localization.md
+++ b/docs/architecture/localization.md
@@ -56,7 +56,7 @@ Main resolves the language ([`app-locale.ts`](../../surfsense_local/electron/src
## Backend text
-The backend stays English. Where it sends a code with its prose, the frontend shows its own text for the code and falls back to the backend's English for a code it does not know: the chat's error kinds ([`chat-error-text.ts`](../../surfsense_local/frontend/src/features/chat/chat-error-text.ts)), the license rejection reasons ([`license-error-text.ts`](../../surfsense_local/frontend/src/features/license/license-error-text.ts)), and why a Studio format is unavailable, where `unavailable_code` names the missing model types, such as `needs_chat_image` ([`studio-unavailable-text.ts`](../../surfsense_local/frontend/src/features/studio/studio-unavailable-text.ts)); a create, a regenerate, a refine or a podcast brief refused for that reason carries the same code in its `409`, and a job that cannot start, be run again or be stopped carries its own, in a `409` or, for `no_sources`, a `422`: `already_generating`, `nothing_running`, `no_sources`, `sources_indexing` and `sources_gone` ([`studio-error-text.ts`](../../surfsense_local/frontend/src/features/studio/studio-error-text.ts)); and a model install's events, where `code` names the step or the refusal, a refusal for disk space sends its sizes as raw bytes for the message to format, and an embedder that fails its search check sends the two counts ([`install-text.ts`](../../surfsense_local/frontend/src/features/models/local/installs/install-text.ts)); and a build's fit badge, where `code` is the speed tier, `uma` picks between "the GPU" and "the graphics card", and a refusal's two sizes come from the fit's own byte counts ([`fit-text.ts`](../../surfsense_local/frontend/src/features/models/local/chat/fit-text.ts)); and why a local model cannot run, where `not_runnable_code` names what the classifier found the model to be, and an install refusing a searched file for that reason carries the same code ([`not-runnable-text.ts`](../../surfsense_local/frontend/src/features/models/local/chat/not-runnable-text.ts)). Each code has a whole sentence of its own, not a list joined from parts.
+The backend stays English. Where it sends a code with its prose, the frontend shows its own text for the code and falls back to the backend's English for a code it does not know: the chat's error kinds ([`chat-error-text.ts`](../../surfsense_local/frontend/src/features/chat/chat-error-text.ts)), the license rejection reasons ([`license-error-text.ts`](../../surfsense_local/frontend/src/features/license/license-error-text.ts)), and why a Studio format is unavailable, where `unavailable_code` names the missing model types, such as `needs_chat_image` ([`studio-unavailable-text.ts`](../../surfsense_local/frontend/src/features/studio/studio-unavailable-text.ts)); a create, a regenerate, a refine or a podcast brief refused for that reason carries the same code in its `409`; five job refusals carry a code of their own, in a `409` or, for `no_sources`, a `422`: `already_generating`, `nothing_running`, `no_sources`, `sources_indexing` and `sources_gone`; and so do four of a refine's: `not_refinable`, `agent_made`, `version_unfinished`, and `already_generating` while a version of the document is being made ([`studio-error-text.ts`](../../surfsense_local/frontend/src/features/studio/studio-error-text.ts)); and a model install's events, where `code` names the step or the refusal, a refusal for disk space sends its sizes as raw bytes for the message to format, and an embedder that fails its search check sends the two counts ([`install-text.ts`](../../surfsense_local/frontend/src/features/models/local/installs/install-text.ts)); and a build's fit badge, where `code` is the speed tier, `uma` picks between "the GPU" and "the graphics card", and a refusal's two sizes come from the fit's own byte counts ([`fit-text.ts`](../../surfsense_local/frontend/src/features/models/local/chat/fit-text.ts)); and why a local model cannot run, where `not_runnable_code` names what the classifier found the model to be, and an install refusing a searched file for that reason carries the same code ([`not-runnable-text.ts`](../../surfsense_local/frontend/src/features/models/local/chat/not-runnable-text.ts)). Each code has a whole sentence of its own, not a list joined from parts.
## Build
@@ -87,4 +87,4 @@ The ten `README..md` files at the repo root are not built from these cat
## Known gaps
-- Backend prose without a code stays English in every language: the one refusal of a Hugging Face embedder repo that names a file ("Hugging Face lists no checksum for …"), and Studio's refusals other than a missing model and a job that cannot start, be run again or be stopped ([Backend text](#backend-text)): for example a source named by id that is in another workspace or still indexing, a format's own option errors, a refine's and a revised copy's refusals, a quiz's and a flashcard deck's state refusals, and the artifact and file errors the viewers show as sent. The Studio panel shows the ones it receives under its translated alert title; a Refine shows its own under its box, and Accept all and Reject all theirs under the bar, with no title.
+- Backend prose without a code stays English in every language: the one refusal of a Hugging Face embedder repo that names a file ("Hugging Face lists no checksum for …"), and Studio's refusals other than a missing model, the five job refusals with a code and the four of a refine with one ([Backend text](#backend-text)): for example a source named by id that is in another workspace or still indexing, a bad podcast brief, a format's own option errors, a rewrite too long for the selected model's window, a revised copy's refusals, its "already generating" among them, a quiz's and a flashcard deck's state refusals, and the artifact and file errors the viewers show as sent. The Studio panel shows the ones it receives under its translated alert title; a Refine shows its own under its box, and Accept all and Reject all theirs under the bar, with no title.
diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md
index 988cf9ab76..e2d8acd900 100644
--- a/docs/architecture/overview.md
+++ b/docs/architecture/overview.md
@@ -22,6 +22,7 @@ Electron main ─┬─ api FastAPI on 127.0.0.1, free port ─┐
├─ llamacpp llama-server in router mode
├─ sdcpp sd-server, started once an image model is chosen
├─ audiocpp audiocpp_server, started once the API names an audio model
+ ├─ audiocpp-transcription the same binary, started once the API names a speech model
└─ opencode opencode serve, started once the API writes its configuration
BrowserWindow (Vite SPA) ── HTTP ──> api
@@ -35,11 +36,12 @@ worker-ingest, worker-studio ── POST /internal/events ──> api ── SSE
- llama-server starts whenever its pinned build is staged, in dev too (`pnpm build:llamacpp`, which `predev` runs). It reads per-model arguments from a preset file once at startup, so Electron restarts it when the API rewrites that file ([`local-models/runtime.md`](local-models/runtime.md)).
- sd-server takes its model as startup arguments, so it cannot start at boot. Whenever its pinned build is staged, in dev too (`pnpm build:sdcpp`, which `predev` runs), `watchImageModel` asks the API every 5 seconds which files to run, and starts, restarts or stops it on a change. The API names a model only while a Studio job needs one and for up to 5 minutes after, so its weights are not held beside the chat model all session ([`studio.md`](studio.md#the-image-path)).
- audiocpp_server refuses an empty model list, so it starts only once the API has written `audio/server.json` under the data directory, in dev too (`pnpm build:audiocpp`, which `predev` runs). On Windows and Linux that script compiles audio.cpp, and without a C++ toolchain the app runs without local audio ([packaging](packaging.md)). Electron checks that file every 5 seconds and restarts the server when it changes. Electron sets the machine-wide flags: the CPU backend, half the logical cores up to 8, one loaded model, and an unload after 5 idle minutes. The API writes that file whenever an audio model is installed or deleted, and at startup ([`local-models/catalog.md`](local-models/catalog.md)). The Studio worker voices podcasts there, at `SURFSENSE_LOCAL_AUDIO_BASE_URL`, and unloads the model when a podcast ends ([`studio.md`](studio.md)).
+- The same `audiocpp_server` runs a second time as `audiocpp-transcription`, for speech recognition, from its own `audio/transcription.json` and on its own port, so a voice install, a podcast's unload or a busy voice model never touches it ([ADR 0056](../adr/0056-speech-recognition-runs-on-a-second-audiocpp-server.md)). Electron checks that file every 5 seconds: it starts the server once the file is there, restarts it when the file changes and stops it when the file goes. It takes the voices' flags, with no eSpeak. Unlike the voices' server it is restarted after a crash, at most once every 10 seconds, and left stopped after 3 crashes in 5 minutes until the file changes. The API alone is told its address, `SURFSENSE_LOCAL_TRANSCRIPTION_BASE_URL`. The API writes that file once a speech recogniser is downloaded, and only when its bytes change ([catalog](local-models/catalog.md)), so the server runs only then. The API's transcription routes are its only caller, one request at a time through one gate ([admission](local-models/admission.md#speech-recognition)).
- Only the API gates the window. Electron waits up to 60 seconds for `/health` and gives up at once if the API exits. llama-server is best-effort; its state shows through `/llm/providers`.
-- opencode, the agent's engine, starts only once the API has written `agent/opencode.json` under the data directory, in dev too (`pnpm build:opencode`, which `predev` runs, stages it unless `SURFSENSE_LOCAL_OPENCODE_ENABLED=0`: [agent](agent.md)). Electron checks for that file every 2 seconds: it starts opencode once the file is there, stops it when the file goes, and restarts it after a crash, at most once every 10 seconds. A rewrite does not restart it, because opencode reads the file per folder the first time the folder is used; the API makes it read the file again through `POST /global/dispose` ([agent proposal](../proposals/agent/03-opencode.md)). Electron removes the file at boot, so each run's API writes its own. At boot Electron picks opencode's port and a random password, which it passes to the API alone as `SURFSENSE_LOCAL_OPENCODE_URL` and `SURFSENSE_LOCAL_OPENCODE_PASSWORD`. opencode's environment is built from a few system variables rather than inherited, with its home and XDG folders under `agent/opencode/`, its own fetches switched off and its proxy pointed at the discard port ([`sidecars/opencode.ts`](../../surfsense_local/electron/src/main/sidecars/opencode.ts)). `serve` keeps running when its parent is killed, so Electron records the pid, port and password in `agent/opencode-process.json`, and the next boot stops a recorded process that still accepts that password.
+- opencode, the agent's engine, starts only once the API has written `agent/opencode.json` under the data directory, in dev too (`pnpm build:opencode`, which `predev` runs, stages it unless `SURFSENSE_LOCAL_OPENCODE_ENABLED=0`: [agent](agent.md)). Electron checks for that file every 2 seconds: it starts opencode once the file is there, stops it when the file goes, and restarts it after a crash with a growing delay, stopping after three quick exits until the API writes the file again ([agent](agent.md#from-a-thread-to-a-running-opencode)). A rewrite does not restart it, because opencode reads the file per folder the first time the folder is used; the API makes it read the file again through `POST /global/dispose` ([agent proposal](../proposals/agent/03-opencode.md)). Electron removes the file at boot, so each run's API writes its own. At boot Electron picks opencode's port and a random password, which it passes to the API alone as `SURFSENSE_LOCAL_OPENCODE_URL` and `SURFSENSE_LOCAL_OPENCODE_PASSWORD`. opencode's environment is built from a few system variables rather than inherited, with its home and XDG folders under `agent/opencode/`, its own fetches switched off and its proxy pointed at the discard port ([`sidecars/opencode.ts`](../../surfsense_local/electron/src/main/sidecars/opencode.ts)). `serve` keeps running when its parent is killed, so Electron records the pid, port and password in `agent/opencode-process.json`, and the next boot stops a recorded process that still accepts that password.
- Before a quit, whether from the menu or by closing the last window, Electron asks the API how many chat replies are being written; with any, it asks the person, and on Quit has the API store each reply before anything is stopped ([chat](chat.md#runs)). Cancel keeps the app open.
- On macOS and Linux each child runs in its own process group. On quit Electron sends SIGTERM and, after 5 seconds, SIGKILL; the group is also killed as soon as the sidecar itself exits, because a child that ignores SIGTERM would outlive the app. On Windows it kills the process tree. A single-instance lock hands a second launch to the first window, because two sets of sidecars would fight over the SQLite file.
-- The Python sidecars are configured through `SURFSENSE_LOCAL_*` variables: the API's host and port, the data and models directories, the llama-server and sd-server addresses, the images folder, audio.cpp's address and folder where it is staged, and `SURFSENSE_LOCAL_SECRET`, the key that encrypts stored API keys ([`connections.md`](connections.md)). No other sidecar receives the secret. The API alone also gets `SURFSENSE_LOCAL_SHELL_PID`, Electron's own pid, the root of what Settings › Resources counts as the app ([`resource-usage.md`](resource-usage.md)).
+- The Python sidecars are configured through `SURFSENSE_LOCAL_*` variables: the API's host and port, the data and models directories, the llama-server and sd-server addresses, the images folder, audio.cpp's address and folder where it is staged, and `SURFSENSE_LOCAL_SECRET`, the key that encrypts stored API keys ([`connections.md`](connections.md)). No other sidecar receives the secret. The API alone also gets `SURFSENSE_LOCAL_SHELL_PID`, Electron's own pid, the root of what Settings › Resources counts as the app ([`resource-usage.md`](resource-usage.md)), and the speech recognition server's address.
## Layer boundary
@@ -69,7 +71,7 @@ The rules that keep the processes out of each other's way:
- Workers call `POST /internal/events` after each status change ([`worker/notify.py`](../../surfsense_local/backend/worker/notify.py)): ingest sends a `documents` event keyed by document id, Studio an `artifacts` event keyed by artifact id. The notice is best-effort with a 2-second timeout; losing one costs a live update, never the job.
- The API announces each reply, chat or agent, starting, changing state and ending on the same stream, as a `chat-runs` event keyed by thread id with status `running`, `queued`, `needs-approval` or `done` ([chat](chat.md#runs)).
- The API notifies of its own document changes on the same stream, straight to the broker: a note written, an upload, a rename or edit, a retry, a cancel, and a delete, whose status is `deleted` ([`api/notify.py`](../../surfsense_local/backend/api/notify.py)). So a change reaches an open window whoever made it: the window itself, a plugin, or a second window.
-- The API fans each notice out on `GET /workspaces/{id}/events` ([`modules/events/`](../../surfsense_local/backend/modules/events/)) as a named SSE event whose data is `{"ids": [...], "status": "..."}`. The stream opens with a `: connected` comment and sends `: ping` after 15 idle seconds. The broker is an in-memory map, which holds because one uvicorn process serves the app.
+- The API fans each notice out on `GET /workspaces/{id}/events` ([`modules/events/`](../../surfsense_local/backend/modules/events/)) as a named SSE event whose data is `{"ids": [...], "status": "..."}`, with a transient `progress` `{done_s, total_s}` on a recording's event for each transcribed window ([documents](documents.md#recordings)). The stream opens with a `: connected` comment and sends `: ping` after 15 idle seconds. The broker is an in-memory map, which holds because one uvicorn process serves the app.
- The frontend holds one subscription per workspace, shared by the lists that listen ([`features/workspaces/workspace-changes.ts`](../../surfsense_local/frontend/src/features/workspaces/workspace-changes.ts)), and closes it with the last of them. The sources panel reloads its list on each `documents` event and the Studio artifact list on each `artifacts` event; both reload each time a dropped stream is back, for what changed while nothing listened. Because a notice can be lost, each list also refetches every 10 seconds while any of its rows is `pending` or `processing`, and stops when none is: a lost notice leaves a row stale for at most that long, and nothing is requested on a timer while nothing is in flight.
## Data directory
@@ -98,7 +100,9 @@ The rules that keep the processes out of each other's way:
- A Vite and React SPA with Tailwind and shadcn/ui components in `components/ui/`, built on Base UI in shadcn's `base-nova` style; assistant-ui drives the conversation, and still depends on Radix. Packaged, Electron loads `frontend/dist/index.html` from disk, which is why Vite builds with relative asset paths. In dev it loads the Vite server on port 5173.
- The API's address comes from the preload: Electron passes it as a command-line argument and [`preload/index.ts`](../../surfsense_local/electron/src/preload/index.ts) exposes it as `window.surfsense.apiUrl`. In a bare browser there is no preload, requests stay root-relative, and the Vite dev server forwards `/health`, `/llm`, `/workspaces`, `/chat` and `/artifacts` to `127.0.0.1:8000`.
-- The preload bridge is the renderer's only other channel: opening or revealing an original file natively, opening an external link (an allowlisted one, one the user confirmed in the link popup, or a listed plugin's page by its id), the platform name, updates, theme, the title bar, this session's log and unexpected sidecar-exit notices. The renderer never sees Node or the sidecars directly. A source with a registered viewer (PDF first) is fetched from the API and previewed in place of the left sidebar; other file types keep the native open action ([`documents.md`](documents.md)).
+- The preload bridge is the renderer's only other channel: opening or revealing an original file natively, opening an external link (an allowlisted one, one the user confirmed in the link popup, or a listed plugin's page by its id), the platform name, updates, theme, the title bar, this session's log, unexpected sidecar-exit notices, and the operating system's microphone switch for SurfSense. The renderer never sees Node or the sidecars directly. A source with a registered viewer (PDF first) is fetched from the API and previewed in place of the left sidebar; other file types keep the native open action ([`documents.md`](documents.md)).
+- Electron approves every permission a session has no handler for, so the main process gives each session request and check handlers as it is created ([`permissions/`](../../surfsense_local/electron/src/main/permissions/)), before the app is ready. `media` is granted only to the main window's main frame on the app's own page, a file in the SPA's own folder when packaged and Vite's origin in development, and only for audio: a camera, a desktop or tab capture and `display-capture` are refused. Every other permission keeps Electron's own answer, so clipboard writes keep working; a strict allowlist would need an audit of every permission the app uses. The Word snapshot window's session refuses every permission ([agent](agent.md#previews)).
+- `window.surfsense.microphone` answers the operating system's side ([`microphone/`](../../surfsense_local/electron/src/main/microphone/)), for the main window's main frame only: `status()` is `systemPreferences.getMediaAccessStatus("microphone")` on Windows and macOS, and `granted` on Linux, which has no switch for an AppImage or a deb; `request()` asks macOS with `askForMediaAccess` before the page calls `getUserMedia`, and on Windows reports the switch as it is; `openSettings()` opens `ms-settings:privacy-microphone` or the macOS Privacy › Microphone pane, which `allowedExternalUrl` cannot, since it passes only https, and answers false on Linux. The page never names the address.
- API calls go through `request()` in [`lib/api.ts`](../../surfsense_local/frontend/src/lib/api.ts), except the Studio viewers, which fetch an artifact's file bytes directly; an ``, `