From ba6a4ac4e4ddcab1074c0b275974b7c78d10a99a Mon Sep 17 00:00:00 2001
From: jon
Date: Thu, 23 Jul 2026 14:22:46 -0500
Subject: [PATCH] feat: workspaces, agentic action tools, local Whisper STT,
vec recall
- Projects/workspaces: documents grouped into projects; chat RAG scopes to the
active project. Switcher in the Documents page.
- Agentic action tools: web_search, fetch_url, and remember (first write tool),
allowlist-gated per playbook.
- Local Whisper STT (faster-whisper, no torch): on-device dictation replacing
the browser Web Speech API. POST /stt + GET /stt/status; browser fallback.
- Vector index extended to conversation recall (message_vectors), with the
brute-force cosine scan kept as the fallback.
Co-Authored-By: Claude Opus 4.8
---
interface/web/src/Chatbot.jsx | 75 +++++++++++--
interface/web/src/Documents.jsx | 63 ++++++++++-
interface/web/src/Playbook.jsx | 2 +-
requirements-base.txt | 2 +
requirements-windows.txt | 2 +
synapse/main.py | 65 ++++++++++-
synapse/memory/store.py | 193 +++++++++++++++++++++++++++-----
synapse/stt.py | 55 +++++++++
synapse/tools.py | 101 ++++++++++++++++-
tests/test_documents.py | 56 +++++++++
tests/test_smoke.py | 7 ++
tests/test_tools.py | 18 +++
12 files changed, 586 insertions(+), 53 deletions(-)
create mode 100644 synapse/stt.py
diff --git a/interface/web/src/Chatbot.jsx b/interface/web/src/Chatbot.jsx
index b332753..6fec25b 100644
--- a/interface/web/src/Chatbot.jsx
+++ b/interface/web/src/Chatbot.jsx
@@ -19,23 +19,64 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
const [editingIdx, setEditingIdx] = useState(null); // user message being edited
const [editText, setEditText] = useState("");
const [listening, setListening] = useState(false); // mic dictation active
+ const [transcribing, setTranscribing] = useState(false); // local STT running
+ const [sttLocal, setSttLocal] = useState(false); // backend Whisper available
const [speakingIdx, setSpeakingIdx] = useState(null); // message being read aloud
const recognitionRef = useRef(null);
+ const mediaRecRef = useRef(null);
// Web Speech API — browser-native, no backend/model. Absent on unsupported browsers.
const SpeechRec = typeof window !== "undefined" && (window.SpeechRecognition || window.webkitSpeechRecognition);
const ttsSupported = typeof window !== "undefined" && "speechSynthesis" in window;
+ const canRecord = typeof navigator !== "undefined" && navigator.mediaDevices && window.MediaRecorder;
- const toggleMic = () => {
- if (!SpeechRec) return;
- if (listening) { recognitionRef.current?.stop(); return; }
+ // Prefer local Whisper (on-device) over browser speech (Chrome routes audio to Google).
+ useEffect(() => {
+ fetch(`${API_BASE}/stt/status`).then(r => r.ok ? r.json() : null)
+ .then(d => setSttLocal(Boolean(d && d.available))).catch(() => {});
+ }, []);
+
+ const _appendTranscript = (text) => {
+ if (text) setInput(prev => (prev ? prev + " " : "") + text);
+ };
+
+ // Local path: record audio, POST to /stt (faster-whisper transcribes on-device).
+ const startLocalDictation = async () => {
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ const rec = new MediaRecorder(stream);
+ const chunks = [];
+ rec.ondataavailable = e => e.data.size && chunks.push(e.data);
+ rec.onstop = async () => {
+ stream.getTracks().forEach(t => t.stop());
+ setListening(false);
+ setTranscribing(true);
+ try {
+ const b64 = await new Promise((res) => {
+ const fr = new FileReader();
+ fr.onload = () => res(String(fr.result).split(",")[1]);
+ fr.readAsDataURL(new Blob(chunks, { type: rec.mimeType }));
+ });
+ const r = await fetch(`${API_BASE}/stt`, {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ audio: b64 }),
+ });
+ if (r.ok) _appendTranscript((await r.json()).text);
+ } catch { /* ignore */ } finally {
+ setTranscribing(false);
+ }
+ };
+ mediaRecRef.current = rec;
+ setListening(true);
+ rec.start();
+ } catch { setListening(false); }
+ };
+
+ const startBrowserDictation = () => {
const rec = new SpeechRec();
rec.lang = "en-US";
rec.interimResults = false;
- rec.onresult = (e) => {
- const text = Array.from(e.results).map(r => r[0].transcript).join(" ").trim();
- if (text) setInput(prev => (prev ? prev + " " : "") + text);
- };
+ rec.onresult = (e) => _appendTranscript(Array.from(e.results).map(r => r[0].transcript).join(" ").trim());
rec.onend = () => setListening(false);
rec.onerror = () => setListening(false);
recognitionRef.current = rec;
@@ -43,6 +84,17 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
rec.start();
};
+ const toggleMic = () => {
+ if (transcribing) return;
+ if (listening) {
+ if (sttLocal && mediaRecRef.current) mediaRecRef.current.stop();
+ else recognitionRef.current?.stop();
+ return;
+ }
+ if (sttLocal && canRecord) startLocalDictation();
+ else if (SpeechRec) startBrowserDictation();
+ };
+
// Read a reply aloud (light markdown strip so symbols aren't spoken).
const speak = (idx, text) => {
if (!ttsSupported) return;
@@ -617,10 +669,11 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
📎
- {SpeechRec && (
-
+
+ Workspace:
+
+ {activeProject && (
+
+ Delete project
+
+ )}
+
+ {activeProject ? "Chat scopes to this project's documents." : "Chat searches all documents."}
+
+
+
setTitle(e.target.value)} style={input} />
diff --git a/interface/web/src/Playbook.jsx b/interface/web/src/Playbook.jsx
index a3e8109..8b5d01c 100644
--- a/interface/web/src/Playbook.jsx
+++ b/interface/web/src/Playbook.jsx
@@ -360,7 +360,7 @@ export function Playbook() {
/>
setForm(prev => ({ ...prev, tools: e.target.value }))}
style={{ padding: "0.9rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "10px" }}
diff --git a/requirements-base.txt b/requirements-base.txt
index 3bf4a6f..f2aea3e 100644
--- a/requirements-base.txt
+++ b/requirements-base.txt
@@ -43,6 +43,8 @@ python-docx
# Vector search: loadable SQLite extension (prebuilt wheels). Brute-force cosine
# stays as the fallback when the host Python can't load extensions.
sqlite-vec
+# Local speech-to-text: CTranslate2-based, no torch, keeps dictation on-device.
+faster-whisper
# Documentation Support
markdown-it-py
diff --git a/requirements-windows.txt b/requirements-windows.txt
index 96189b4..be3b3b8 100755
--- a/requirements-windows.txt
+++ b/requirements-windows.txt
@@ -15,6 +15,8 @@ python-docx
# Vector search: loadable SQLite extension (prebuilt wheels). Brute-force cosine
# stays as the fallback when the host Python can't load extensions.
sqlite-vec
+# Local speech-to-text: CTranslate2-based, no torch, keeps dictation on-device.
+faster-whisper
# Native desktop window for the UI (WebView2 on Windows; pulls pythonnet).
# Used by bin/nexus_window.py, launched from launch_nexus.ps1.
diff --git a/synapse/main.py b/synapse/main.py
index 6465b8d..8c919f3 100644
--- a/synapse/main.py
+++ b/synapse/main.py
@@ -290,6 +290,7 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
message, get_ollama_manager().embed,
limit=app_settings.get("rag_top_k", 3),
min_score=app_settings.get("rag_min_score", 0.6),
+ project_id=app_settings.get("active_project", "") or None,
)
doc_titles: list = []
if doc_hits:
@@ -943,12 +944,70 @@ async def delete_playbook_endpoint(id: UUID):
raise HTTPException(status_code=500, detail=str(e))
+# -------------------------
+# Speech-to-text (local Whisper)
+# -------------------------
+from . import stt as _stt
+
+
+@app.get("/stt/status")
+async def stt_status():
+ return {"available": _stt.available()}
+
+
+@app.post("/stt")
+async def stt_transcribe(payload: Dict[str, Any] = Body(...)):
+ if not _stt.available():
+ raise HTTPException(status_code=503, detail="local STT (faster-whisper) not installed")
+ audio = payload.get("audio") or ""
+ if not audio:
+ raise HTTPException(status_code=400, detail="audio (base64) is required")
+ try:
+ text = await _asyncio.to_thread(_stt.transcribe_b64, audio)
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"transcription failed: {e}")
+ return {"text": text}
+
+
+# -------------------------
+# Projects / workspaces
+# -------------------------
+def _active_project() -> str:
+ """The active project id, or '' for the unscoped 'All' view."""
+ return store.get_settings().get("active_project", "") or ""
+
+
+@app.get("/projects")
+async def list_projects():
+ return {"projects": store.list_projects(), "active": _active_project()}
+
+
+@app.post("/projects")
+async def create_project(payload: Dict[str, Any] = Body(...)):
+ name = (payload.get("name") or "").strip()
+ if not name:
+ raise HTTPException(status_code=400, detail="name is required")
+ return store.create_project(name)
+
+
+@app.delete("/projects/{project_id}")
+async def delete_project(project_id: str):
+ if not store.delete_project(project_id):
+ raise HTTPException(status_code=404, detail="Not Found")
+ # If the deleted project was active, fall back to the "All" view.
+ if _active_project() == project_id:
+ store.update_settings({"active_project": ""})
+ return {"status": "deleted"}
+
+
# -------------------------
# Documents (RAG)
# -------------------------
@app.get("/documents")
async def list_documents():
- return {"documents": store.list_documents()}
+ # Scope to the active project; "" (All) lists everything.
+ active = _active_project()
+ return {"documents": store.list_documents(active if active else None)}
@app.post("/documents")
@@ -957,7 +1016,7 @@ async def add_document(payload: Dict[str, Any] = Body(...)):
content = (payload.get("content") or "").strip()
if not title or not content:
raise HTTPException(status_code=400, detail="title and content are required")
- result = await store.add_document(title, content, get_ollama_manager().embed)
+ result = await store.add_document(title, content, get_ollama_manager().embed, _active_project())
if result["chunks"] == 0:
raise HTTPException(status_code=400, detail="no text to index")
return result
@@ -1000,7 +1059,7 @@ async def upload_document(payload: Dict[str, Any] = Body(...)):
if not text:
raise HTTPException(status_code=400, detail="no extractable text in file")
title = _os.path.splitext(_os.path.basename(filename))[0] or filename
- return await store.add_document(title, text, get_ollama_manager().embed)
+ return await store.add_document(title, text, get_ollama_manager().embed, _active_project())
@app.get("/documents/{doc_id}")
diff --git a/synapse/memory/store.py b/synapse/memory/store.py
index 9da7fc7..bc3b62d 100644
--- a/synapse/memory/store.py
+++ b/synapse/memory/store.py
@@ -191,6 +191,20 @@ class PersistentMemoryStore:
""")
cur.execute("CREATE INDEX IF NOT EXISTS idx_documents_doc_id ON documents (doc_id)")
+ # Projects / workspaces: group documents so RAG can scope to one set.
+ cur.execute("""
+ CREATE TABLE IF NOT EXISTS projects (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ created_at REAL NOT NULL
+ )
+ """)
+ # documents.project_id — "" (or missing) means unscoped / All.
+ try:
+ cur.execute("ALTER TABLE documents ADD COLUMN project_id TEXT NOT NULL DEFAULT ''")
+ except Exception:
+ pass
+
cur.execute("""
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
@@ -569,26 +583,31 @@ class PersistentMemoryStore:
"INSERT OR REPLACE INTO message_vectors (message_id, embedding) VALUES (?, ?)",
(row["id"], json.dumps(vec)),
)
+ self._vec_upsert_msg(conn, row["id"], vec) # mirror into the ANN index
if missing:
conn.commit()
- # Score every stored message against the query vector.
- cur.execute("""
- SELECT v.message_id, v.embedding, m.conversation_id
- FROM message_vectors v
- JOIN messages m ON m.id = v.message_id
- """)
- scored = []
- for row in cur.fetchall():
- try:
- vec = json.loads(row["embedding"])
- except Exception:
- continue
- score = _cosine(query_vec, vec)
- if score >= min_score:
- scored.append((score, row["message_id"], row["conversation_id"]))
-
- scored.sort(reverse=True)
+ # Rank messages by similarity. Fast path: the sqlite-vec ANN index over an
+ # over-fetch (conversation dedup below thins it); else brute-force cosine.
+ scored = None
+ if self.vec_enabled:
+ scored = self._vec_search_messages(conn, query_vec, max(limit * 5, 20), min_score)
+ if scored is None:
+ cur.execute("""
+ SELECT v.message_id, v.embedding, m.conversation_id
+ FROM message_vectors v
+ JOIN messages m ON m.id = v.message_id
+ """)
+ scored = []
+ for row in cur.fetchall():
+ try:
+ vec = json.loads(row["embedding"])
+ except Exception:
+ continue
+ score = _cosine(query_vec, vec)
+ if score >= min_score:
+ scored.append((score, row["message_id"], row["conversation_id"]))
+ scored.sort(reverse=True)
results: List[dict] = []
seen_convs: set = set()
@@ -616,6 +635,70 @@ class PersistentMemoryStore:
return results
+ # --- message vector index (sqlite-vec) — same pattern as documents --------
+ def _ensure_vec_msgs(self, conn, dim: int) -> None:
+ conn.execute(
+ f"CREATE VIRTUAL TABLE IF NOT EXISTS vec_messages "
+ f"USING vec0(embedding float[{dim}] distance_metric=cosine)"
+ )
+
+ def _vec_upsert_msg(self, conn, message_id: int, vec: list) -> None:
+ if not (self.vec_enabled and vec):
+ return
+ try:
+ import sqlite_vec
+ self._ensure_vec_msgs(conn, len(vec))
+ conn.execute("DELETE FROM vec_messages WHERE rowid = ?", (message_id,))
+ conn.execute(
+ "INSERT INTO vec_messages(rowid, embedding) VALUES (?, ?)",
+ (message_id, sqlite_vec.serialize_float32(vec)),
+ )
+ except Exception:
+ pass
+
+ def _backfill_vec_msgs(self, conn, dim: int) -> None:
+ """Index any message_vectors rows missing from vec_messages."""
+ try:
+ import sqlite_vec
+ self._ensure_vec_msgs(conn, dim)
+ rows = conn.execute(
+ "SELECT mv.message_id AS mid, mv.embedding AS emb FROM message_vectors mv "
+ "LEFT JOIN vec_messages v ON v.rowid = mv.message_id WHERE v.rowid IS NULL"
+ ).fetchall()
+ for r in rows:
+ try:
+ vec = json.loads(r["emb"])
+ if len(vec) == dim:
+ conn.execute(
+ "INSERT INTO vec_messages(rowid, embedding) VALUES (?, ?)",
+ (r["mid"], sqlite_vec.serialize_float32(vec)),
+ )
+ except Exception:
+ pass
+ conn.commit()
+ except Exception:
+ pass
+
+ def _vec_search_messages(self, conn, query_vec: list, fetch: int, min_score: float):
+ """Top message hits via the vec index as sorted [(score, msg_id, conv_id)],
+ or None to fall back to the brute-force scan. Stale rows for deleted
+ messages are dropped by the inner join, so they never surface."""
+ try:
+ import sqlite_vec
+ self._backfill_vec_msgs(conn, len(query_vec))
+ rows = conn.execute(
+ "SELECT v.rowid AS mid, v.distance AS distance, m.conversation_id AS cid "
+ "FROM vec_messages v JOIN messages m ON m.id = v.rowid "
+ "WHERE v.embedding MATCH ? ORDER BY v.distance LIMIT ?",
+ (sqlite_vec.serialize_float32(query_vec), fetch),
+ ).fetchall()
+ return [
+ (1.0 - r["distance"], r["mid"], r["cid"])
+ for r in rows if (1.0 - r["distance"]) >= min_score
+ ] # distance-asc == score-desc, already sorted
+ except Exception:
+ return None
+
def _exchange_pair(self, cur, conv_id: str, message_id: int) -> Optional[dict]:
"""Build a {id, updated_at, matches} record with the full user+assistant
pair surrounding `message_id`, in the shape search callers expect."""
@@ -677,7 +760,7 @@ class PersistentMemoryStore:
chunks.append(buf)
return chunks
- async def add_document(self, title: str, content: str, embed_fn) -> dict:
+ async def add_document(self, title: str, content: str, embed_fn, project_id: str = "") -> dict:
"""Chunk, embed, and store a document. Returns {doc_id, chunks}."""
import uuid as _uuid
doc_id = str(_uuid.uuid4())
@@ -688,16 +771,47 @@ class PersistentMemoryStore:
for i, piece in enumerate(pieces):
vec = await embed_fn(self._EMBED_DOC_PREFIX + piece[:2000])
cur.execute(
- "INSERT INTO documents (id, doc_id, title, chunk_idx, text, embedding, created_at)"
- " VALUES (?, ?, ?, ?, ?, ?, ?)",
+ "INSERT INTO documents (id, doc_id, title, chunk_idx, text, embedding, created_at, project_id)"
+ " VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(str(_uuid.uuid4()), doc_id, title, i, piece,
- json.dumps(vec) if vec else None, now),
+ json.dumps(vec) if vec else None, now, project_id or ""),
)
self._vec_upsert(conn, cur.lastrowid, vec) # mirror into the ANN index
conn.commit()
conn.close()
return {"doc_id": doc_id, "title": title, "chunks": len(pieces)}
+ # --- projects / workspaces --------------------------------------------------
+ def create_project(self, name: str) -> dict:
+ import uuid as _uuid
+ pid = str(_uuid.uuid4())
+ conn = self._connect()
+ conn.execute("INSERT INTO projects (id, name, created_at) VALUES (?, ?, ?)",
+ (pid, name.strip(), time.time()))
+ conn.commit()
+ conn.close()
+ return {"id": pid, "name": name.strip()}
+
+ def list_projects(self) -> List[dict]:
+ conn = self._connect()
+ rows = conn.execute("""
+ SELECT p.id, p.name, p.created_at,
+ (SELECT COUNT(DISTINCT doc_id) FROM documents d WHERE d.project_id = p.id) AS docs
+ FROM projects p ORDER BY p.created_at ASC
+ """).fetchall()
+ conn.close()
+ return [dict(r) for r in rows]
+
+ def delete_project(self, project_id: str) -> bool:
+ """Delete a project; its documents survive but become unscoped ("")."""
+ conn = self._connect()
+ conn.execute("UPDATE documents SET project_id = '' WHERE project_id = ?", (project_id,))
+ cur = conn.execute("DELETE FROM projects WHERE id = ?", (project_id,))
+ deleted = cur.rowcount
+ conn.commit()
+ conn.close()
+ return deleted > 0
+
# --- vector index (sqlite-vec) — accelerator over the JSON embedding column,
# --- with brute-force cosine below as the guaranteed fallback ---------------
def _ensure_vec_docs(self, conn, dim: int) -> None:
@@ -768,13 +882,21 @@ class PersistentMemoryStore:
except Exception:
return None
- def list_documents(self) -> List[dict]:
+ def list_documents(self, project_id: Optional[str] = None) -> List[dict]:
+ """All documents, or just one project's when project_id is given."""
conn = self._connect()
cur = conn.cursor()
- cur.execute("""
- SELECT doc_id, title, COUNT(*) AS chunks, MIN(created_at) AS created_at
- FROM documents GROUP BY doc_id, title ORDER BY created_at DESC
- """)
+ if project_id is not None:
+ cur.execute("""
+ SELECT doc_id, title, COUNT(*) AS chunks, MIN(created_at) AS created_at
+ FROM documents WHERE project_id = ?
+ GROUP BY doc_id, title ORDER BY created_at DESC
+ """, (project_id,))
+ else:
+ cur.execute("""
+ SELECT doc_id, title, COUNT(*) AS chunks, MIN(created_at) AS created_at
+ FROM documents GROUP BY doc_id, title ORDER BY created_at DESC
+ """)
rows = cur.fetchall()
conn.close()
return [dict(r) for r in rows]
@@ -807,23 +929,30 @@ class PersistentMemoryStore:
return deleted > 0
async def search_documents(
- self, query: str, embed_fn, limit: int = 3, min_score: float = 0.6
+ self, query: str, embed_fn, limit: int = 3, min_score: float = 0.6,
+ project_id: Optional[str] = None,
) -> List[dict]:
"""Top-`limit` document chunks most similar to `query`. Returns
- [{title, text, score}]. Empty on no query / embeddings down."""
+ [{title, text, score}]. Empty on no query / embeddings down.
+ When project_id is given, only that project's docs are searched.
+ ponytail: scoped search uses the brute-force path (easy SQL filter, few
+ docs per project); the vec index accelerates the unscoped "All" case."""
if not query or not query.strip():
return []
query_vec = await embed_fn(self._EMBED_QUERY_PREFIX + query.strip())
if not query_vec:
return []
- # Fast path: the sqlite-vec ANN index. None => fall through to brute force.
- if self.vec_enabled:
+ # Fast path (unscoped only): the sqlite-vec ANN index.
+ if not project_id and self.vec_enabled:
hits = self._vec_search(query_vec, limit, min_score)
if hits is not None:
return hits
conn = self._connect()
cur = conn.cursor()
- cur.execute("SELECT title, text, embedding FROM documents WHERE embedding IS NOT NULL")
+ if project_id:
+ cur.execute("SELECT title, text, embedding FROM documents WHERE embedding IS NOT NULL AND project_id = ?", (project_id,))
+ else:
+ cur.execute("SELECT title, text, embedding FROM documents WHERE embedding IS NOT NULL")
scored = []
for row in cur.fetchall():
try:
@@ -852,6 +981,8 @@ class PersistentMemoryStore:
# cosine similarity (0-1) a chunk must clear to count as relevant.
"rag_top_k": 3,
"rag_min_score": 0.6,
+ # Active project/workspace; "" = all documents (unscoped).
+ "active_project": "",
"system_prompt": "",
"timeout": 120,
# How long Ollama keeps the model resident in VRAM between messages.
diff --git a/synapse/stt.py b/synapse/stt.py
new file mode 100644
index 0000000..039f939
--- /dev/null
+++ b/synapse/stt.py
@@ -0,0 +1,55 @@
+"""Local speech-to-text via faster-whisper.
+
+Deliberately torch-free: faster-whisper runs on CTranslate2 (CPU, int8), so it
+fits the "no ML stack in the venv" rule that keeps Promethean small. Replaces
+the browser's Web Speech API, which in Chrome ships audio to Google — the whole
+point is to keep dictation on-device.
+
+Model size via NEXUS_STT_MODEL (default "base"); downloaded and cached on first
+use. If faster-whisper isn't installed, `available()` is False and the backend
+reports it so the UI can fall back.
+"""
+from __future__ import annotations
+
+import base64
+import logging
+import os
+import tempfile
+
+_log = logging.getLogger("nexus.stt")
+_MODEL = None
+_MODEL_SIZE = os.getenv("NEXUS_STT_MODEL", "base")
+
+
+def available() -> bool:
+ try:
+ import faster_whisper # noqa: F401
+ return True
+ except Exception:
+ return False
+
+
+def _get_model():
+ global _MODEL
+ if _MODEL is None:
+ from faster_whisper import WhisperModel
+ _log.info("loading whisper model %s (cpu/int8)", _MODEL_SIZE)
+ _MODEL = WhisperModel(_MODEL_SIZE, device="cpu", compute_type="int8")
+ return _MODEL
+
+
+def transcribe_b64(audio_b64: str) -> str:
+ """Transcribe a base64 audio blob (any container PyAV can decode — the
+ browser's MediaRecorder produces webm/opus)."""
+ raw = base64.b64decode(audio_b64)
+ with tempfile.NamedTemporaryFile(suffix=".webm", delete=False) as f:
+ f.write(raw)
+ path = f.name
+ try:
+ segments, _info = _get_model().transcribe(path, vad_filter=True)
+ return " ".join(s.text for s in segments).strip()
+ finally:
+ try:
+ os.unlink(path)
+ except OSError:
+ pass
diff --git a/synapse/tools.py b/synapse/tools.py
index 68d26b5..1075e32 100644
--- a/synapse/tools.py
+++ b/synapse/tools.py
@@ -1,17 +1,19 @@
-"""Read-only tools a playbook can call during chat.
+"""Tools a playbook can call during chat.
Ollama drives the calling: `/api/chat` with a `tools` param returns
-`message.tool_calls`, and this module is just the registry + dispatch. Every
-tool here only READS local state (SQLite, Ollama) — no side effects. The
-per-playbook allowlist (`PlaybookItem.tools`) is the security boundary; keep the
-registry read-only until the loop is trusted.
+`message.tool_calls`, and this module is just the registry + dispatch.
+
+Most tools READ local state (memory, history, documents, models). A few act:
+`web_search`/`fetch_url` make outbound HTTP requests, and `remember` WRITES a
+memory fact. The per-playbook allowlist (`PlaybookItem.tools`) is the security
+boundary — an action tool only fires when a playbook explicitly lists it.
"""
from __future__ import annotations
import json
from typing import Awaitable, Callable
-from .memory.store import store
+from .memory.store import store, MemoryItem
from .ollama_manager import get_ollama_manager
@@ -51,6 +53,45 @@ async def _get_time(**_) -> str:
return json.dumps({"now": datetime.now().isoformat(timespec="seconds")})
+async def _web_search(query: str = "", **_) -> str:
+ import asyncio as _a
+ from .search import web_search
+ res = await _a.to_thread(web_search, query or "", 4)
+ return res or "(no results)"
+
+
+async def _fetch_url(url: str = "", **_) -> str:
+ import re
+ import httpx
+ url = (url or "").strip()
+ if not url.startswith(("http://", "https://")):
+ return json.dumps({"error": "url must start with http:// or https://"})
+ # ponytail: no SSRF allow/deny-list — local single-user assistant, and the
+ # tool only runs when a playbook explicitly grants fetch_url. Add host
+ # filtering if this ever serves multiple/untrusted users.
+ try:
+ async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as c:
+ r = await c.get(url, headers={"User-Agent": "NexusOS/1.0"})
+ r.raise_for_status()
+ html = r.text
+ except Exception as e:
+ return json.dumps({"error": f"fetch failed: {e}"})
+ text = re.sub(r"(?is)<(script|style).*?\1>", " ", html)
+ text = re.sub(r"(?s)<[^>]+>", " ", text)
+ text = re.sub(r"\s+", " ", text).strip()
+ return text[:4000]
+
+
+async def _remember(text: str = "", section: str = "General", **_) -> str:
+ """WRITE tool: persist a memory fact. First action tool — allowlist-gated."""
+ import uuid as _uuid
+ text = (text or "").strip()
+ if not text:
+ return json.dumps({"error": "text is required"})
+ store.add(MemoryItem(id=str(_uuid.uuid4()), section=(section or "General"), text=text))
+ return json.dumps({"saved": text, "section": section or "General"})
+
+
# name -> (schema, callable). Schema is the OpenAI/Ollama function-tool format.
REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
"search_memory": (
@@ -119,6 +160,54 @@ REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
},
_get_time,
),
+ "web_search": (
+ {
+ "type": "function",
+ "function": {
+ "name": "web_search",
+ "description": "Search the web (DuckDuckGo) and return the top result snippets.",
+ "parameters": {
+ "type": "object",
+ "properties": {"query": {"type": "string"}},
+ "required": ["query"],
+ },
+ },
+ },
+ _web_search,
+ ),
+ "fetch_url": (
+ {
+ "type": "function",
+ "function": {
+ "name": "fetch_url",
+ "description": "Fetch a web page and return its visible text (truncated).",
+ "parameters": {
+ "type": "object",
+ "properties": {"url": {"type": "string", "description": "http(s) URL"}},
+ "required": ["url"],
+ },
+ },
+ },
+ _fetch_url,
+ ),
+ "remember": (
+ {
+ "type": "function",
+ "function": {
+ "name": "remember",
+ "description": "Save a durable fact to the user's persistent memory.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "text": {"type": "string", "description": "the fact to remember"},
+ "section": {"type": "string", "description": "optional category, e.g. Health"},
+ },
+ "required": ["text"],
+ },
+ },
+ },
+ _remember,
+ ),
}
diff --git a/tests/test_documents.py b/tests/test_documents.py
index 19a8cdc..a40ba03 100644
--- a/tests/test_documents.py
+++ b/tests/test_documents.py
@@ -57,6 +57,62 @@ def test_search_empty_query_returns_nothing():
assert asyncio.run(s.search_documents("", _fake_embed)) == []
+def test_conversation_recall_uses_vec_and_matches_brute_force():
+ s = _store()
+ if not s.vec_enabled:
+ import pytest
+ pytest.skip("sqlite-vec not loadable on this host")
+
+ async def run():
+ s.create_conversation("c1")
+ s.add_message("c1", "user", "tell me about lego star wars")
+ s.add_message("c1", "assistant", "lego star wars is a fun game")
+ s.create_conversation("c2")
+ s.add_message("c2", "user", "gpu vega vram notes")
+ s.add_message("c2", "assistant", "vega has 4gb")
+
+ hits = await s.semantic_search_conversations("lego star wars", _fake_embed, limit=2, min_score=0.1)
+ assert hits and hits[0]["id"] == "c1"
+
+ conn = s._connect()
+ n = conn.execute("SELECT COUNT(*) FROM vec_messages").fetchone()[0]
+ conn.close()
+ assert n >= 2 # dual-write populated the message vec index
+
+ s.vec_enabled = False
+ bf = await s.semantic_search_conversations("lego star wars", _fake_embed, limit=2, min_score=0.1)
+ assert bf[0]["id"] == hits[0]["id"]
+
+ asyncio.run(run())
+
+
+def test_projects_scope_documents_and_survive_delete():
+ s = _store()
+
+ async def run():
+ p = s.create_project("Star Wars")
+ assert [x["name"] for x in s.list_projects()] == ["Star Wars"]
+
+ await s.add_document("Lego", "lego star wars boss tips", _fake_embed, project_id=p["id"])
+ await s.add_document("GPU", "gpu vega notes", _fake_embed) # unscoped
+
+ # project sees only its own; unscoped/all sees both
+ assert [d["title"] for d in s.list_documents(p["id"])] == ["Lego"]
+ assert len(s.list_documents(None)) == 2
+
+ # scoped search only returns the project's docs
+ scoped = await s.search_documents("star wars", _fake_embed, min_score=0.1, project_id=p["id"])
+ assert scoped and all(h["title"] == "Lego" for h in scoped)
+
+ # deleting the project keeps the docs but unscopes them
+ assert s.delete_project(p["id"]) is True
+ assert s.list_projects() == []
+ assert len(s.list_documents(None)) == 2
+ assert len(s.list_documents(p["id"])) == 0 # nothing left in that project
+
+ asyncio.run(run())
+
+
def test_chunker_overlap_and_hard_split():
s = _store()
# a single oversized paragraph (no blank lines, as in PDF text) is split
diff --git a/tests/test_smoke.py b/tests/test_smoke.py
index c6ba377..894d717 100644
--- a/tests/test_smoke.py
+++ b/tests/test_smoke.py
@@ -43,6 +43,13 @@ def test_keep_alive_pins_the_model():
assert "keep_alive" not in mgr._apply_keep_alive({"model": "x"})
+def test_stt_status_endpoint():
+ # Reports whether local Whisper is installed; wiring must respond either way.
+ resp = TestClient(app).get("/stt/status")
+ assert resp.status_code == 200
+ assert isinstance(resp.json()["available"], bool)
+
+
def test_num_ctx_option_only_when_positive():
# 0 / None -> omit num_ctx so Ollama uses the model default; positive -> set it.
from synapse.ollama_manager import _chat_options
diff --git a/tests/test_tools.py b/tests/test_tools.py
index 1529ed2..9c1469c 100644
--- a/tests/test_tools.py
+++ b/tests/test_tools.py
@@ -23,6 +23,24 @@ async def _drain(gen):
return [s async for s in gen]
+def test_remember_writes_a_memory_fact(tmp_path, monkeypatch):
+ # The `remember` action tool persists a fact through the store.
+ import synapse.memory.store as store_mod
+ from synapse.memory.store import PersistentMemoryStore
+ fresh = PersistentMemoryStore(tmp_path / "m.db")
+ monkeypatch.setattr(tools, "store", fresh)
+ out = asyncio.run(tools.dispatch("remember", {"text": "user likes tea", "section": "Prefs"}))
+ assert "user likes tea" in out
+ assert any(it.text == "user likes tea" for it in fresh.all())
+
+
+def test_action_tools_registered():
+ for name in ("web_search", "fetch_url", "remember"):
+ assert name in tools.REGISTRY
+ names = [s["function"]["name"] for s in tools.schemas_for(["web_search", "remember", "nope"])]
+ assert names == ["web_search", "remember"]
+
+
class _FakeManager:
"""Returns a tool_call on the first chat() call, plain content after."""
def __init__(self):