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:
2026-04-19 10:14:15 -04:00
parent 877b97ec78
commit 6e43cc7383
7 changed files with 737 additions and 282 deletions

View File

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

View File

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