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:
@@ -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:from–to` 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.
|
||||
@@ -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()
|
||||
@@ -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 — {{TITLE}}</h1>
|
||||
<span id="conn" class="dead">connecting…</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 — 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…</button>
|
||||
</div>
|
||||
|
||||
<p id="status"></p>
|
||||
|
||||
<form id="ask">
|
||||
<input name="q" placeholder="Ask anything about this page…" 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>
|
||||
Reference in New Issue
Block a user