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 <noreply@anthropic.com>
This commit is contained in:
jon
2026-07-23 14:22:46 -05:00
co-authored by Claude Opus 4.8
parent f4aea78b55
commit ba6a4ac4e4
12 changed files with 586 additions and 53 deletions
+64 -11
View File
@@ -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
📎
<input type="file" accept="image/*" multiple onChange={onImagePick} disabled={loading} style={{ display: "none" }} />
</label>
{SpeechRec && (
<button onClick={toggleMic} title={listening ? "Stop dictation" : "Dictate (speech to text)"}
style={{ padding: "0.9rem", background: listening ? "#3a1a1a" : "#222", border: "1px solid " + (listening ? "#c0392b" : "#333"), borderRadius: "10px", cursor: "pointer", color: listening ? "#ff8a80" : "#ccc" }}>
{listening ? "🔴" : "🎤"}
{((sttLocal && canRecord) || SpeechRec) && (
<button onClick={toggleMic}
title={transcribing ? "Transcribing…" : listening ? "Stop dictation" : sttLocal ? "Dictate (on-device Whisper)" : "Dictate (browser speech)"}
style={{ padding: "0.9rem", background: listening ? "#3a1a1a" : "#222", border: "1px solid " + (listening ? "#c0392b" : "#333"), borderRadius: "10px", cursor: transcribing ? "default" : "pointer", color: listening ? "#ff8a80" : "#ccc" }}>
{transcribing ? "⏳" : listening ? "🔴" : "🎤"}
</button>
)}
<textarea
+62 -1
View File
@@ -10,6 +10,43 @@ export function Documents() {
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
const [viewing, setViewing] = useState(null); // {title, chunks} being previewed
const [projects, setProjects] = useState([]);
const [activeProject, setActiveProject] = useState(""); // "" = All
const loadProjects = async () => {
try {
const r = await fetch(`${API_BASE}/projects`);
if (r.ok) { const d = await r.json(); setProjects(d.projects || []); setActiveProject(d.active || ""); }
} catch { /* offline */ }
};
// Switch the active workspace (persisted in settings; scopes chat RAG too).
const setActive = async (pid) => {
await fetch(`${API_BASE}/settings`, {
method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ active_project: pid }),
});
setActiveProject(pid);
load();
loadProjects();
};
const newProject = async () => {
const name = window.prompt("New project name:");
if (!name || !name.trim()) return;
const r = await fetch(`${API_BASE}/projects`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim() }),
});
if (r.ok) { const p = await r.json(); await setActive(p.id); }
};
const removeProject = async () => {
if (!activeProject) return;
if (!window.confirm("Delete this project? Its documents are kept but become unscoped.")) return;
await fetch(`${API_BASE}/projects/${encodeURIComponent(activeProject)}`, { method: "DELETE" });
await setActive("");
};
const openDoc = async (doc) => {
try {
@@ -25,7 +62,7 @@ export function Documents() {
} catch { /* offline — leave list as-is */ }
};
useEffect(() => { load(); }, []);
useEffect(() => { load(); loadProjects(); }, []);
// Files (pdf/docx/txt/md) upload straight to the server, which extracts the
// text. Base64 in JSON — no multipart dependency.
@@ -106,6 +143,30 @@ export function Documents() {
material when a message is relevant.
</p>
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem", marginBottom: "1.2rem", flexWrap: "wrap" }}>
<span style={{ color: "#888", fontSize: "0.85rem" }}>Workspace:</span>
<select
value={activeProject}
onChange={e => (e.target.value === "__new__" ? newProject() : setActive(e.target.value))}
style={{ padding: "0.5rem 0.7rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "8px" }}
>
<option value="">All documents</option>
{projects.map(p => (
<option key={p.id} value={p.id}>{p.name} ({p.docs})</option>
))}
<option value="__new__"> New project…</option>
</select>
{activeProject && (
<button onClick={removeProject} title="Delete this project (documents kept)"
style={{ padding: "0.4rem 0.7rem", background: "#2a1a1a", color: "#ff8a80", border: "1px solid #5a2a2a", borderRadius: "8px", cursor: "pointer", fontSize: "0.8rem" }}>
Delete project
</button>
)}
<span style={{ color: "#666", fontSize: "0.78rem" }}>
{activeProject ? "Chat scopes to this project's documents." : "Chat searches all documents."}
</span>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "0.7rem", marginBottom: "1.5rem" }}>
<input type="text" placeholder="Title" value={title}
onChange={e => setTitle(e.target.value)} style={input} />
+1 -1
View File
@@ -360,7 +360,7 @@ export function Playbook() {
/>
<input
type="text"
placeholder="Tools (comma separated): search_memory, search_history, search_documents, list_models, get_time"
placeholder="Tools: search_memory, search_history, search_documents, list_models, get_time, web_search, fetch_url, remember"
value={form.tools}
onChange={e => setForm(prev => ({ ...prev, tools: e.target.value }))}
style={{ padding: "0.9rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "10px" }}
+2
View File
@@ -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
+2
View File
@@ -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.
+62 -3
View File
@@ -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}")
+162 -31
View File
@@ -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.
+55
View File
@@ -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
+95 -6
View File
@@ -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,
),
}
+56
View File
@@ -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
+7
View File
@@ -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
+18
View File
@@ -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):