diff --git a/dashboard/api.js b/dashboard/api.js index 092a5a7..a25f498 100644 --- a/dashboard/api.js +++ b/dashboard/api.js @@ -93,6 +93,10 @@ const api = { recomputeAgentTime: () => api.post('/api/agent-time/recompute', {}), // Agent Insights (AI usage analytics) getAgentInsights: (recompute = false) => api.get(`/api/agent-insights${recompute ? '?recompute=true' : ''}`), + // Brain Index (centralized knowledge pipeline) + getBrainIndexSearch: (q, source = '', limit = 20) => api.get(`/api/brain-index/search?q=${encodeURIComponent(q)}&limit=${limit}${source ? `&source=${encodeURIComponent(source)}` : ''}`), + getBrainIndexStats: () => api.get('/api/brain-index/stats'), + brainIndexIngest: () => api.post('/api/brain-index/ingest', {}), // Agent Registry getAgents: () => api.get('/api/agents'), registerAgent: (data) => api.post('/api/agents/register', data), diff --git a/dashboard/index.html b/dashboard/index.html index da7abd9..d126e21 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -50,6 +50,7 @@ 🧭Smart Router πŸ“ŠLearning Analytics 🧠Agent Insights + πŸ”Brain Search πŸ”„Session Replay ⏱Agent Time diff --git a/dashboard/pages/brain-search.js b/dashboard/pages/brain-search.js new file mode 100644 index 0000000..a810512 --- /dev/null +++ b/dashboard/pages/brain-search.js @@ -0,0 +1,91 @@ +// Brain Search β€” query the centralized brain index (brain/ notes, +// skill learnings, chat history) via /api/brain-index/search. + +async function renderBrainSearch() { + const content = document.getElementById('pageContent'); + content.innerHTML = ` + +
+
+ + + +
+
+
+
πŸ”
Search your brain
Type a query above to search across all indexed knowledge.
+ `; + // Load stats + initial hint. + try { + const s = await api.getBrainIndexStats(); + const parts = Object.entries(s) + .filter(([k]) => k !== 'total') + .map(([k, v]) => `${k}: ${v}`); + document.getElementById('brainStats').textContent = + `Indexed: ${s.total || 0} docs (${parts.join(' Β· ')})`; + } catch (e) { + document.getElementById('brainStats').textContent = 'Index not built yet β€” click Re-ingest.'; + } +} + +async function brainSearchRun() { + const q = document.getElementById('brainQuery').value.trim(); + const source = document.getElementById('brainSource').value; + const box = document.getElementById('brainResults'); + if (!q) { box.innerHTML = '
Enter a search term
'; return; } + box.innerHTML = '
Searching…
'; + try { + const data = await api.getBrainIndexSearch(q, source); + if (!data.results.length) { + box.innerHTML = '
πŸ”
No results
Nothing matched "' + escapeHtml(q) + '"' + (source ? ' in ' + escapeHtml(source) : '') + '.
'; + return; + } + box.innerHTML = `
${data.count} result(s) for "${escapeHtml(q)}"
` + + data.results.map(r => { + const srcIcon = { 'brain': 'πŸ“', 'skill-learning': '🧠', 'chat': 'πŸ’¬', 'agent-note': 'πŸ€–' }[r.source] || 'πŸ“„'; + const agent = r.agent ? `${escapeHtml(r.agent)}` : ''; + return ` +
+
+ ${srcIcon} + ${escapeHtml(r.title)} + ${escapeHtml(r.source)} + ${agent} +
+
${escapeHtml(r.snippet || '')}
+
`; + }).join(''); + } catch (err) { + box.innerHTML = `
⚠
Search failed
${escapeHtml(err.message)}
`; + } +} + +async function brainSearchIngest() { + const btn = event && event.target; + if (btn) { btn.disabled = true; btn.textContent = 'Ingesting…'; } + try { + const r = await api.brainIndexIngest(); + const s = r.stats || {}; + if (btn) btn.textContent = 'βœ“ Re-ingest'; + const statEl = document.getElementById('brainStats'); + if (statEl) { + const parts = Object.entries(s).filter(([k]) => k !== 'total').map(([k, v]) => `${k}: ${v}`); + statEl.textContent = `Indexed: ${s.total || 0} docs (${parts.join(' Β· ')})`; + } + } catch (e) { + if (btn) btn.textContent = 'Ingest failed'; + } finally { + if (btn) setTimeout(() => { btn.disabled = false; btn.textContent = '⟳ Re-ingest'; }, 1500); + } +} diff --git a/dashboard/utils.js b/dashboard/utils.js index da28eec..6a59491 100644 --- a/dashboard/utils.js +++ b/dashboard/utils.js @@ -118,6 +118,7 @@ const PAGE_TITLES = { audit: { title: 'Audit Log', breadcrumb: 'System activity trail' }, cost: { title: 'Cost Analytics', breadcrumb: 'Usage & spending' }, 'agent-insights': { title: 'Agent Insights', breadcrumb: 'AI usage analytics' }, + 'brain-search': { title: 'Brain Search', breadcrumb: 'Unified knowledge index' }, plugins: { title: 'Plugin Registry', breadcrumb: 'Manage plugins' }, backups: { title: 'Backups', breadcrumb: 'Disaster recovery' }, prompts: { title: 'Prompt Library', breadcrumb: 'Reusable templates' }, diff --git a/scheduler/jobs/brain-index-ingest-job.json b/scheduler/jobs/brain-index-ingest-job.json new file mode 100644 index 0000000..c99be09 --- /dev/null +++ b/scheduler/jobs/brain-index-ingest-job.json @@ -0,0 +1,10 @@ +{ + "id": "brain-index-ingest", + "name": "Brain Index Auto-Ingest", + "endpoint": "/api/brain-index/ingest", + "cron": "*/30 * * * *", + "enabled": true, + "created": "2026-07-25T00:00:00Z", + "last_run": null, + "next_run": null +} diff --git a/scheduler/scheduler.py b/scheduler/scheduler.py index f27dd35..d7ca2b5 100644 --- a/scheduler/scheduler.py +++ b/scheduler/scheduler.py @@ -49,7 +49,7 @@ def log_audit(entry: dict): def run_skill_via_api(skill_name: str, agent: str = "auto") -> dict: """Invoke a skill through the live server API. - Returns a dict with at least {'ok': bool, 'reason'/'output': ...}. + Returns a dict with at least {'ok': bool, 'reason'/'data': ...}. """ url = f"{SERVER_URL}/api/skills/{skill_name}/run" payload = json.dumps({"input": "", "agent": agent}).encode() @@ -68,32 +68,58 @@ def run_skill_via_api(skill_name: str, agent: str = "auto") -> dict: return {"ok": False, "reason": str(e)} -def run_job(job: dict): - skill = job.get("skill") - name = job.get("name", skill) - agent = job.get("agent", "auto") - print(f"[{datetime.now().isoformat()}] Firing job '{name}' -> skill '{skill}'") - log_audit({"action": "scheduler_run", "job": name, "skill": skill, "stage": "start"}) +def run_endpoint_via_api(endpoint: str) -> dict: + """POST to an arbitrary server endpoint (e.g. /api/brain-index/ingest) + so scheduler jobs can trigger maintenance tasks that aren't skills.""" + url = f"{SERVER_URL}{endpoint}" + req = urllib.request.Request( + url, data=b"{}", headers={"Content-Type": "application/json"}, method="POST" + ) + try: + with urllib.request.urlopen(req, timeout=RUN_TIMEOUT) as resp: + data = json.loads(resp.read()) + return {"ok": True, "data": data} + except urllib.error.HTTPError as e: + return {"ok": False, "reason": f"HTTP {e.code}: {e.reason}"} + except urllib.error.URLError as e: + return {"ok": False, "reason": f"server unreachable: {e.reason}"} + except Exception as e: + return {"ok": False, "reason": str(e)} - if not skill: - log_audit({"action": "scheduler_run", "job": name, "error": "no skill defined"}) + +def run_job(job: dict): + name = job.get("name", job.get("skill") or job.get("endpoint")) + agent = job.get("agent", "auto") + endpoint = job.get("endpoint") + skill = job.get("skill") + print(f"[{datetime.now().isoformat()}] Firing job '{name}' -> " + f"{('endpoint ' + endpoint) if endpoint else ('skill ' + str(skill))}") + log_audit({"action": "scheduler_run", "job": name, + "skill": skill, "endpoint": endpoint, "stage": "start"}) + + if endpoint: + result = run_endpoint_via_api(endpoint) + elif skill: + result = run_skill_via_api(skill, agent) + else: + log_audit({"action": "scheduler_run", "job": name, "error": "no skill or endpoint defined"}) return - result = run_skill_via_api(skill, agent) if result["ok"]: - data = result["data"] + data = result.get("data", {}) log_audit({ "action": "scheduler_run", "job": name, "skill": skill, - "agent": data.get("agent"), - "run_id": data.get("run_id"), + "endpoint": endpoint, + "result": data, "stage": "done", }) - print(f" -> OK (agent={data.get('agent')}, run_id={data.get('run_id')})") + print(f" -> OK") else: reason = result["reason"] - log_audit({"action": "scheduler_run", "job": name, "skill": skill, "error": reason}) + log_audit({"action": "scheduler_run", "job": name, + "skill": skill, "endpoint": endpoint, "error": reason}) print(f" -> FAILED: {reason}") @@ -112,20 +138,22 @@ def load_jobs(scheduler: BackgroundScheduler): continue cron = data.get("cron") skill = data.get("skill") - if not cron or not skill: - print(f" Skipping job {data.get('name')}: missing cron or skill") + endpoint = data.get("endpoint") + if not cron or not (skill or endpoint): + print(f" Skipping job {data.get('name')}: missing cron and (skill or endpoint)") continue scheduler.add_job( run_job, CronTrigger.from_crontab(cron), args=[data], id=data.get("id", data["name"]), - name=data.get("name", skill), + name=data.get("name", skill or endpoint), replace_existing=True, max_instances=1, coalesce=True, ) - print(f" Scheduled: {data.get('name')} (skill={skill}, cron={cron})") + print(f" Scheduled: {data.get('name')} " + f"({'endpoint=' + endpoint if endpoint else 'skill=' + str(skill)}, cron={cron})") def main(): diff --git a/server.py b/server.py index fa5df21..1c77fd1 100644 --- a/server.py +++ b/server.py @@ -187,6 +187,34 @@ def append_audit(entry: dict): # underlying operation, but surface it on the server console. print(f"[audit] failed to write entry {entry.get('action')!r}: {e}") + +def record_brain_learning(source: str, source_path: str, title: str, + content: str, agent: str | None = None, + updated_at: str | None = None) -> bool: + """Push a single document into the centralized brain index. + + Best-effort: a brain-index failure must never break the calling + operation (e.g. a skill run). Calls the same engine the CLI/HTTP + endpoints use, synchronously β€” no HTTP round-trip. + """ + try: + mod = _brain_index_module() + conn = mod.get_conn() + try: + mod.upsert_doc( + conn, source=source, source_path=source_path, title=title, + content=content, agent=agent, updated_at=updated_at, + ) + conn.commit() + return True + finally: + conn.close() + except Exception as e: + print(f"[brain-index] learning upsert failed ({source}/{source_path}): {e}") + return False + + + # ─── Agent Stats (real, persisted) ────────────────────────────── AGENT_STATS_FILE = BASE_DIR / "data" / "agent-stats.json" @@ -858,6 +886,17 @@ def run_skill(name: str, req: Optional[SkillRunRequest] = None): ) write_file(path / "learnings.md", existing + new_entry) + # Also push this learning into the centralized brain index so it is + # immediately searchable alongside brain/ notes and chat history. + record_brain_learning( + source="skill-learning", + source_path=str(path.relative_to(BASE_DIR) / "learnings.md") + f"#{run_id}", + title=f"{name} β€” learning {run_id}", + content=f"## {timestamp} (Run {run_id})\n- Agent: {agent_choice}\n- Input: {skill_input or '(none)'}\n- Output: {response_text[:500]}", + agent=agent_choice, + updated_at=get_timestamp(), + ) + # Log execution append_audit({ "action": "skill_run",