feat(brain): brain search dashboard + scheduled auto-ingest + skill-run auto-upsert

1) Brain Search page (dashboard/pages/brain-search.js): mirrors Agent
   Insights; queries /api/brain-index/search with source filter + live
   index stats; nav entry + PAGE_TITLES added.
2) Scheduled auto-ingest: scheduler gains endpoint-style jobs
   (run_endpoint_via_api) so non-skill maintenance tasks can be cron'd;
   new job brain-index-ingest-job.json re-indexes every 30 min.
3) Agents auto-upsert learnings: run_skill wrap-up now calls
   record_brain_learning() so each skill run is immediately searchable
   in the unified index (best-effort, never breaks the run).

Fixed a regression where AGENT_STATS_FILE def was dropped during the
brain-learning helper insertion (would have broken all skill runs).

Verified live: brain search returns ranked results; run_skill wrap-up
upserts a searchable doc; scheduler imports + job validates.
This commit is contained in:
Austin 2026-07-25 15:49:14 -07:00
parent a36dff7f2c
commit 257d64327a
7 changed files with 193 additions and 19 deletions

View File

@ -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),

View File

@ -50,6 +50,7 @@
<a href="#smart-router" class="nav-item" data-page="smart-router"><span class="nav-icon">🧭</span><span class="nav-label">Smart Router</span></a>
<a href="#learning-analytics" class="nav-item" data-page="learning-analytics"><span class="nav-icon">📊</span><span class="nav-label">Learning Analytics</span></a>
<a href="#agent-insights" class="nav-item" data-page="agent-insights"><span class="nav-icon">🧠</span><span class="nav-label">Agent Insights</span></a>
<a href="#brain-search" class="nav-item" data-page="brain-search"><span class="nav-icon">🔍</span><span class="nav-label">Brain Search</span></a>
<a href="#session-replay" class="nav-item" data-page="session-replay"><span class="nav-icon">🔄</span><span class="nav-label">Session Replay</span></a>
<a href="#agent-time" class="nav-item" data-page="agent-time"><span class="nav-icon"></span><span class="nav-label">Agent Time</span></a>
<div class="sidebar-section"><div class="sidebar-section-label">Management</div></div>

View File

@ -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 = `
<div class="page-header">
<div class="page-header-left">
<h1 class="page-title">Brain Search</h1>
<p class="page-subtitle">Full-text search across your unified knowledge: notes, skill learnings, chat history</p>
</div>
<button class="btn btn-secondary" onclick="brainSearchIngest()"> Re-ingest</button>
</div>
<div class="card mb-4">
<div class="flex gap-2 items-center">
<input id="brainQuery" class="form-input" style="flex:1" placeholder="Search the brain… (e.g. router, gemini, deployment)" onkeydown="if(event.key==='Enter')brainSearchRun()" />
<select id="brainSource" class="form-input" style="max-width:180px">
<option value="">All sources</option>
<option value="brain">📝 Notes (brain/)</option>
<option value="skill-learning">🧠 Skill learnings</option>
<option value="chat">💬 Chat history</option>
</select>
<button class="btn btn-primary" onclick="brainSearchRun()">Search</button>
</div>
<div id="brainStats" class="mt-2" style="font-size:11px;color:var(--text-muted)"></div>
</div>
<div id="brainResults"><div class="empty-state"><div class="empty-state-icon">🔍</div><div class="empty-state-title">Search your brain</div><div class="empty-state-desc">Type a query above to search across all indexed knowledge.</div></div></div>
`;
// 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 = '<div class="empty-state"><div class="empty-state-title">Enter a search term</div></div>'; return; }
box.innerHTML = '<div class="loading"><div class="loading-spinner"></div><span>Searching…</span></div>';
try {
const data = await api.getBrainIndexSearch(q, source);
if (!data.results.length) {
box.innerHTML = '<div class="empty-state"><div class="empty-state-icon">🔍</div><div class="empty-state-title">No results</div><div class="empty-state-desc">Nothing matched "' + escapeHtml(q) + '"' + (source ? ' in ' + escapeHtml(source) : '') + '.</div></div>';
return;
}
box.innerHTML = `<div class="page-subtitle mb-2">${data.count} result(s) for "${escapeHtml(q)}"</div>` +
data.results.map(r => {
const srcIcon = { 'brain': '📝', 'skill-learning': '🧠', 'chat': '💬', 'agent-note': '🤖' }[r.source] || '📄';
const agent = r.agent ? `<span class="badge badge-accent" style="font-size:10px;margin-left:6px">${escapeHtml(r.agent)}</span>` : '';
return `
<div class="card mb-2" style="border-left:4px solid var(--accent)">
<div class="flex items-center gap-2 mb-1">
<span>${srcIcon}</span>
<strong style="font-size:13px">${escapeHtml(r.title)}</strong>
<span class="badge" style="font-size:10px;opacity:.7">${escapeHtml(r.source)}</span>
${agent}
</div>
<div style="font-size:12px;color:var(--text-muted);white-space:pre-wrap">${escapeHtml(r.snippet || '')}</div>
</div>`;
}).join('');
} catch (err) {
box.innerHTML = `<div class="empty-state"><div class="empty-state-icon">⚠</div><div class="empty-state-title">Search failed</div><div class="empty-state-desc">${escapeHtml(err.message)}</div></div>`;
}
}
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);
}
}

View File

@ -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' },

View File

@ -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
}

View File

@ -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():

View File

@ -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",