Files
ATOCore/src/atocore/engineering/wiki.py

1159 lines
50 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""AtoCore Wiki — navigable HTML pages from structured data.
A lightweight wiki served directly from the AtoCore API. Every page is
generated on-demand from the database so it's always current. Source of
truth is the database — the wiki is a derived view.
Routes:
/wiki Homepage with project list + search
/wiki/projects/{name} Full project overview
/wiki/entities/{id} Entity detail with relationships
/wiki/search?q=... Search entities, memories, state
"""
from __future__ import annotations
import markdown as md
from atocore.context.project_state import get_state
from atocore.engineering.service import (
get_entities,
get_entity,
get_entity_with_context,
get_relationships,
)
from atocore.memory.service import get_memories
from atocore.projects.registry import (
GLOBAL_PROJECT,
INBOX_PROJECT,
load_project_registry,
)
_TOP_NAV_LINKS = [
("🏠 Home", "/wiki"),
("📡 Activity", "/wiki/activity"),
("🔀 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:
if href:
parts.append(f'<a href="{href}">{label}</a>')
else:
parts.append(f"<span>{label}</span>")
crumbs = f'<nav class="breadcrumbs">{" / ".join(parts)}</nav>'
nav = topnav + crumbs
return _TEMPLATE.replace("{{title}}", title).replace("{{nav}}", nav).replace("{{body}}", body_html)
def render_homepage() -> str:
projects = []
try:
registered = load_project_registry()
for p in registered:
entity_count = len(get_entities(project=p.project_id, limit=200))
memory_count = len(get_memories(project=p.project_id, active_only=True, limit=200))
state_entries = get_state(p.project_id)
# Pull stage/type/client from state entries
stage = ""
proj_type = ""
client = ""
for e in state_entries:
if e.category == "status":
if e.key == "stage":
stage = e.value
elif e.key == "type":
proj_type = e.value
elif e.key == "client":
client = e.value
projects.append({
"id": p.project_id,
"description": p.description,
"entities": entity_count,
"memories": memory_count,
"state": len(state_entries),
"stage": stage,
"type": proj_type,
"client": client,
})
except Exception:
pass
# Group by high-level bucket
buckets: dict[str, list] = {
"Active Contracts": [],
"Leads & Prospects": [],
"Internal Tools & Infra": [],
"Other": [],
}
for p in projects:
t = p["type"].lower()
s = p["stage"].lower()
if "lead" in t or "lead" in s or "prospect" in s:
buckets["Leads & Prospects"].append(p)
elif "contract" in t or ("active" in s and "contract" in s):
buckets["Active Contracts"].append(p)
elif "infra" in t or "tool" in t or "internal" in t:
buckets["Internal Tools & Infra"].append(p)
else:
buckets["Other"].append(p)
lines = ['<h1>AtoCore Wiki</h1>']
lines.append('<form class="search-box" action="/wiki/search" method="get">')
lines.append('<input type="text" name="q" placeholder="Search entities, memories, projects..." autofocus>')
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
# Issue C: Inbox + Global pseudo-projects alongside registered projects.
# scope_only=True keeps real-project entities out of these counts.
try:
inbox_count = len(get_entities(
project=INBOX_PROJECT, scope_only=True, limit=500,
))
global_count = len(get_entities(
project=GLOBAL_PROJECT, scope_only=True, limit=500,
))
except Exception:
inbox_count = 0
global_count = 0
lines.append('<h2>📥 Inbox &amp; Global</h2>')
lines.append(
'<p class="emerging-intro">Entities that don\'t belong to a specific '
'project yet. <strong>Inbox</strong> holds pre-project leads and quotes. '
'<strong>Global</strong> holds cross-project facts (material properties, '
'vendor capabilities) that apply everywhere.</p>'
)
lines.append('<div class="card-grid">')
lines.append(
f'<a href="/entities?project=inbox&scope_only=true" class="card">'
f'<h3>📥 Inbox</h3>'
f'<p>Pre-project leads, quotes, early conversations.</p>'
f'<div class="stats">{inbox_count} entities</div>'
f'</a>'
)
lines.append(
f'<a href="/entities?project=&scope_only=true" class="card">'
f'<h3>🌐 Global</h3>'
f'<p>Cross-project facts: materials, vendors, shared knowledge.</p>'
f'<div class="stats">{global_count} entities</div>'
f'</a>'
)
lines.append('</div>')
for bucket_name, items in buckets.items():
if not items:
continue
lines.append(f'<h2>{bucket_name}</h2>')
lines.append('<div class="card-grid">')
for p in items:
client_line = f'<div class="client">{p["client"]}</div>' if p["client"] else ''
stage_tag = f'<span class="tag">{p["stage"].split("")[0]}</span>' if p["stage"] else ''
lines.append(f'<a href="/wiki/projects/{p["id"]}" class="card">')
lines.append(f'<h3>{p["id"]} {stage_tag}</h3>')
lines.append(client_line)
lines.append(f'<p>{p["description"][:140]}</p>')
lines.append(f'<div class="stats">{p["entities"]} entities · {p["memories"]} memories · {p["state"]} state</div>')
lines.append('</a>')
lines.append('</div>')
# Phase 6 C.2: Emerging projects section
try:
import json as _json
emerging_projects = []
state_entries = get_state("atocore")
for e in state_entries:
if e.category == "proposals" and e.key == "unregistered_projects":
try:
emerging_projects = _json.loads(e.value)
except Exception:
emerging_projects = []
break
if emerging_projects:
lines.append('<h2>📋 Emerging</h2>')
lines.append('<p class="emerging-intro">Projects that appear in memories but aren\'t yet registered. '
'One click to promote them to first-class projects.</p>')
lines.append('<div class="emerging-grid">')
for ep in emerging_projects[:10]:
name = ep.get("project", "?")
count = ep.get("count", 0)
samples = ep.get("sample_contents", [])
samples_html = "".join(f'<li>{s[:120]}</li>' for s in samples[:2])
lines.append(
f'<div class="emerging-card">'
f'<h3>{name}</h3>'
f'<div class="emerging-count">{count} memories</div>'
f'<ul class="emerging-samples">{samples_html}</ul>'
f'<button class="btn-register-emerging" onclick="registerEmerging({name!r})">📌 Register as project</button>'
f'</div>'
)
lines.append('</div>')
except Exception:
pass
# Quick stats
all_entities = get_entities(limit=500)
all_memories = get_memories(active_only=True, limit=500)
pending = get_memories(status="candidate", limit=500)
lines.append('<h2>System</h2>')
lines.append(f'<p>{len(all_entities)} entities · {len(all_memories)} active memories · {len(projects)} projects</p>')
# Triage queue prompt — surfaced prominently if non-empty
if pending:
tone = "triage-warning" if len(pending) > 50 else "triage-notice"
lines.append(
f'<p class="{tone}">🗂️ <strong>{len(pending)} candidates</strong> awaiting triage — '
f'<a href="/admin/triage">review now →</a></p>'
)
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), active_path="/wiki")
def render_project(project: str) -> str:
from atocore.engineering.mirror import generate_project_overview
markdown_content = generate_project_overview(project)
# Resolve [[Wikilinks]] before markdown so redlinks / cross-project
# indicators appear in the rendered HTML. (Issue B)
markdown_content = _wikilink_transform(markdown_content, current_project=project)
# Convert entity names to links
entities = get_entities(project=project, limit=200)
html_body = md.markdown(markdown_content, extensions=["tables", "fenced_code"])
for ent in sorted(entities, key=lambda e: len(e.name), reverse=True):
linked = f'<a href="/wiki/entities/{ent.id}" title="{ent.entity_type}">{ent.name}</a>'
html_body = html_body.replace(f"<strong>{ent.name}</strong>", f"<strong>{linked}</strong>", 1)
return render_html(
f"{project}",
html_body,
breadcrumbs=[("Wiki", "/wiki"), (project, "")],
)
import re as _re
_WIKILINK_PATTERN = _re.compile(r"\[\[([^\[\]|]+?)(?:\|([^\[\]]+?))?\]\]")
def _resolve_wikilink(target: str, current_project: str | None) -> tuple[str, str, str]:
"""Resolve a ``[[Name]]`` target to ``(href, css_class, extra_suffix)``.
Resolution order (Issue B):
1. Same-project exact name match → live link (class ``wikilink``).
2. Other-project exact name match → live link with ``(in project X)``
suffix (class ``wikilink wikilink-cross``).
3. No match → redlink pointing at ``/wiki/new?name=...`` so clicking
opens a pre-filled "create this entity" form (class ``redlink``).
"""
needle = target.strip()
if not needle:
return ("/wiki/new", "redlink", "")
same_project = None
cross_project = None
try:
candidates = get_entities(name_contains=needle, limit=200)
except Exception:
candidates = []
lowered = needle.lower()
for ent in candidates:
if ent.name.lower() != lowered:
continue
if current_project and ent.project == current_project:
same_project = ent
break
if cross_project is None:
cross_project = ent
if same_project is not None:
return (f"/wiki/entities/{same_project.id}", "wikilink", "")
if cross_project is not None:
suffix = (
f' <span class="wikilink-scope">(in {cross_project.project})</span>'
if cross_project.project
else ' <span class="wikilink-scope">(global)</span>'
)
return (f"/wiki/entities/{cross_project.id}", "wikilink wikilink-cross", suffix)
from urllib.parse import quote
href = f"/wiki/new?name={quote(needle)}"
if current_project:
href += f"&project={quote(current_project)}"
return (href, "redlink", "")
def _wikilink_transform(text: str, current_project: str | None) -> str:
"""Replace ``[[Name]]`` / ``[[Name|Display]]`` tokens with HTML anchors.
Runs before markdown rendering. Emits raw HTML which python-markdown
preserves unchanged.
"""
if not text or "[[" not in text:
return text
def _sub(match: _re.Match) -> str:
target = match.group(1)
display = (match.group(2) or target).strip()
href, cls, suffix = _resolve_wikilink(target, current_project)
title = "create this entity" if cls == "redlink" else target.strip()
return (
f'<a href="{href}" class="{cls}" title="{_escape_attr(title)}">'
f'{_escape_html(display)}</a>{suffix}'
)
return _WIKILINK_PATTERN.sub(_sub, text)
def render_new_entity_form(name: str = "", project: str = "") -> str:
"""Issue B — "create this entity" form targeted by redlinks."""
from atocore.engineering.service import ENTITY_TYPES
safe_name = _escape_attr(name or "")
safe_project = _escape_attr(project or "")
opts = "".join(
f'<option value="{t}">{t}</option>' for t in ENTITY_TYPES
)
lines = [
'<h1>Create entity</h1>',
('<p>This entity was referenced via a wikilink but does not exist yet. '
'Fill in the details to create it — the wiki link will resolve on reload.</p>'),
'<form id="new-entity-form" class="new-entity-form">',
f'<label>Name<br><input type="text" name="name" value="{safe_name}" required></label>',
f'<label>Entity type<br><select name="entity_type" required>{opts}</select></label>',
f'<label>Project<br><input type="text" name="project" value="{safe_project}" '
f'placeholder="leave blank for cross-project / global"></label>',
'<label>Description<br><textarea name="description" rows="4"></textarea></label>',
'<button type="submit">Create</button>',
'<div id="new-entity-result" class="new-entity-result"></div>',
'</form>',
"""<script>
(function() {
const form = document.getElementById('new-entity-form');
const out = document.getElementById('new-entity-result');
form.addEventListener('submit', async (ev) => {
ev.preventDefault();
const fd = new FormData(form);
const body = {
name: fd.get('name'),
entity_type: fd.get('entity_type'),
project: fd.get('project') || '',
description: fd.get('description') || '',
// V1-0: human writes via the wiki form are hand_authored by definition.
hand_authored: true,
};
try {
const r = await fetch('/v1/entities', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
});
const j = await r.json();
if (r.ok) {
out.innerHTML = 'Created: <a href="/wiki/entities/' + j.id + '">' +
(j.name || 'new entity') + '</a>';
setTimeout(() => { window.location.href = '/wiki/entities/' + j.id; }, 800);
} else {
out.textContent = 'Error: ' + (j.detail || JSON.stringify(j));
}
} catch (e) {
out.textContent = 'Network error: ' + e;
}
});
})();
</script>""",
]
return render_html(
f"Create {name}" if name else "Create entity",
"\n".join(lines),
breadcrumbs=[("Wiki", "/wiki"), ("Create entity", "")],
)
def _render_visual_evidence(entity_id: str, ctx: dict) -> str:
"""Render EVIDENCED_BY → artifact links as an inline thumbnail strip."""
from atocore.assets import get_asset
artifacts = []
for rel in ctx["relationships"]:
if rel.source_entity_id != entity_id or rel.relationship_type != "evidenced_by":
continue
target = ctx["related_entities"].get(rel.target_entity_id)
if target is None or target.entity_type != "artifact":
continue
artifacts.append(target)
if not artifacts:
return ""
tiles = []
for art in artifacts:
props = art.properties or {}
kind = props.get("kind", "other")
caption = props.get("caption", art.name)
asset_id = props.get("asset_id")
asset = get_asset(asset_id) if asset_id else None
detail_href = f"/wiki/entities/{art.id}"
if kind == "image" and asset and asset.mime_type.startswith("image/"):
full_href = f"/assets/{asset.id}"
thumb = f"/assets/{asset.id}/thumbnail?size=240"
tiles.append(
f'<figure class="evidence-tile">'
f'<a href="{full_href}" target="_blank" rel="noopener">'
f'<img src="{thumb}" alt="{_escape_attr(caption)}" loading="lazy">'
f'</a>'
f'<figcaption><a href="{detail_href}">{_escape_html(caption)}</a></figcaption>'
f'</figure>'
)
elif kind == "pdf" and asset:
full_href = f"/assets/{asset.id}"
tiles.append(
f'<div class="evidence-tile evidence-pdf">'
f'<a href="{full_href}" target="_blank" rel="noopener">'
f'📄 PDF: {_escape_html(caption)}</a>'
f' · <a href="{detail_href}">details</a>'
f'</div>'
)
else:
tiles.append(
f'<div class="evidence-tile evidence-other">'
f'<a href="{detail_href}">📎 {_escape_html(caption)}</a>'
f' <span class="tag">{kind}</span>'
f'</div>'
)
return (
'<h2>Visual evidence</h2>'
f'<div class="evidence-strip">{"".join(tiles)}</div>'
)
def _render_artifact_body(ent) -> list[str]:
"""Render an artifact entity's own image/pdf/caption."""
from atocore.assets import get_asset
props = ent.properties or {}
kind = props.get("kind", "other")
caption = props.get("caption", "")
capture_context = props.get("capture_context", "")
asset_id = props.get("asset_id")
asset = get_asset(asset_id) if asset_id else None
out: list[str] = []
if kind == "image" and asset and asset.mime_type.startswith("image/"):
out.append(
f'<figure class="artifact-full">'
f'<a href="/assets/{asset.id}" target="_blank" rel="noopener">'
f'<img src="/assets/{asset.id}/thumbnail?size=1024" '
f'alt="{_escape_attr(caption or ent.name)}">'
f'</a>'
f'<figcaption>{_escape_html(caption)}</figcaption>'
f'</figure>'
)
elif kind == "pdf" and asset:
out.append(
f'<p>📄 <a href="/assets/{asset.id}" target="_blank" rel="noopener">'
f'Open PDF ({asset.size_bytes // 1024} KB)</a></p>'
)
elif asset_id:
out.append(f'<p class="meta">asset_id: <code>{asset_id}</code> — blob missing</p>')
if capture_context:
out.append('<h2>Capture context</h2>')
out.append(f'<blockquote>{_escape_html(capture_context)}</blockquote>')
return out
def _escape_html(s: str) -> str:
if s is None:
return ""
return (str(s)
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;"))
def _escape_attr(s: str) -> str:
return _escape_html(s).replace('"', "&quot;")
def render_entity(entity_id: str) -> str | None:
ctx = get_entity_with_context(entity_id)
if ctx is None:
return None
ent = ctx["entity"]
lines = [f'<h1>[{ent.entity_type}] {ent.name}</h1>']
if ent.project:
lines.append(f'<p>Project: <a href="/wiki/projects/{ent.project}">{ent.project}</a></p>')
if ent.description:
desc_html = _wikilink_transform(ent.description, current_project=ent.project)
lines.append(f'<p>{desc_html}</p>')
if ent.properties:
lines.append('<h2>Properties</h2><ul>')
for k, v in ent.properties.items():
lines.append(f'<li><strong>{k}</strong>: {v}</li>')
lines.append('</ul>')
lines.append(f'<p class="meta">confidence: {ent.confidence} · status: {ent.status} · created: {ent.created_at}</p>')
# Issue F: artifact entities render their own image inline; other
# entities render their EVIDENCED_BY artifacts as a visual strip.
if ent.entity_type == "artifact":
lines.extend(_render_artifact_body(ent))
else:
evidence_html = _render_visual_evidence(ent.id, ctx)
if evidence_html:
lines.append(evidence_html)
if ctx["relationships"]:
lines.append('<h2>Relationships</h2><ul>')
for rel in ctx["relationships"]:
other_id = rel.target_entity_id if rel.source_entity_id == entity_id else rel.source_entity_id
other = ctx["related_entities"].get(other_id)
if other:
direction = "\u2192" if rel.source_entity_id == entity_id else "\u2190"
lines.append(
f'<li>{direction} <em>{rel.relationship_type}</em> '
f'<a href="/wiki/entities/{other_id}">[{other.entity_type}] {other.name}</a></li>'
)
lines.append('</ul>')
breadcrumbs = [("Wiki", "/wiki")]
if ent.project:
breadcrumbs.append((ent.project, f"/wiki/projects/{ent.project}"))
breadcrumbs.append((ent.name, ""))
return render_html(ent.name, "\n".join(lines), breadcrumbs=breadcrumbs)
def render_search(query: str) -> str:
lines = [f'<h1>Search: "{query}"</h1>']
# Search entities by name
entities = get_entities(name_contains=query, limit=20)
if entities:
lines.append(f'<h2>Entities ({len(entities)})</h2><ul>')
for e in entities:
proj = f' <span class="tag">{e.project}</span>' if e.project else ''
lines.append(
f'<li><a href="/wiki/entities/{e.id}">[{e.entity_type}] {e.name}</a>{proj}'
f'{"" + e.description[:100] if e.description else ""}</li>'
)
lines.append('</ul>')
# Search memories — match on content OR domain_tags (Phase 3)
all_memories = get_memories(active_only=True, limit=200)
query_lower = query.lower()
matching_mems = [
m for m in all_memories
if query_lower in m.content.lower()
or any(query_lower in (t or "").lower() for t in (m.domain_tags or []))
][:20]
if matching_mems:
lines.append(f'<h2>Memories ({len(matching_mems)})</h2><ul>')
for m in matching_mems:
proj = f' <span class="tag">{m.project}</span>' if m.project else ''
tags_html = ""
if m.domain_tags:
tag_links = " ".join(
f'<a href="/wiki/search?q={t}" class="tag-badge">{t}</a>'
for t in m.domain_tags[:5]
)
tags_html = f' <span class="mem-tags">{tag_links}</span>'
expiry_html = ""
if m.valid_until:
expiry_html = f' <span class="mem-expiry">valid until {m.valid_until[:10]}</span>'
lines.append(
f'<li>[{m.memory_type}]{proj}{tags_html}{expiry_html} '
f'{m.content[:200]}</li>'
)
lines.append('</ul>')
if not entities and not matching_mems:
lines.append('<p>No results found.</p>')
lines.append('<form class="search-box" action="/wiki/search" method="get">')
lines.append(f'<input type="text" name="q" value="{query}" autofocus>')
lines.append('<button type="submit">Search</button>')
lines.append('</form>')
return render_html(
f"Search: {query}",
"\n".join(lines),
breadcrumbs=[("Wiki", "/wiki"), ("Search", "")],
)
# ---------------------------------------------------------------------
# /wiki/capture — DEPRECATED emergency paste-in form.
# Kept as an endpoint because POST /interactions is public anyway, but
# REMOVED from the topnav so it's not promoted as the capture path.
# The sanctioned surfaces are Claude Code (Stop + UserPromptSubmit
# hooks) and OpenClaw (capture plugin with 7I context injection).
# This form is explicitly a last-resort for when someone has to feed
# in an external log and can't get the normal hooks to reach it.
# ---------------------------------------------------------------------
def render_capture() -> str:
lines = ['<h1>📥 Manual capture (fallback only)</h1>']
lines.append(
'<div class="triage-warning"><strong>This is not the capture path.</strong> '
'The sanctioned capture surfaces are Claude Code (Stop hook auto-captures every turn) '
'and OpenClaw (plugin auto-captures + injects AtoCore context on every agent turn). '
'This form exists only as a last resort for external logs you can\'t get into the normal pipeline.</div>'
)
lines.append(
'<p>If you\'re reaching for this page because you had a chat somewhere AtoCore didn\'t see, '
'fix the capture surface instead — don\'t paste. The deliberate scope is Claude Code + OpenClaw.</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>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{title}} — AtoCore</title>
<style>
:root { --bg: #fafafa; --text: #1a1a2e; --accent: #2563eb; --border: #e2e8f0; --card: #fff; --hover: #f1f5f9; }
@media (prefers-color-scheme: dark) {
:root { --bg: #0f172a; --text: #e2e8f0; --accent: #60a5fa; --border: #334155; --card: #1e293b; --hover: #334155; }
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.7; color: var(--text); background: var(--bg);
max-width: 800px; margin: 0 auto; padding: 1.5rem;
}
h1 { font-size: 1.8rem; margin-bottom: 0.5rem; color: var(--accent); }
h2 { font-size: 1.3rem; margin-top: 2rem; margin-bottom: 0.6rem; padding-bottom: 0.2rem; border-bottom: 2px solid var(--border); }
h3 { font-size: 1.1rem; margin-top: 1.2rem; margin-bottom: 0.4rem; }
p { margin-bottom: 0.8rem; }
ul { margin-left: 1.5rem; margin-bottom: 1rem; }
li { margin-bottom: 0.3rem; }
li ul { margin-top: 0.2rem; }
strong { color: var(--accent); font-weight: 600; }
em { opacity: 0.7; font-size: 0.9em; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
blockquote {
background: var(--card); border-left: 4px solid var(--accent);
padding: 0.6rem 1rem; margin: 1rem 0; border-radius: 0 6px 6px 0;
font-size: 0.9em;
}
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; }
.wikilink { color: var(--accent); text-decoration: none; border-bottom: 1px dashed transparent; }
.wikilink:hover { border-bottom-color: var(--accent); }
.wikilink-cross { border-bottom-style: dotted; }
.wikilink-scope { font-size: 0.75em; opacity: 0.6; font-style: italic; }
.redlink { color: #d0473d; text-decoration: none; font-style: italic; border-bottom: 1px dashed #d0473d; }
.redlink:hover { background: rgba(208, 71, 61, 0.08); }
.new-entity-form { display: flex; flex-direction: column; gap: 0.9rem; max-width: 520px; background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem 1.2rem; }
.new-entity-form label { display: flex; flex-direction: column; font-size: 0.88rem; opacity: 0.8; }
.new-entity-form input, .new-entity-form select, .new-entity-form textarea { margin-top: 0.3rem; padding: 0.45rem 0.6rem; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; color: var(--text); font-size: 0.95rem; }
.new-entity-form button { align-self: flex-start; padding: 0.5rem 1.1rem; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; }
.new-entity-result { font-size: 0.9rem; opacity: 0.85; min-height: 1em; }
.evidence-strip { display: flex; flex-wrap: wrap; gap: 0.75rem; margin: 0.75rem 0 1.25rem; }
.evidence-tile { margin: 0; background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 0.4rem; max-width: 270px; }
.evidence-tile img { display: block; max-width: 100%; height: auto; border-radius: 3px; }
.evidence-tile figcaption { font-size: 0.8rem; margin-top: 0.35rem; opacity: 0.85; }
.evidence-pdf, .evidence-other { padding: 0.6rem 0.8rem; font-size: 0.9rem; }
.artifact-full figure, .artifact-full { margin: 0 0 1rem; }
.artifact-full img { display: block; max-width: 100%; height: auto; border: 1px solid var(--border); border-radius: 4px; }
.artifact-full figcaption { font-size: 0.9rem; margin-top: 0.5rem; opacity: 0.85; }
.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; }
.search-box input {
flex: 1; padding: 0.6rem 1rem; border: 2px solid var(--border);
border-radius: 8px; background: var(--card); color: var(--text);
font-size: 1rem;
}
.search-box input:focus { border-color: var(--accent); outline: none; }
.search-box button {
padding: 0.6rem 1.2rem; background: var(--accent); color: var(--bg);
border: none; border-radius: 8px; cursor: pointer; font-size: 1rem;
}
.card-grid { display: grid; grid-template-columns: 1fr; gap: 1rem; margin: 1rem 0; }
@media (min-width: 600px) { .card-grid { grid-template-columns: 1fr 1fr; } }
.card {
display: block; background: var(--card); border: 1px solid var(--border);
border-radius: 10px; padding: 1.2rem; text-decoration: none;
color: var(--text); transition: border-color 0.2s;
}
.card:hover { border-color: var(--accent); background: var(--hover); text-decoration: none; }
.card h3 { color: var(--accent); margin: 0 0 0.3rem 0; }
.card p { font-size: 0.9em; margin: 0; opacity: 0.8; }
.card .stats { font-size: 0.8em; margin-top: 0.5rem; opacity: 0.5; }
.card .client { font-size: 0.85em; opacity: 0.65; margin-bottom: 0.3rem; font-style: italic; }
.card h3 .tag { font-size: 0.65em; vertical-align: middle; margin-left: 0.4rem; }
.triage-notice { background: var(--card); border-left: 4px solid var(--accent); padding: 0.6rem 1rem; border-radius: 4px; margin: 0.8rem 0; }
.triage-warning { background: #fef3c7; color: #78350f; border-left: 4px solid #d97706; padding: 0.6rem 1rem; border-radius: 4px; margin: 0.8rem 0; }
@media (prefers-color-scheme: dark) { .triage-warning { background: #451a03; color: #fde68a; } }
.mem-tags { display: inline-flex; gap: 0.25rem; flex-wrap: wrap; vertical-align: middle; }
.tag-badge { background: var(--accent); color: white; padding: 0.1rem 0.5rem; border-radius: 10px; font-size: 0.7rem; font-family: monospace; text-decoration: none; font-weight: 500; }
.tag-badge:hover { opacity: 0.85; text-decoration: none; }
.mem-expiry { font-size: 0.75rem; color: #d97706; font-style: italic; margin-left: 0.4rem; }
@media (prefers-color-scheme: dark) { .mem-expiry { color: #fbbf24; } }
/* Phase 6 C.2 — Emerging projects section */
.emerging-intro { font-size: 0.9rem; opacity: 0.75; margin-bottom: 0.8rem; }
.emerging-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; margin-bottom: 1rem; }
.emerging-card { background: var(--card); border: 1px dashed var(--accent); border-radius: 8px; padding: 1rem; }
.emerging-card h3 { margin: 0 0 0.3rem 0; color: var(--accent); font-family: monospace; font-size: 1rem; }
.emerging-count { font-size: 0.8rem; opacity: 0.6; margin-bottom: 0.5rem; }
.emerging-samples { font-size: 0.85rem; margin: 0.5rem 0; padding-left: 1.2rem; opacity: 0.8; }
.emerging-samples li { margin-bottom: 0.25rem; }
.btn-register-emerging { width: 100%; padding: 0.45rem 0.9rem; background: var(--accent); color: white; border: 1px solid var(--accent); border-radius: 4px; cursor: pointer; font-size: 0.88rem; font-weight: 500; margin-top: 0.5rem; }
.btn-register-emerging:hover { opacity: 0.9; }
</style>
<script>
async function registerEmerging(projectId) {
if (!confirm(`Register "${projectId}" as a first-class project?\n\nThis creates:\n• /wiki/projects/${projectId} page\n• System map + gaps + killer queries\n• Triage + graduation support\n\nIngest root defaults to vault:incoming/projects/${projectId}/`)) {
return;
}
try {
const r = await fetch('/admin/projects/register-emerging', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({project_id: projectId}),
});
if (r.ok) {
const data = await r.json();
alert(data.message || `Registered ${projectId}`);
window.location.reload();
} else {
const err = await r.text();
alert(`Registration failed: ${r.status}\n${err.substring(0, 300)}`);
}
} catch (e) {
alert(`Network error: ${e.message}`);
}
}
</script>
</head>
<body>
{{nav}}
{{body}}
</body>
</html>"""