feat: Phase 7I + UI refresh (capture form, memory/domain/activity pages, topnav)
Closes three gaps the user surfaced: (1) OpenClaw agents run blind
without AtoCore context, (2) mobile/desktop chats can't be captured
at all, (3) wiki UI hadn't kept up with backend capabilities.
Phase 7I — OpenClaw two-way bridge
- Plugin now calls /context/build on before_agent_start and prepends
the context pack to event.prompt, so whatever LLM runs underneath
(sonnet, opus, codex, local model) answers grounded in AtoCore
knowledge. Captured prompt stays the user's original text; fail-open
with a 5s timeout. Config-gated via injectContext flag.
- Plugin version 0.0.0 → 0.2.0; README rewritten.
UI refresh
- /wiki/capture — paste-to-ingest form for Claude Desktop / web / mobile
/ ChatGPT / other. Goes through normal /interactions pipeline with
client="claude-desktop|claude-web|claude-mobile|chatgpt|other".
Fixes the rotovap/mushroom-on-phone gap.
- /wiki/memories/{id} (Phase 7E) — full memory detail: content, status,
confidence, refs, valid_until, domain_tags (clickable to domain
pages), project link, source chunk, graduated-to-entity link, full
audit trail, related-by-tag neighbors.
- /wiki/domains/{tag} (Phase 7F) — cross-project view: all active
memories with the given tag grouped by project, sorted by count.
Case-insensitive, whitespace-tolerant. Also surfaces graduated
entities carrying the tag.
- /wiki/activity — autonomous-activity timeline feed. Summary chips
by action (created/promoted/merged/superseded/decayed/canonicalized)
and by actor (auto-dedup-tier1, auto-dedup-tier2, confidence-decay,
phase10-auto-promote, transient-to-durable, tag-canon, human-triage).
Answers "what has the brain been doing while I was away?"
- Home refresh: persistent topnav (Home · Activity · Capture · Triage
· Dashboard), "What the brain is doing" snippet above project cards
showing recent autonomous-actor counts, link to full activity.
Tests: +10 (capture page, memory detail + 404, domain cross-project +
empty + tag normalization, activity feed + groupings, home topnav,
superseded-source detail after merge). 440 → 450.
Known next: capture-browser extension for Claude.ai web (bigger
project, deferred); voice/mobile relay (adjacent).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -33,8 +33,12 @@ from atocore.interactions.service import (
|
||||
)
|
||||
from atocore.engineering.mirror import generate_project_overview
|
||||
from atocore.engineering.wiki import (
|
||||
render_activity,
|
||||
render_capture,
|
||||
render_domain,
|
||||
render_entity,
|
||||
render_homepage,
|
||||
render_memory_detail,
|
||||
render_project,
|
||||
render_search,
|
||||
)
|
||||
@@ -119,6 +123,33 @@ def wiki_search(q: str = "") -> HTMLResponse:
|
||||
return HTMLResponse(content=render_search(q))
|
||||
|
||||
|
||||
@router.get("/wiki/capture", response_class=HTMLResponse)
|
||||
def wiki_capture() -> HTMLResponse:
|
||||
"""Phase 7I follow-up: paste mobile/desktop chats into AtoCore."""
|
||||
return HTMLResponse(content=render_capture())
|
||||
|
||||
|
||||
@router.get("/wiki/memories/{memory_id}", response_class=HTMLResponse)
|
||||
def wiki_memory(memory_id: str) -> HTMLResponse:
|
||||
"""Phase 7E: memory detail with audit trail + neighbors."""
|
||||
html = render_memory_detail(memory_id)
|
||||
if html is None:
|
||||
raise HTTPException(status_code=404, detail="Memory not found")
|
||||
return HTMLResponse(content=html)
|
||||
|
||||
|
||||
@router.get("/wiki/domains/{tag}", response_class=HTMLResponse)
|
||||
def wiki_domain(tag: str) -> HTMLResponse:
|
||||
"""Phase 7F: cross-project view for a domain tag."""
|
||||
return HTMLResponse(content=render_domain(tag))
|
||||
|
||||
|
||||
@router.get("/wiki/activity", response_class=HTMLResponse)
|
||||
def wiki_activity(hours: int = 48, limit: int = 100) -> HTMLResponse:
|
||||
"""Autonomous-activity timeline feed."""
|
||||
return HTMLResponse(content=render_activity(hours=hours, limit=limit))
|
||||
|
||||
|
||||
@router.get("/admin/triage", response_class=HTMLResponse)
|
||||
def admin_triage(limit: int = 100) -> HTMLResponse:
|
||||
"""Human triage UI for candidate memories.
|
||||
|
||||
@@ -26,8 +26,26 @@ from atocore.memory.service import get_memories
|
||||
from atocore.projects.registry import load_project_registry
|
||||
|
||||
|
||||
def render_html(title: str, body_html: str, breadcrumbs: list[tuple[str, str]] | None = None) -> str:
|
||||
nav = ""
|
||||
_TOP_NAV_LINKS = [
|
||||
("🏠 Home", "/wiki"),
|
||||
("📡 Activity", "/wiki/activity"),
|
||||
("📥 Capture", "/wiki/capture"),
|
||||
("🔀 Triage", "/admin/triage"),
|
||||
("📊 Dashboard", "/admin/dashboard"),
|
||||
]
|
||||
|
||||
|
||||
def _render_topnav(active_path: str = "") -> str:
|
||||
items = []
|
||||
for label, href in _TOP_NAV_LINKS:
|
||||
cls = "topnav-item active" if href == active_path else "topnav-item"
|
||||
items.append(f'<a href="{href}" class="{cls}">{label}</a>')
|
||||
return f'<nav class="topnav">{" ".join(items)}</nav>'
|
||||
|
||||
|
||||
def render_html(title: str, body_html: str, breadcrumbs: list[tuple[str, str]] | None = None, active_path: str = "") -> str:
|
||||
topnav = _render_topnav(active_path)
|
||||
crumbs = ""
|
||||
if breadcrumbs:
|
||||
parts = []
|
||||
for label, href in breadcrumbs:
|
||||
@@ -35,8 +53,9 @@ def render_html(title: str, body_html: str, breadcrumbs: list[tuple[str, str]] |
|
||||
parts.append(f'<a href="{href}">{label}</a>')
|
||||
else:
|
||||
parts.append(f"<span>{label}</span>")
|
||||
nav = f'<nav class="breadcrumbs">{" / ".join(parts)}</nav>'
|
||||
crumbs = f'<nav class="breadcrumbs">{" / ".join(parts)}</nav>'
|
||||
|
||||
nav = topnav + crumbs
|
||||
return _TEMPLATE.replace("{{title}}", title).replace("{{nav}}", nav).replace("{{body}}", body_html)
|
||||
|
||||
|
||||
@@ -100,6 +119,35 @@ def render_homepage() -> str:
|
||||
lines.append('<button type="submit">Search</button>')
|
||||
lines.append('</form>')
|
||||
|
||||
# What's happening — autonomous activity snippet
|
||||
try:
|
||||
from atocore.memory.service import get_recent_audit
|
||||
recent = get_recent_audit(limit=30)
|
||||
by_action: dict[str, int] = {}
|
||||
by_actor: dict[str, int] = {}
|
||||
for a in recent:
|
||||
by_action[a["action"]] = by_action.get(a["action"], 0) + 1
|
||||
by_actor[a["actor"]] = by_actor.get(a["actor"], 0) + 1
|
||||
# Surface autonomous actors specifically
|
||||
auto_actors = {k: v for k, v in by_actor.items()
|
||||
if k.startswith("auto-") or k == "confidence-decay"
|
||||
or k == "phase10-auto-promote" or k == "transient-to-durable"}
|
||||
if recent:
|
||||
lines.append('<div class="activity-snippet">')
|
||||
lines.append('<h3>📡 What the brain is doing</h3>')
|
||||
top_actions = sorted(by_action.items(), key=lambda x: -x[1])[:6]
|
||||
lines.append('<div class="stat-row">' +
|
||||
"".join(f'<span>{a}: {n}</span>' for a, n in top_actions) +
|
||||
'</div>')
|
||||
if auto_actors:
|
||||
lines.append(f'<p style="font-size:0.9rem; margin:0.3rem 0;">Autonomous actors: ' +
|
||||
" · ".join(f'<code>{k}</code> ({v})' for k, v in auto_actors.items()) +
|
||||
'</p>')
|
||||
lines.append('<p style="font-size:0.85rem; margin:0;"><a href="/wiki/activity">Full timeline →</a></p>')
|
||||
lines.append('</div>')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for bucket_name, items in buckets.items():
|
||||
if not items:
|
||||
continue
|
||||
@@ -167,7 +215,7 @@ def render_homepage() -> str:
|
||||
|
||||
lines.append(f'<p><a href="/admin/triage">Triage Queue</a> · <a href="/admin/dashboard">API Dashboard (JSON)</a> · <a href="/health">Health Check</a></p>')
|
||||
|
||||
return render_html("AtoCore Wiki", "\n".join(lines))
|
||||
return render_html("AtoCore Wiki", "\n".join(lines), active_path="/wiki")
|
||||
|
||||
|
||||
def render_project(project: str) -> str:
|
||||
@@ -288,6 +336,370 @@ def render_search(query: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Phase 7I follow-up — /wiki/capture: paste mobile/desktop chats
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def render_capture() -> str:
|
||||
lines = ['<h1>📥 Capture a conversation</h1>']
|
||||
lines.append(
|
||||
'<p>Paste a chat from Claude Desktop, Claude.ai (web or mobile), '
|
||||
'or any other LLM. It goes through the same pipeline as auto-captured '
|
||||
'interactions: extraction → 3-tier triage → active memory if it carries signal.</p>'
|
||||
)
|
||||
lines.append('<p class="meta">Your prompt + the assistant\'s response. Project is optional — '
|
||||
'the extractor infers it from content.</p>')
|
||||
lines.append("""
|
||||
<form id="capture-form" style="display:flex; flex-direction:column; gap:0.8rem; margin-top:1rem;">
|
||||
<label><strong>Your prompt / question</strong>
|
||||
<textarea id="cap-prompt" required rows="4"
|
||||
style="width:100%; padding:0.6rem; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:6px; font-family:inherit; font-size:0.95rem;"
|
||||
placeholder="Paste what you asked…"></textarea>
|
||||
</label>
|
||||
<label><strong>Assistant response</strong>
|
||||
<textarea id="cap-response" required rows="10"
|
||||
style="width:100%; padding:0.6rem; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:6px; font-family:inherit; font-size:0.95rem;"
|
||||
placeholder="Paste the full assistant response…"></textarea>
|
||||
</label>
|
||||
<div style="display:flex; gap:0.5rem; align-items:center; flex-wrap:wrap;">
|
||||
<label style="display:flex; gap:0.35rem; align-items:center;">Project (optional):
|
||||
<input type="text" id="cap-project" placeholder="auto-detect"
|
||||
style="padding:0.35rem 0.6rem; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:4px; font-family:monospace; width:180px;">
|
||||
</label>
|
||||
<label style="display:flex; gap:0.35rem; align-items:center;">Source:
|
||||
<select id="cap-source" style="padding:0.35rem; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:4px;">
|
||||
<option value="claude-desktop">Claude Desktop</option>
|
||||
<option value="claude-web">Claude.ai web</option>
|
||||
<option value="claude-mobile">Claude mobile</option>
|
||||
<option value="chatgpt">ChatGPT</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit"
|
||||
style="padding:0.6rem 1.2rem; background:var(--accent); color:white; border:none; border-radius:6px; cursor:pointer; font-size:1rem; font-weight:600; align-self:flex-start;">
|
||||
Save to AtoCore
|
||||
</button>
|
||||
</form>
|
||||
<div id="cap-status" style="margin-top:1rem; font-size:0.9rem; min-height:1.5em;"></div>
|
||||
|
||||
<script>
|
||||
document.getElementById('capture-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const prompt = document.getElementById('cap-prompt').value.trim();
|
||||
const response = document.getElementById('cap-response').value.trim();
|
||||
const project = document.getElementById('cap-project').value.trim();
|
||||
const source = document.getElementById('cap-source').value;
|
||||
const status = document.getElementById('cap-status');
|
||||
if (!prompt || !response) { status.textContent = 'Need both prompt and response.'; return; }
|
||||
status.textContent = 'Saving…';
|
||||
try {
|
||||
const r = await fetch('/interactions', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
prompt: prompt, response: response,
|
||||
client: source, project: project, reinforce: true
|
||||
})
|
||||
});
|
||||
if (r.ok) {
|
||||
const data = await r.json();
|
||||
status.innerHTML = '✅ Saved — interaction ' + (data.interaction_id || '?').slice(0,8) +
|
||||
'. Runs through extraction + triage within the hour.<br>' +
|
||||
'<a href="/interactions/' + (data.interaction_id || '') + '">view</a>';
|
||||
document.getElementById('capture-form').reset();
|
||||
} else {
|
||||
status.textContent = '❌ ' + r.status + ': ' + (await r.text()).slice(0, 200);
|
||||
}
|
||||
} catch (err) { status.textContent = '❌ ' + err.message; }
|
||||
});
|
||||
</script>
|
||||
""")
|
||||
lines.append(
|
||||
'<h2>How this works</h2>'
|
||||
'<ul>'
|
||||
'<li><strong>Claude Code</strong> → auto-captured via Stop hook</li>'
|
||||
'<li><strong>OpenClaw</strong> → auto-captured + gets AtoCore context injected on prompt start (Phase 7I)</li>'
|
||||
'<li><strong>Anything else</strong> (Claude Desktop, mobile, web, ChatGPT) → paste here</li>'
|
||||
'</ul>'
|
||||
'<p>The extractor is aggressive about capturing signal — don\'t hand-filter. '
|
||||
'If the conversation had nothing durable, triage will auto-reject.</p>'
|
||||
)
|
||||
|
||||
return render_html(
|
||||
"Capture — AtoCore",
|
||||
"\n".join(lines),
|
||||
breadcrumbs=[("Wiki", "/wiki"), ("Capture", "")],
|
||||
active_path="/wiki/capture",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Phase 7E — /wiki/memories/{id}: memory detail page
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def render_memory_detail(memory_id: str) -> str | None:
|
||||
"""Full view of a single memory: content, audit trail, source refs,
|
||||
neighbors, graduation status. Fills the drill-down gap the list
|
||||
views can't."""
|
||||
from atocore.memory.service import get_memory_audit
|
||||
from atocore.models.database import get_connection
|
||||
|
||||
with get_connection() as conn:
|
||||
row = conn.execute("SELECT * FROM memories WHERE id = ?", (memory_id,)).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
import json as _json
|
||||
mem = dict(row)
|
||||
try:
|
||||
tags = _json.loads(mem.get("domain_tags") or "[]") or []
|
||||
except Exception:
|
||||
tags = []
|
||||
|
||||
lines = [f'<h1>{mem["memory_type"]}: <span style="color:var(--text);">{mem["content"][:80]}</span></h1>']
|
||||
if len(mem["content"]) > 80:
|
||||
lines.append(f'<blockquote><p>{mem["content"]}</p></blockquote>')
|
||||
|
||||
# Metadata row
|
||||
meta_items = [
|
||||
f'<span class="tag">{mem["status"]}</span>',
|
||||
f'<strong>{mem["memory_type"]}</strong>',
|
||||
]
|
||||
if mem.get("project"):
|
||||
meta_items.append(f'<a href="/wiki/projects/{mem["project"]}">{mem["project"]}</a>')
|
||||
meta_items.append(f'confidence: <strong>{float(mem.get("confidence") or 0):.2f}</strong>')
|
||||
meta_items.append(f'refs: <strong>{int(mem.get("reference_count") or 0)}</strong>')
|
||||
if mem.get("valid_until"):
|
||||
meta_items.append(f'<span class="mem-expiry">valid until {str(mem["valid_until"])[:10]}</span>')
|
||||
lines.append(f'<p>{" · ".join(meta_items)}</p>')
|
||||
|
||||
if tags:
|
||||
tag_links = " ".join(f'<a href="/wiki/domains/{t}" class="tag-badge">{t}</a>' for t in tags)
|
||||
lines.append(f'<p><span class="mem-tags">{tag_links}</span></p>')
|
||||
|
||||
lines.append(f'<p class="meta">id: <code>{mem["id"]}</code> · created: {mem["created_at"]}'
|
||||
f' · updated: {mem.get("updated_at", "?")}'
|
||||
+ (f' · last referenced: {mem["last_referenced_at"]}' if mem.get("last_referenced_at") else '')
|
||||
+ '</p>')
|
||||
|
||||
# Graduation
|
||||
if mem.get("graduated_to_entity_id"):
|
||||
eid = mem["graduated_to_entity_id"]
|
||||
lines.append(
|
||||
f'<h2>🎓 Graduated</h2>'
|
||||
f'<p>This memory was promoted to a typed entity: '
|
||||
f'<a href="/wiki/entities/{eid}">{eid[:8]}</a></p>'
|
||||
)
|
||||
|
||||
# Source chunk
|
||||
if mem.get("source_chunk_id"):
|
||||
lines.append(f'<h2>Source chunk</h2><p><code>{mem["source_chunk_id"]}</code></p>')
|
||||
|
||||
# Audit trail
|
||||
audit = get_memory_audit(memory_id, limit=50)
|
||||
if audit:
|
||||
lines.append(f'<h2>Audit trail ({len(audit)} events)</h2><ul>')
|
||||
for a in audit:
|
||||
note = f' — {a["note"]}' if a.get("note") else ""
|
||||
lines.append(
|
||||
f'<li><code>{a["timestamp"]}</code> '
|
||||
f'<strong>{a["action"]}</strong> '
|
||||
f'<em>{a["actor"]}</em>{note}</li>'
|
||||
)
|
||||
lines.append('</ul>')
|
||||
|
||||
# Neighbors by shared tag
|
||||
if tags:
|
||||
from atocore.memory.service import get_memories as _get_memories
|
||||
neighbors = []
|
||||
for t in tags[:3]:
|
||||
for other in _get_memories(active_only=True, limit=30):
|
||||
if other.id == memory_id:
|
||||
continue
|
||||
if any(ot == t for ot in (other.domain_tags or [])):
|
||||
neighbors.append(other)
|
||||
# Dedupe
|
||||
seen = set()
|
||||
uniq = []
|
||||
for n in neighbors:
|
||||
if n.id in seen:
|
||||
continue
|
||||
seen.add(n.id)
|
||||
uniq.append(n)
|
||||
if uniq:
|
||||
lines.append(f'<h2>Related (by tag)</h2><ul>')
|
||||
for n in uniq[:10]:
|
||||
lines.append(
|
||||
f'<li><a href="/wiki/memories/{n.id}">[{n.memory_type}] '
|
||||
f'{n.content[:120]}</a>'
|
||||
+ (f' <span class="tag">{n.project}</span>' if n.project else '')
|
||||
+ '</li>'
|
||||
)
|
||||
lines.append('</ul>')
|
||||
|
||||
return render_html(
|
||||
f"Memory {memory_id[:8]}",
|
||||
"\n".join(lines),
|
||||
breadcrumbs=[("Wiki", "/wiki"), ("Memory", "")],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Phase 7F — /wiki/domains/{tag}: cross-project domain view
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def render_domain(tag: str) -> str:
|
||||
"""All memories + entities carrying a given domain_tag, grouped by project.
|
||||
Answers 'what does the brain know about optics, across all projects?'"""
|
||||
tag = (tag or "").strip().lower()
|
||||
if not tag:
|
||||
return render_html("Domain", "<p>No tag specified.</p>",
|
||||
breadcrumbs=[("Wiki", "/wiki"), ("Domains", "")])
|
||||
|
||||
all_mems = get_memories(active_only=True, limit=500)
|
||||
matching = [m for m in all_mems
|
||||
if any((t or "").lower() == tag for t in (m.domain_tags or []))]
|
||||
|
||||
# Group by project
|
||||
by_project: dict[str, list] = {}
|
||||
for m in matching:
|
||||
by_project.setdefault(m.project or "(global)", []).append(m)
|
||||
|
||||
lines = [f'<h1>Domain: <code>{tag}</code></h1>']
|
||||
lines.append(f'<p class="meta">{len(matching)} active memories across {len(by_project)} projects</p>')
|
||||
|
||||
if not matching:
|
||||
lines.append(
|
||||
f'<p>No memories currently carry the tag <code>{tag}</code>.</p>'
|
||||
'<p>Domain tags are assigned by the extractor when it identifies '
|
||||
'the topical scope of a memory. They update over time.</p>'
|
||||
)
|
||||
return render_html(
|
||||
f"Domain: {tag}",
|
||||
"\n".join(lines),
|
||||
breadcrumbs=[("Wiki", "/wiki"), ("Domains", ""), (tag, "")],
|
||||
)
|
||||
|
||||
# Sort projects by count descending, (global) last
|
||||
def sort_key(item: tuple[str, list]) -> tuple[int, int]:
|
||||
proj, mems = item
|
||||
return (1 if proj == "(global)" else 0, -len(mems))
|
||||
|
||||
for proj, mems in sorted(by_project.items(), key=sort_key):
|
||||
proj_link = proj if proj == "(global)" else f'<a href="/wiki/projects/{proj}">{proj}</a>'
|
||||
lines.append(f'<h2>{proj_link} ({len(mems)})</h2><ul>')
|
||||
for m in mems:
|
||||
other_tags = [t for t in (m.domain_tags or []) if t != tag][:3]
|
||||
other_tags_html = ""
|
||||
if other_tags:
|
||||
other_tags_html = ' <span class="mem-tags">' + " ".join(
|
||||
f'<a href="/wiki/domains/{t}" class="tag-badge">{t}</a>' for t in other_tags
|
||||
) + '</span>'
|
||||
lines.append(
|
||||
f'<li><a href="/wiki/memories/{m.id}">[{m.memory_type}] '
|
||||
f'{m.content[:200]}</a>'
|
||||
f' <span class="meta">conf {m.confidence:.2f} · refs {m.reference_count}</span>'
|
||||
f'{other_tags_html}</li>'
|
||||
)
|
||||
lines.append('</ul>')
|
||||
|
||||
# Entities with this tag (if any have tags — currently they might not)
|
||||
try:
|
||||
all_entities = get_entities(limit=500)
|
||||
ent_matching = []
|
||||
for e in all_entities:
|
||||
tags = e.properties.get("domain_tags") if e.properties else []
|
||||
if isinstance(tags, list) and tag in [str(t).lower() for t in tags]:
|
||||
ent_matching.append(e)
|
||||
if ent_matching:
|
||||
lines.append(f'<h2>🔧 Entities ({len(ent_matching)})</h2><ul>')
|
||||
for e in ent_matching:
|
||||
lines.append(
|
||||
f'<li><a href="/wiki/entities/{e.id}">[{e.entity_type}] {e.name}</a>'
|
||||
+ (f' <span class="tag">{e.project}</span>' if e.project else '')
|
||||
+ '</li>'
|
||||
)
|
||||
lines.append('</ul>')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return render_html(
|
||||
f"Domain: {tag}",
|
||||
"\n".join(lines),
|
||||
breadcrumbs=[("Wiki", "/wiki"), ("Domains", ""), (tag, "")],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# /wiki/activity — autonomous-activity feed
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def render_activity(hours: int = 48, limit: int = 100) -> str:
|
||||
"""Timeline of what the autonomous pipeline did recently. Answers
|
||||
'what has the brain been doing while I was away?'"""
|
||||
from atocore.memory.service import get_recent_audit
|
||||
|
||||
audit = get_recent_audit(limit=limit)
|
||||
|
||||
# Group events by category for summary
|
||||
by_action: dict[str, int] = {}
|
||||
by_actor: dict[str, int] = {}
|
||||
for a in audit:
|
||||
by_action[a["action"]] = by_action.get(a["action"], 0) + 1
|
||||
by_actor[a["actor"]] = by_actor.get(a["actor"], 0) + 1
|
||||
|
||||
lines = [f'<h1>📡 Activity Feed</h1>']
|
||||
lines.append(f'<p class="meta">Last {len(audit)} events in the memory audit log</p>')
|
||||
|
||||
# Summary chips
|
||||
if by_action or by_actor:
|
||||
lines.append('<h2>Summary</h2>')
|
||||
lines.append('<p><strong>By action:</strong> ' +
|
||||
" · ".join(f'{k}: {v}' for k, v in sorted(by_action.items(), key=lambda x: -x[1])) +
|
||||
'</p>')
|
||||
lines.append('<p><strong>By actor:</strong> ' +
|
||||
" · ".join(f'<code>{k}</code>: {v}' for k, v in sorted(by_actor.items(), key=lambda x: -x[1])) +
|
||||
'</p>')
|
||||
|
||||
# Action-type color/emoji
|
||||
action_emoji = {
|
||||
"created": "➕", "promoted": "✅", "rejected": "❌", "invalidated": "🚫",
|
||||
"superseded": "🔀", "reinforced": "🔁", "updated": "✏️",
|
||||
"auto_promoted": "⚡", "created_via_merge": "🔗",
|
||||
"valid_until_extended": "⏳", "tag_canonicalized": "🏷️",
|
||||
}
|
||||
|
||||
lines.append('<h2>Timeline</h2><ul>')
|
||||
for a in audit:
|
||||
emoji = action_emoji.get(a["action"], "•")
|
||||
preview = a.get("content_preview") or ""
|
||||
ts_short = a["timestamp"][:16] if a.get("timestamp") else "?"
|
||||
mid_short = (a.get("memory_id") or "")[:8]
|
||||
note = f' — <em>{a["note"]}</em>' if a.get("note") else ""
|
||||
lines.append(
|
||||
f'<li>{emoji} <code>{ts_short}</code> '
|
||||
f'<strong>{a["action"]}</strong> '
|
||||
f'<em>{a["actor"]}</em> '
|
||||
f'<a href="/wiki/memories/{a["memory_id"]}">{mid_short}</a>'
|
||||
f'{note}'
|
||||
+ (f'<br><span style="opacity:0.6; font-size:0.85rem; margin-left:1.5rem;">{preview[:140]}</span>' if preview else '')
|
||||
+ '</li>'
|
||||
)
|
||||
lines.append('</ul>')
|
||||
|
||||
return render_html(
|
||||
"Activity — AtoCore",
|
||||
"\n".join(lines),
|
||||
breadcrumbs=[("Wiki", "/wiki"), ("Activity", "")],
|
||||
active_path="/wiki/activity",
|
||||
)
|
||||
|
||||
|
||||
_TEMPLATE = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -324,6 +736,17 @@ _TEMPLATE = """<!DOCTYPE html>
|
||||
hr { border: none; border-top: 1px solid var(--border); margin: 2rem 0; }
|
||||
.breadcrumbs { margin-bottom: 1.5rem; font-size: 0.85em; opacity: 0.7; }
|
||||
.breadcrumbs a { opacity: 0.8; }
|
||||
.topnav { display: flex; gap: 0.25rem; flex-wrap: wrap; margin-bottom: 1rem; padding-bottom: 0.8rem; border-bottom: 1px solid var(--border); }
|
||||
.topnav-item { padding: 0.35rem 0.8rem; background: var(--card); border: 1px solid var(--border); border-radius: 6px; font-size: 0.88rem; color: var(--text); opacity: 0.75; text-decoration: none; }
|
||||
.topnav-item:hover { opacity: 1; background: var(--hover); text-decoration: none; }
|
||||
.topnav-item.active { background: var(--accent); color: white; border-color: var(--accent); opacity: 1; }
|
||||
.topnav-item.active:hover { background: var(--accent); }
|
||||
.activity-snippet { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; margin: 1rem 0; }
|
||||
.activity-snippet h3 { color: var(--accent); margin-bottom: 0.4rem; }
|
||||
.activity-snippet ul { margin: 0.3rem 0 0 1.2rem; font-size: 0.9rem; }
|
||||
.activity-snippet li { margin-bottom: 0.2rem; }
|
||||
.stat-row { display: flex; gap: 1rem; flex-wrap: wrap; font-size: 0.9rem; margin: 0.4rem 0; }
|
||||
.stat-row span { padding: 0.1rem 0.4rem; background: var(--hover); border-radius: 4px; }
|
||||
.meta { font-size: 0.8em; opacity: 0.5; margin-top: 0.5rem; }
|
||||
.tag { background: var(--accent); color: var(--bg); padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.75em; margin-left: 0.3rem; }
|
||||
.search-box { display: flex; gap: 0.5rem; margin: 1.5rem 0; }
|
||||
|
||||
Reference in New Issue
Block a user