Add explore skill: interactive pseudocode browser

Serves a localhost page in the pseudo-skill register with the
reference markers as clickable buttons: clicks land in
<workdir>/events.jsonl, a persistent Monitor tail wakes the session,
the session rewrites page.html, and the browser live-reloads over SSE.
Actions per marker: expand / show real code / explain / ask, plus a
page-level ask box; anchors become forge deep-links when the project
has a reachable remote; incidental findings are surfaced on the page
as anchored notes.

The port is configured in one place (the PORT constant atop
scripts/server.py; --port overrides per run). Mechanics proven live
against the aura repo before extraction into this skill.
This commit is contained in:
2026-07-20 12:11:57 +02:00
parent fe39658d67
commit c68b2303a0
3 changed files with 453 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
---
name: explore
description: Use when the user wants to explore a project interactively in the browser — invoked as `/explore <project>` or when the user asks for a clickable/served pseudocode page. Serves a localhost page showing the project as commented pseudocode in the pseudo-skill register, with the reference markers rendered as clickable buttons; every click (expand / show real code / explain / ask, plus a free-text box) lands as an event that wakes the session through a Monitor back-channel, the session rewrites the page, and the browser live-reloads via SSE. Anchors become forge deep-links when the project has a web-reachable remote; incidental findings (doc/code drift, stale comments) are surfaced on the page itself as anchored finding notes.
---
# explore — interactive pseudocode browser
## Overview
`/explore <project>` turns the `pseudo` skill's register into a served,
clickable page. The user reads pseudocode in the browser and zooms by
clicking instead of typing: each marker is a button, each click wakes
the session, the session rewrites the page, SSE reloads the browser.
The loop:
```
browser click ──POST /event──▶ server.py ──append──▶ <workdir>/events.jsonl
Monitor "tail -F -n 0 events.jsonl"
this session
browser ◀──SSE "reload"── server.py ◀──mtime── rewrites <workdir>/page.html
```
Latency expectations are conversational, not app-like: every click costs
one thinking cycle. Tell the user this once at start.
## Moving parts
- `scripts/server.py` — stdlib localhost server. **The port is
configured in exactly one place: the `PORT` constant at the top of
this file.** A per-run override exists (`--port N`) for a second
concurrent instance; the constant is the default every instance
shares.
- `templates/page.html` — the page scaffold (CSS, click menu, ask box,
SSE client). Placeholders: `{{TITLE}}` (project name, appears in
tab and header), `{{HINT}}` (one line: what the project is + current
altitude), `{{CONTENT}}` (the pseudocode block, see content rules).
- Workdir `~/.cache/explore/<project-slug>/` — holds the generated
`page.html` and `events.jsonl`. Scratch, never committed, safe to
delete after the session.
## Start procedure
1. `mkdir -p ~/.cache/explore/<project-slug>`
2. Generate `<workdir>/page.html` from `templates/page.html`, filling
the three placeholders. `{{CONTENT}}` starts at the project's main
entry point (pseudo rule 9) unless the user pointed elsewhere.
3. Start the server in the background:
`python3 <skill-dir>/scripts/server.py <workdir>` (add `--port N`
only for a second concurrent instance).
4. Arm the back-channel as a persistent Monitor:
`tail -F -n 0 <workdir>/events.jsonl` (`-n 0` is load-bearing:
without it, stale events replay at arm time).
5. Give the user the URL the server printed.
## Content rules
The `pseudo` skill's Iron Law governs everything inside `{{CONTENT}}`:
anchor line first, one altitude per view, markers as pointing handles
explained inline (never a legend), data shapes beside control flow,
reduce/omit the rest. Read that skill's rules before generating the
first page. On top of it, the served medium adds:
- **Escape first, tag second.** `{{CONTENT}}` and every `.note` body
are substituted into HTML: escape `&`, `<`, `>` in all source and
pseudocode text before insertion (Rust is full of `Vec<T>`, `&mut`,
`a < b`), and only then wrap tokens in highlight spans. A stray `<`
swallows the rest of the page, including the SSE script.
- **Markers are buttons.** `[N]` renders as
`<button class="marker" data-marker="N">[N]</button>`. Expansions
introduce sub-markers (`4a`, `4b`) — the zoom is recursive.
- **Anchors are links.** When the project has a web-reachable forge
remote (Gitea/GitHub), every file anchor becomes an
`<a class="src" href="…#L10-L20" target="_blank" rel="noopener">`
deep-link. Verify the URL pattern once per project (curl the file
URL, expect 200) before emitting the first link; skip links when
there is no reachable remote.
- **Real code** (`code` action): a `.note.code` block at the marker —
caption line = linked `file:fromto` anchor, then the code verbatim
with light span highlighting (`.c` comments, `.k` keywords, `.s`
strings).
- **Explanations** (`explain` action): a `.note` block anchored at the
marker. Page-level `ask` answers go in a `<section id="qa">` between
the pseudocode and the menu div (question dimmed in `.q`, answer in
`.note`).
- **Findings.** Anything noticed in passing — doc/code drift, a stale
comment, a suspicious contract — is surfaced on the page itself: an
amber `.note.finding` (⚠) anchored at the spot with deep-links to
the evidence, and a wavy `.warn` span (with `title` tooltip) on the
stale phrase where it is displayed. A one-line chat mention suffices
beside it; filing a tracker issue stays a separate ask.
- **Language.** Scaffold and pseudocode are English (repo register).
Answers to `ask` events are written in the user's language.
## Handling events
Each Monitor line is one click: `{marker, action, text?, ts}`
(`marker: null` = page-level; `text` only on `ask`). Respond by
rewriting `<workdir>/page.html` — the SSE watcher reloads the browser
automatically, so the rewrite IS the reply. Every rewrite MUST
preserve the full template scaffold: the SSE `<script>` (EventSource +
reload listener), the `#menu` div, and the `#ask` form — after each
reload that script is what re-binds every marker button. Edit only the
pseudocode, the anchored notes, and the `#qa` section; dropping the
scaffold silently kills the loop. Read the real source
before expanding or explaining; keep each response at the altitude the
click asked for. Batch related edits into as few writes as possible
(each write triggers a visible reload).
## Stop
`TaskStop` the server task and the Monitor. The workdir is scratch and
may be deleted. To switch projects: stop the server, start it again on
the new project's workdir, and re-arm the Monitor on the NEW workdir's
`events.jsonl` — server and monitor are both bound to one workdir at
startup, so neither survives a workdir switch.
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""explore — localhost server behind the /explore skill.
Serves <workdir>/page.html, records marker-click events into
<workdir>/events.jsonl (one JSON object per line), and pushes an SSE
"reload" event whenever page.html changes on disk. The Claude session
arms a Monitor (`tail -F -n 0 <workdir>/events.jsonl`) as the
back-channel; rewriting page.html is the reply channel.
Usage: server.py <workdir> [--port N]
Stdlib only. Binds to localhost.
"""
import argparse
import json
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
# The single place the default port is configured (--port overrides per run).
PORT = 8000
# SSE tuning: how often to poll the page mtime, and how many idle polls
# between keep-alive comments (0.5s * 30 = one ping every 15s).
POLL_S = 0.5
PINGS_EVERY = 30
# Set in main() from the workdir argument.
PAGE = None
EVENTS = None
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def do_GET(self):
if self.path in ("/", "/index.html"):
self._serve_page()
elif self.path == "/stream":
self._serve_stream()
else:
self.send_error(404)
def do_POST(self):
if self.path == "/event":
self._record_event()
else:
self.send_error(404)
def _serve_page(self):
# Read fresh from disk every time — Claude rewrites this file.
try:
body = PAGE.read_bytes()
except OSError:
self.send_error(503, "page.html missing")
return
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _serve_stream(self):
# Long-lived SSE connection: emit "reload" whenever page.html
# changes on disk, keep-alive comments in between.
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-store")
self.end_headers()
try:
last = PAGE.stat().st_mtime_ns
except OSError:
last = 0
idle = 0
try:
while True:
time.sleep(POLL_S)
try:
cur = PAGE.stat().st_mtime_ns
except OSError:
continue # page briefly missing mid-rewrite
if cur != last:
last = cur
self.wfile.write(b"event: reload\ndata: 1\n\n")
self.wfile.flush()
idle = 0
else:
idle += 1
if idle >= PINGS_EVERY:
self.wfile.write(b": ping\n\n")
self.wfile.flush()
idle = 0
except (BrokenPipeError, ConnectionResetError):
return # client went away — normal
def _record_event(self):
try:
length = int(self.headers.get("Content-Length", 0))
except ValueError:
self.send_error(411)
return
raw = self.rfile.read(length)
try:
event = json.loads(raw)
if not isinstance(event, dict):
raise ValueError("expected a JSON object")
except (ValueError, UnicodeDecodeError):
self.send_error(400, "invalid JSON")
return
event["ts"] = time.strftime("%Y-%m-%dT%H:%M:%S")
# Single-line appends in "a" mode are atomic enough here.
with EVENTS.open("a", encoding="utf-8") as f:
f.write(json.dumps(event, ensure_ascii=False) + "\n")
self.send_response(204)
self.end_headers()
def log_message(self, *args):
pass # keep stdout clean — it is the background-task log
def main():
global PAGE, EVENTS
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("workdir", type=Path, help="directory holding page.html and events.jsonl")
ap.add_argument("--port", type=int, default=PORT, help=f"listen port (default {PORT})")
a = ap.parse_args()
workdir = a.workdir.resolve()
workdir.mkdir(parents=True, exist_ok=True)
PAGE = workdir / "page.html"
EVENTS = workdir / "events.jsonl"
EVENTS.touch()
srv = ThreadingHTTPServer(("127.0.0.1", a.port), Handler)
print(f"explore serving {workdir} on http://127.0.0.1:{a.port}", flush=True)
srv.serve_forever()
if __name__ == "__main__":
main()
+195
View File
@@ -0,0 +1,195 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>explore — {{TITLE}}</title>
<style>
:root {
--bg: #10141a; --panel: #171c24; --border: #232b36;
--fg: #d6dde6; --dim: #68737f; --comment: #7d8894;
--kw: #c792ea; --str: #a5d6a7;
--marker-bg: #1f3a52; --marker-border: #35618a; --marker-fg: #8fd6ff;
--ok: #63d68f; --busy: #e8c268; --err: #e87979;
}
* { box-sizing: border-box; }
html, body { margin: 0; }
body {
background: var(--bg); color: var(--fg);
font: 15px/1.5 "JetBrains Mono", "Fira Code", ui-monospace, monospace;
max-width: 980px; margin-inline: auto; padding: 2rem 1.5rem 5rem;
}
header { display: flex; align-items: baseline; gap: .9rem; margin-bottom: .4rem; }
h1 { font-size: 1.05rem; margin: 0; font-weight: 600; }
#conn { font-size: .78rem; color: var(--dim); }
#conn.live::before { content: "\25CF "; color: var(--ok); }
#conn.dead::before { content: "\25CF "; color: var(--err); }
p.hint { color: var(--dim); font-size: .82rem; margin: 0 0 1.4rem; }
pre.pseudo {
background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
padding: 1.15rem 1.35rem; overflow-x: auto; line-height: 1.72; margin: 0;
}
.c { color: var(--comment); }
.note {
display: block; margin: .4rem 0 .55rem; padding: .5rem .85rem;
border-left: 3px solid var(--marker-border); border-radius: 0 6px 6px 0;
background: #1a2330; color: #aebccb; font-size: .88em; line-height: 1.55;
white-space: pre-wrap;
}
.note.code { white-space: pre; overflow-x: auto; font-size: .85em; line-height: 1.5; color: var(--fg); }
.note.finding { border-left-color: var(--busy); background: #241f14; color: #d9c9a0; }
.warn { text-decoration: underline wavy var(--busy); text-decoration-skip-ink: none; cursor: help; }
.k { color: var(--kw); }
.s { color: var(--str); }
a.src { color: inherit; text-decoration: underline dotted; text-underline-offset: 3px; }
a.src:hover { color: var(--marker-fg); }
button.marker {
font: inherit; font-size: .92em; line-height: 1.25;
border: 1px solid var(--marker-border); border-radius: 5px;
background: var(--marker-bg); color: var(--marker-fg);
padding: 0 .4em; cursor: pointer; vertical-align: baseline;
}
button.marker:hover { background: #2a4f70; }
/* click menu */
#menu {
position: fixed; z-index: 10; display: none; flex-direction: column;
background: var(--panel); border: 1px solid var(--marker-border);
border-radius: 8px; padding: .3rem; box-shadow: 0 6px 24px #0009;
}
#menu.open { display: flex; }
#menu button {
font: inherit; font-size: .85rem; text-align: left;
background: none; border: 0; color: var(--fg);
padding: .35rem .7rem; border-radius: 5px; cursor: pointer;
}
#menu button:hover { background: var(--marker-bg); color: var(--marker-fg); }
/* page-level Q&A */
#qa { margin-top: 1.3rem; }
#qa .q { color: var(--dim); font-size: .84rem; margin: 0 0 .35rem; }
/* status + ask box */
#status { min-height: 1.4rem; margin: 1rem 0 0; font-size: .84rem; color: var(--dim); }
#status.busy { color: var(--busy); }
#status.err { color: var(--err); }
form#ask { display: flex; gap: .6rem; margin-top: 1.6rem; }
form#ask input {
flex: 1; font: inherit; font-size: .88rem;
background: var(--panel); border: 1px solid var(--border); border-radius: 7px;
color: var(--fg); padding: .5rem .8rem; outline: none;
}
form#ask input:focus { border-color: var(--marker-border); }
form#ask button {
font: inherit; font-size: .88rem; cursor: pointer;
background: var(--marker-bg); color: var(--marker-fg);
border: 1px solid var(--marker-border); border-radius: 7px; padding: .5rem 1rem;
}
form#ask button:hover { background: #2a4f70; }
</style>
</head>
<body>
<header>
<h1>explore &mdash; {{TITLE}}</h1>
<span id="conn" class="dead">connecting&hellip;</span>
</header>
<p class="hint">{{HINT}} Click a <button class="marker" type="button" tabindex="-1" style="pointer-events:none">[n]</button> marker to zoom.</p>
<pre class="pseudo">{{CONTENT}}</pre>
<div id="menu" role="menu">
<button data-action="expand">expand &mdash; one level deeper</button>
<button data-action="code">show real code</button>
<button data-action="explain">explain</button>
<button data-action="ask">ask about this&hellip;</button>
</div>
<p id="status"></p>
<form id="ask">
<input name="q" placeholder="Ask anything about this page&hellip;" autocomplete="off">
<button type="submit">send</button>
</form>
<script>
"use strict";
const menu = document.getElementById("menu");
const statusEl = document.getElementById("status");
let currentMarker = null;
function setStatus(cls, text) {
statusEl.className = cls;
statusEl.textContent = text;
}
async function send(ev) {
const label = ev.marker ? `${ev.action} [${ev.marker}]` : ev.action;
setStatus("busy", `sent: ${label} — waiting for Claude to rewrite the page…`);
try {
const r = await fetch("/event", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(ev),
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
} catch {
setStatus("err", "send failed — is the explore server running?");
}
}
// marker click -> action menu next to the marker
document.querySelectorAll("button.marker[data-marker]").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
currentMarker = btn.dataset.marker;
const rect = btn.getBoundingClientRect();
menu.classList.add("open");
// measure after showing, then clamp into the viewport
const mw = menu.offsetWidth, mh = menu.offsetHeight;
let x = rect.left, y = rect.bottom + 6;
if (x + mw > innerWidth - 8) x = innerWidth - mw - 8;
if (y + mh > innerHeight - 8) y = rect.top - mh - 6;
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
});
});
menu.addEventListener("click", (e) => {
const action = e.target.dataset && e.target.dataset.action;
if (!action) return;
menu.classList.remove("open");
if (action === "ask") {
const text = prompt(`Question about [${currentMarker}]:`);
if (text === null || text.trim() === "") return;
send({ marker: currentMarker, action, text: text.trim() });
} else {
send({ marker: currentMarker, action });
}
});
document.addEventListener("click", () => menu.classList.remove("open"));
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") menu.classList.remove("open");
});
// free-text question about the whole page
document.getElementById("ask").addEventListener("submit", (e) => {
e.preventDefault();
const input = e.target.elements.q;
const text = input.value.trim();
if (!text) return;
send({ marker: null, action: "ask", text });
input.value = "";
});
// live-reload via SSE
const conn = document.getElementById("conn");
const es = new EventSource("/stream");
es.addEventListener("reload", () => location.reload());
es.onopen = () => { conn.className = "live"; conn.textContent = "live"; };
es.onerror = () => { conn.className = "dead"; conn.textContent = "reconnecting…"; };
</script>
</body>
</html>