1159 lines
50 KiB
Python
1159 lines
50 KiB
Python
"""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 & 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("&", "&")
|
||
.replace("<", "<")
|
||
.replace(">", ">"))
|
||
|
||
|
||
def _escape_attr(s: str) -> str:
|
||
return _escape_html(s).replace('"', """)
|
||
|
||
|
||
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>"""
|