cortexm/trajectory_view.py
Rama main · réplica de lectura del repositorio original.
"""Web trajectory viewer (Reddit 40 mentions: UI / dashboard / viewer).
Lean and simple: a single self-contained HTML file served by a
tiny stdlib HTTP server. No React, no Vite, no CDN. Open the URL
in any browser; the audit log renders as a step-by-step timeline
with click-to-expand payloads.
Usage::
cortexm trajectory-view --db /path/to/mem.db --port 8901
# → http://localhost:8901
API endpoints (all JSON):
GET / → the viewer HTML
GET /api/trajectory?n=200&user_id=alice → audit-log events
GET /api/stats → memory stats
GET /api/facts?user_id=alice&limit=200 → fact dump
The HTML fetches /api/trajectory on load and re-renders the
timeline. Each event has a step number, timestamp, kind, user_id,
and a click-to-expand payload. Keyboard: ↑/↓ to navigate events,
J/K same, / to filter.
"""
from __future__ import annotations
import argparse
import http.server
import json
import os
import socketserver
import sys
import urllib.parse
from typing import Any
HTML = r"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>cortexm trajectory viewer</title>
<style>
:root{
--bg:#0d1117; --fg:#c9d1d9; --muted:#8b949e;
--accent:#58a6ff; --warn:#f0883e; --ok:#3fb950; --err:#f85149;
--card:#161b22; --border:#30363d;
}
*{box-sizing:border-box} body{margin:0;background:var(--bg);
color:var(--fg);font:14px/1.45 -apple-system,BlinkMacSystemFont,
'Segoe UI',Helvetica,Arial,sans-serif;display:flex;flex-direction:
column;height:100vh}
header{background:var(--card);border-bottom:1px solid var(--border);
padding:10px 16px;display:flex;align-items:center;gap:12px}
header h1{font-size:14px;margin:0;font-weight:600}
header .muted{color:var(--muted);font-size:12px}
header input{flex:1;background:var(--bg);color:var(--fg);border:
1px solid var(--border);border-radius:6px;padding:6px 10px;font:inherit}
header button{background:var(--accent);color:var(--bg);border:0;border-radius:
6px;padding:6px 12px;font:inherit;font-weight:600;cursor:pointer}
header button:hover{filter:brightness(1.1)}
.layout{flex:1;display:grid;grid-template-columns:1fr 1fr;overflow:hidden}
.timeline{overflow:auto;border-right:1px solid var(--border)}
.detail{overflow:auto;padding:16px;background:var(--bg)}
.ev{padding:10px 16px;border-bottom:1px solid var(--border);cursor:pointer;
display:grid;grid-template-columns:50px 60px 1fr auto;gap:8px;align-items:center}
.ev:hover{background:var(--card)}
.ev.active{background:var(--card);border-left:3px solid var(--accent);
padding-left:13px}
.ev .step{color:var(--muted);font-size:11px;font-variant-numeric:
tabular-nums;text-align:right}
.ev .kind{font-size:10px;color:var(--accent);text-transform:uppercase;
letter-spacing:.05em;font-weight:600}
.ev .summary{font-size:12px;overflow:hidden;text-overflow:ellipsis;
white-space:nowrap}
.ev .ts{color:var(--muted);font-size:11px;font-variant-numeric:
tabular-nums}
pre{background:var(--card);padding:12px;border-radius:6px;border:1px solid
var(--border);overflow:auto;font:12px/1.4 'SFMono-Regular',Consolas,
'Liberation Mono',Menlo,monospace;color:var(--fg)}
h2{font-size:13px;margin:0 0 8px 0;color:var(--muted);text-transform:
uppercase;letter-spacing:.05em}
.stats{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:16px}
.stat{background:var(--card);border:1px solid var(--border);border-radius:6px;
padding:8px 12px}
.stat .n{font-size:18px;font-weight:600}
.stat .lbl{color:var(--muted);font-size:11px;text-transform:uppercase}
.empty{color:var(--muted);padding:40px;text-align:center;font-size:13px}
.scroll-btns{position:fixed;bottom:16px;right:16px;display:flex;flex-direction:
column;gap:6px}
.scroll-btns button{background:var(--card);color:var(--fg);border:1px solid
var(--border);border-radius:6px;width:36px;height:36px;cursor:pointer;
font:inherit;font-size:16px}
.scroll-btns button:hover{border-color:var(--accent)}
</style>
</head>
<body>
<header>
<h1>cortexm trajectory</h1>
<span class="muted" id="hdr-stats">…</span>
<input id="filter" placeholder="filter events (kind, user_id, payload)…">
<button onclick="reload()">reload</button>
</header>
<div class="layout">
<div class="timeline" id="timeline"></div>
<div class="detail" id="detail">
<div class="empty">select an event on the left to inspect its payload</div>
</div>
</div>
<div class="scroll-btns">
<button onclick="nav(-1)" title="up">↑</button>
<button onclick="nav(+1)" title="down">↓</button>
</div>
<script>
let events=[]; let activeIdx=-1;
async function fetch_(url){const r=await fetch(url);return r.json();}
async function reload(){
const f=document.getElementById('filter').value;
let url='/api/trajectory?n=500';
if(f) url+='&q='+encodeURIComponent(f);
const r=await fetch_(url);
events=r.events||[];
document.getElementById('hdr-stats').textContent=
r.n_events+' events · '+r.user_id;
render();
}
function render(){
const tl=document.getElementById('timeline');
tl.innerHTML='';
if(!events.length){
tl.innerHTML='<div class="empty">no events — load some memory first</div>';
return;
}
events.forEach((e,i)=>{
const d=document.createElement('div');
d.className='ev'+(i===activeIdx?' active':'');
d.onclick=()=>select(i);
d.innerHTML=`
<span class="step">${e.step}</span>
<span class="kind">${e.kind}</span>
<span class="summary">${escapeHtml(e.payload_summary||e.kind)}</span>
<span class="ts">${e.ts?e.ts.slice(11,19):''}</span>`;
tl.appendChild(d);
});
}
function select(i){
activeIdx=i;
render();
const e=events[i];
const detail=document.getElementById('detail');
detail.innerHTML=`
<h2>event ${e.step}</h2>
<div class="stats">
<div class="stat"><div class="n">${e.kind}</div><div class="lbl">kind</div></div>
<div class="stat"><div class="n">${e.user_id||'—'}</div><div class="lbl">user_id</div></div>
<div class="stat"><div class="n">${e.ts||'—'}</div><div class="lbl">ts</div></div>
<div class="stat"><div class="n">${e.id?e.id.slice(0,8):'—'}</div><div class="lbl">event_id</div></div>
</div>
<h2>payload</h2>
<pre>${escapeHtml(JSON.stringify(e.payload||{},null,2))}</pre>
<h2>raw</h2>
<pre>${escapeHtml(JSON.stringify(e,null,2))}</pre>`;
}
function nav(d){if(!events.length)return;activeIdx=Math.max(0,
Math.min(events.length-1,activeIdx+d));render();
document.querySelectorAll('.ev')[activeIdx]?.scrollIntoView({block:'center'});
select(activeIdx);}
function escapeHtml(s){return String(s).replace(/[&<>"']/g,c=>({ '&':'&','<':'<','>':'>','"':'"',"'":'''})[c]);}
document.getElementById('filter').addEventListener('input',reload);
document.addEventListener('keydown',e=>{
if(e.target.tagName==='INPUT')return;
if(e.key==='ArrowUp'||e.key==='k')nav(-1);
if(e.key==='ArrowDown'||e.key==='j')nav(+1);
if(e.key==='/')e.preventDefault(),document.getElementById('filter').focus();
});
reload();
</script>
</body>
</html>"""
class ViewerHandler(http.server.BaseHTTPRequestHandler):
def __init__(self, mem, *a, **kw):
self.mem = mem
super().__init__(*a, **kw)
def log_message(self, *a, **kw):
pass # silence default stderr logging
def _json(self, obj, status=200):
body = json.dumps(obj, indent=2, default=str).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _html(self, body, status=200):
b = body.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(b)))
self.end_headers()
self.wfile.write(b)
def do_GET(self):
u = urllib.parse.urlparse(self.path)
q = urllib.parse.parse_qs(u.query)
if u.path == "/":
return self._html(HTML)
if u.path == "/api/trajectory":
n = int((q.get("n") or ["500"])[0])
f = (q.get("q") or [""])[0].lower()
out = self.mem.trajectory(n=n)
if f:
kept = [e for e in out["events"] if f in (
str(e.get("kind", "")).lower() + " " +
str(e.get("user_id", "")).lower() + " " +
str(e.get("payload_summary", "")).lower())]
out = {"user_id": out["user_id"], "n_events": len(kept),
"events": kept}
return self._json(out)
if u.path == "/api/stats":
return self._json(self.mem.stats())
if u.path == "/api/facts":
user_id = (q.get("user_id") or ["default"])[0]
limit = int((q.get("limit") or ["200"])[0])
return self._json(self.mem.get_all(user_id=user_id,
limit=limit))
return self._json({"error": "not found"}, 404)
class ThreadedHTTPServer(socketserver.ThreadingMixIn,
http.server.HTTPServer):
daemon_threads = True
allow_reuse_address = True
def main(argv=None) -> int:
ap = argparse.ArgumentParser(
prog="cortexm trajectory-view",
description="web viewer for the audit-log timeline")
ap.add_argument("--db", default=":memory:")
ap.add_argument("--host", default="127.0.0.1",
help="bind host (default 127.0.0.1 — set 0.0.0.0 for LAN)")
ap.add_argument("--port", type=int, default=8901)
args = ap.parse_args(argv)
from cortexm.api.memory import Memory
from cortexm.config import Config
cfg = Config.from_env()
cfg.db_path = args.db
mem = Memory(cfg)
def handler_factory(*a, **kw):
return ViewerHandler(mem, *a, **kw)
httpd = ThreadedHTTPServer((args.host, args.port), handler_factory)
url = f"http://{args.host}:{args.port}/"
print(f"cortexm trajectory viewer → {url}", flush=True)
print(" Ctrl-C to stop.", flush=True)
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\nstop.", flush=True)
finally:
mem.close()
httpd.shutdown()
return 0
if __name__ == "__main__":
sys.exit(main())