From 687755f85966514f93a350ce7587964260f0d4ca Mon Sep 17 00:00:00 2001 From: benjsmith Date: Fri, 18 Sep 2026 20:29:37 +0000 Subject: [PATCH 01/19] feat(ce): Phase 2a proxy prefix + hosted hook + migration intake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add CE_PUBLIC_BASE (/embed/ce) stripping and HTML bootstrap (ceApi), X-CE-Host/?host=switchbay|okbay stub via /api/hosted, ADR-001, and Switchbay→CE filebrowser/animation/split intake map. Keep 127.0.0.1 loopback; bare viewer.sh port 8090 unchanged. --- .../docs/ADR-001-proxy-embed-and-hosted.md | 41 +++++ .../curiosity-engine/docs/MIGRATION-INTAKE.md | 65 ++++++++ skills/curiosity-engine/docs/architecture.md | 15 ++ skills/curiosity-engine/docs/viewers.md | 10 ++ .../curiosity-engine/scripts/public_base.py | 147 ++++++++++++++++++ .../curiosity-engine/scripts/viewer_server.py | 73 ++++++++- .../template/wiki-view/static/edit.js | 15 +- .../template/wiki-view/static/vault.js | 10 +- tests/test_public_base.py | 142 +++++++++++++++++ 9 files changed, 512 insertions(+), 6 deletions(-) create mode 100644 skills/curiosity-engine/docs/ADR-001-proxy-embed-and-hosted.md create mode 100644 skills/curiosity-engine/docs/MIGRATION-INTAKE.md create mode 100644 skills/curiosity-engine/scripts/public_base.py create mode 100644 tests/test_public_base.py diff --git a/skills/curiosity-engine/docs/ADR-001-proxy-embed-and-hosted.md b/skills/curiosity-engine/docs/ADR-001-proxy-embed-and-hosted.md new file mode 100644 index 0000000..836d09a --- /dev/null +++ b/skills/curiosity-engine/docs/ADR-001-proxy-embed-and-hosted.md @@ -0,0 +1,41 @@ +# ADR-001 — Proxy embed prefix + hosted-shell contract (Phase 2a) + +**Status:** Accepted (2026-09-18, Europe/Zurich) +**Charter:** umbrella `/workspace/skill-shell-rationalization/docs/CHARTER.md` +**Related:** locked decisions #1 (no iframes), #2 (migrate Switchbay features into CE before thinning), #6 (hosted mode — okstratr settings; CE mirrors the hook). + +## Context + +Switchbay and okbay must **same-origin reverse-proxy** CE at `127.0.0.1:8766` under `/embed/ce/` (not iframes). Absolute frontend fetches (`/api/page`, `/api/vault/…`) break under a path prefix unless the server and static bundle share a public base. Shells also need a `host=switchbay|okbay` signal so CE can later defer workspace/harness chrome to the shell. + +## Decision + +1. **Configurable public base** via env `CE_PUBLIC_BASE` (normalized, no trailing slash; typical value `/embed/ce`). `viewer_server.py` strips that prefix from request paths before routing/static, and injects: + + - `window.CE_PUBLIC_BASE` + - `window.CE_HOSTED` (`""` | `"switchbay"` | `"okbay"`) + - `window.ceApi(path)` → `CE_PUBLIC_BASE + path` + + Wiki-view `edit.js` / `vault.js` call `ceApi` (with a local fallback) so API URLs stay under the embed prefix when proxied. + +2. **Hosted-shell detection** (header wins over query): + + - Header: `X-CE-Host: switchbay|okbay` + - Query: `?host=switchbay|okbay` + - Probe: `GET /api/hosted` → stub policy JSON (`hosted_settings_policy`) + +3. **Loopback bind unchanged:** always `127.0.0.1`. Bare `viewer.sh` default port remains **8090**. Embed/host daemons commonly use **8766** (charter). Setting `CE_PUBLIC_BASE` does not change bind host or imply a port change. + +4. **Settings split stub:** when hosted, `shell_owns_workspace_settings: true` but `ce_viewer_knobs_enabled: true` until a parity-gated follow-up. Full HTML settings ownership split is large and deferred (2b+); this ADR only locks the wire contract. + +## Consequences + +- Proxies may forward either stripped paths (`/api/…`) or prefixed paths (`/embed/ce/api/…`); CE handles the latter when `CE_PUBLIC_BASE` is set. JS always needs the injected base for browser same-origin fetches. +- Migration of Switchbay filebrowser / graph animation / split into CE is tracked in [`MIGRATION-INTAKE.md`](MIGRATION-INTAKE.md); do not delete Switchbay surfaces until the umbrella parity checklist is green. +- okbay’s current `:8766` Atlas host and Switchbay’s tab chrome remain dual-stack until Phase 4/5 thinning. + +## Alternatives considered + +- **`` only** — fragile with absolute `/api` and mixed relative assets; rejected. +- **Iframe embed** — forbidden by charter #1. +- **Full settings disable in 2a** — too large a UX split; stub + document instead. diff --git a/skills/curiosity-engine/docs/MIGRATION-INTAKE.md b/skills/curiosity-engine/docs/MIGRATION-INTAKE.md new file mode 100644 index 0000000..7bad733 --- /dev/null +++ b/skills/curiosity-engine/docs/MIGRATION-INTAKE.md @@ -0,0 +1,65 @@ +# Migration intake map — Switchbay → CE (Phase 2a) + +Charter north star: **CE** owns graph, atlas, wiki, search, source viewer, **filebrowser**, **graph animation + split**. Switchbay keeps PWA shell (workspaces, watcher, tabs chrome, rail UI) and reverse-proxies CE under `/embed/ce/`. + +**Do not delete** Switchbay implementations until `/workspace/skill-shell-rationalization/docs/PARITY-CHECKLIST.md` (umbrella) is checked and a feature flag gates the cutover. + +## Target homes in CE + +| Capability | CE destination | Notes | +|------------|----------------|-------| +| Atlas / graph engine | `packages/knowledge-atlas` | Already the shared engine; Switchbay Graph tab adapts via examples/switchbay | +| Classic wiki viewer + APIs | `skills/curiosity-engine/template/wiki-view` + `scripts/viewer_server.py` | Phase 2a: `CE_PUBLIC_BASE` / hosted hook | +| Filebrowser | New CE surface (likely wiki-view sidebar or `packages/` module) | Intake from Switchbay sidebar | +| Graph opening animation | knowledge-atlas and/or wiki-view | Intake from curation replay | +| Workspace split | CE API + atlas multi-select; shell may keep workspace registry UX | Intake from Switchbay split API | + +## Switchbay source map (intake) + +Paths relative to the Switchbay repo root (branch `feat/skill-shell-rationalization`). + +### Filebrowser + +| Artifact | Role | +|----------|------| +| `frontend/src/sidebar/FileBrowser.tsx` (~1.3k LOC) | Tree, sort, search/regex, context menu, pack file-routes | +| `frontend/src/sidebar/Sidebar.tsx` | Host chrome wrapping browser + selection | +| `frontend/src/sidebar/SourceBrowser.tsx` | Source-oriented browse | +| `frontend/src/sidebar/WikiPane.tsx` | Wiki pane adjacent to browser | +| `frontend/src/sidebar/ingestDrop.ts` | Drop-to-ingest bridge | + +**CE intake notes:** Prefer CE vault/wiki path semantics (`vault/`, `wiki/`) over Switchbay workspace-relative pack routes. Right-click / highlight / search parity is on the umbrella checklist. Pack-specific `file_routes` may stay shell-side or become CE extension hooks later. + +### Graph animation (curation replay) + +| Artifact | Role | +|----------|------| +| `frontend/src/widgets/graph/CurationReplay.tsx` | Graph-tab opening animation UI | +| `frontend/src/widgets/graph/curationReplayAnim.ts` | Shared rAF animation (~389 LOC) | +| `src/switchbay/curation_history.py` | History data behind replay | + +**CE intake notes:** Animation should consume CE graph/atlas data (or OKF/CE `data.json`), not Switchbay-only tab state. Ship behind a viewer flag; keep Switchbay button until parity. + +### Split (subgraph → new workspace) + +| Artifact | Role | +|----------|------| +| `frontend/src/widgets/graph/GraphTab.tsx` | Split mode UI, rubber-band / proposal listener (`sy:split-proposal`) | +| `frontend/src/widgets/graph/atlas.ts` | Atlas stubs `splitEnter` / `splitExit` | +| `src/switchbay/splitting.py` | `split_workspace` implementation | +| `src/switchbay/daemon.py` | `POST /api/workspaces/split`, status, `POST /api/split/proposal` | + +**CE intake notes:** Engine multi-select already exists in knowledge-atlas (`select` / `selection-changed`). Move **page move/copy + wiki partition** logic into CE; Switchbay may retain workspace registry / tab open after split. Feature-flag dual-stack until checklist green. + +## Embed contract (for Switchbay Phase 4a) + +- Upstream: `http://127.0.0.1:8766` (loopback only) +- Public prefix: `/embed/ce` → set `CE_PUBLIC_BASE=/embed/ce` on the CE process +- Forward `X-CE-Host: switchbay` (okbay: `okbay`) or `?host=` +- No iframes — in-app panels load first-party proxied routes + +## Sequencing + +1. **2a (this branch):** proxy prefix + hosted stub + this map + ADR +2. **2b+:** land filebrowser / animation / split behind flags in CE +3. **4b:** Switchbay tabs thin to `/embed/ce/` once parity checklist passes diff --git a/skills/curiosity-engine/docs/architecture.md b/skills/curiosity-engine/docs/architecture.md index c2d961a..79325bc 100644 --- a/skills/curiosity-engine/docs/architecture.md +++ b/skills/curiosity-engine/docs/architecture.md @@ -95,6 +95,21 @@ Known failure modes, in rough order of when you'll hit them: - **~500-page wiki ceiling.** Beyond that, plan latency grows. The incremental score cache helps; the tiered-vault design (bounded wiki + unbounded indexed vault) unlocks more; cluster-scoped CURATE (see below) keeps individual waves coherent past the threshold. - **Single user.** No protocol for multiple humans editing the same wiki live. Two asynchronous export paths cover different audiences: **OKF** (`okf_export.py build wiki --output-dir `) projects the wiki to a vendor-neutral [Open Knowledge Format](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md) bundle for cross-tool / cross-org exchange (read-only; CE structure rides in `x_ce_*` keys — see [`okf-interop.md`](okf-interop.md)); **curiosity-merge** subgraph-export/merge shares whole or sub-wikis with other CE workspaces. Neither is concurrent multi-user editing. +## Shell embed + hosted mode (Phase 2a) + +Umbrella charter: Switchbay/okbay **same-origin reverse-proxy** CE (typically +`127.0.0.1:8766`) under `/embed/ce/` — **no iframes**. CE readiness: + +- Env `CE_PUBLIC_BASE` (e.g. `/embed/ce`) — see `scripts/public_base.py` and + `viewer_server.py` +- Hosted hook: `X-CE-Host` / `?host=switchbay|okbay` and `GET /api/hosted` +- ADR: [`ADR-001-proxy-embed-and-hosted.md`](ADR-001-proxy-embed-and-hosted.md) +- Feature migration intake (filebrowser, graph animation, split): + [`MIGRATION-INTAKE.md`](MIGRATION-INTAKE.md) + +Bare `viewer.sh` loopback default remains port **8090**; bind host is always +`127.0.0.1`. + ## Cluster scoping at scale Past `cluster_scope_threshold` non-source pages (default 500), `epoch_summary.py` emits a `wave_scope` field — the worst-scoring page plus every page within two wikilink hops, in either direction. Phase 1 of the CURATE loop honours the scope for **repair mode**: editorial and frontier target selection are restricted to pages inside the scope. Create and wire modes stay global (new pages aren't in the graph yet; inbound-link starvation legitimately crosses clusters). diff --git a/skills/curiosity-engine/docs/viewers.md b/skills/curiosity-engine/docs/viewers.md index b1c59b6..25b5a85 100644 --- a/skills/curiosity-engine/docs/viewers.md +++ b/skills/curiosity-engine/docs/viewers.md @@ -59,6 +59,16 @@ Each workspace's bundle goes into rebuilds it after every inline edit, so refresh and the change is visible. + +## Embed / reverse-proxy (Switchbay, okbay) + +For same-origin shell embeds, run the viewer server with +`CE_PUBLIC_BASE=/embed/ce` (and typically port **8766**). The server strips +that prefix and injects `window.CE_PUBLIC_BASE` / `ceApi()` into HTML. +Optional hosted signal: `X-CE-Host: switchbay|okbay` or `?host=`. See +[`ADR-001-proxy-embed-and-hosted.md`](ADR-001-proxy-embed-and-hosted.md). +Bare local `viewer.sh` is unchanged (default port 8090, `127.0.0.1`). + ## Obsidian (alternative — same underlying markdown) `wiki/` is plain markdown with `[[wikilinks]]`. Open Obsidian → diff --git a/skills/curiosity-engine/scripts/public_base.py b/skills/curiosity-engine/scripts/public_base.py new file mode 100644 index 0000000..51954f4 --- /dev/null +++ b/skills/curiosity-engine/scripts/public_base.py @@ -0,0 +1,147 @@ +"""Public URL base path + hosted-shell detection (Phase 2a). + +Switchbay/okbay same-origin reverse-proxy CE under a configurable prefix +(default empty; typical embed: ``/embed/ce``). See umbrella CHARTER locked +decisions #1 (no iframes) and the CE hosted-mode contract in +``docs/ADR-001-proxy-embed-and-hosted.md``. + +Loopback serve stays ``127.0.0.1`` (bare viewer default port 8090; embed +hosts commonly use **8766**). Setting ``CE_PUBLIC_BASE`` does not change +the bind address. +""" + +from __future__ import annotations + +import os +from typing import Mapping +from urllib.parse import parse_qs + +HOSTED_SHELLS = frozenset({"switchbay", "okbay"}) +HOST_HEADER = "X-CE-Host" +# Recommended public base when proxied (no trailing slash). +DEFAULT_EMBED_BASE = "/embed/ce" + + +def public_base() -> str: + """Normalized public base path (no trailing slash), or \"\" when unset. + + Env: ``CE_PUBLIC_BASE`` — e.g. ``/embed/ce``. + """ + raw = (os.environ.get("CE_PUBLIC_BASE") or "").strip() + if not raw or raw == "/": + return "" + if not raw.startswith("/"): + raw = "/" + raw + return raw.rstrip("/") + + +def strip_public_base(path: str) -> str: + """Strip configured public base from an absolute request path.""" + base = public_base() + if not base: + return path or "/" + p = path or "/" + if p == base: + return "/" + if p.startswith(base + "/"): + rest = p[len(base) :] + return rest if rest else "/" + return p + + +def normalize_hosted_shell(raw: str | None) -> str | None: + """Return ``switchbay``|``okbay`` or None.""" + if not raw: + return None + h = str(raw).strip().lower() + if h in HOSTED_SHELLS: + return h + return None + + +def hosted_shell_from_request( + headers: Mapping[str, str] | None, + query: Mapping[str, list[str]] | None = None, +) -> str | None: + """Resolve hosted shell from ``X-CE-Host`` or ``?host=``. + + Header wins when both are present and valid. + """ + if headers: + for key, val in headers.items(): + if key.lower() == HOST_HEADER.lower(): + found = normalize_hosted_shell(val) + if found: + return found + break + if query: + vals = query.get("host") or [] + if vals: + return normalize_hosted_shell(vals[0]) + return None + + +def hosted_shell_from_query_string(qs: str) -> str | None: + if not qs: + return None + return hosted_shell_from_request(None, parse_qs(qs)) + + +def viewer_bootstrap_js(*, hosted: str | None = None, api_base: str | None = None) -> str: + """Inline script assigning window.CE_* for atlas/wiki/static under a proxy prefix.""" + base = public_base() if api_base is None else (api_base or "") + base = base.rstrip("/") + host = normalize_hosted_shell(hosted) or "" + return ( + "window.CE_PUBLIC_BASE=" + + _js_str(base) + + ";window.CE_HOSTED=" + + _js_str(host) + + ";window.ceApi=function(p){var b=window.CE_PUBLIC_BASE||\"\";" + "if(!p)p=\"/\";if(p.charAt(0)!==\"/\")p=\"/\"+p;return b+p;};" + ) + + +def _js_str(s: str) -> str: + return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def inject_viewer_bootstrap(html: bytes | str, *, hosted: str | None = None) -> bytes: + """Inject bootstrap script before ```` (or start of ````).""" + text = html.decode("utf-8") if isinstance(html, (bytes, bytearray)) else str(html) + snippet = "\n" + lower = text.lower() + idx = lower.find("") + if idx >= 0: + text = text[:idx] + snippet + text[idx:] + else: + bidx = lower.find("= 0: + gt = text.find(">", bidx) + if gt >= 0: + text = text[: gt + 1] + "\n" + snippet + text[gt + 1 :] + else: + text = snippet + text + else: + text = snippet + text + return text.encode("utf-8") + + +def hosted_settings_policy(hosted: str | None) -> dict: + """Stub contract for shell-hosted CE (Phase 2a). + + Full settings UI split lands later. Today we only advertise intent: + when ``host=switchbay|okbay``, shells own workspace/harness chrome; + CE may later hide duplicate settings surfaces. Physics / viewer knobs + remain available until a parity-gated follow-up. + """ + shell = normalize_hosted_shell(hosted) + return { + "hosted": shell, + "shell_owns_workspace_settings": shell is not None, + "ce_viewer_knobs_enabled": True, # stub: keep atlas/physics until 2b+ + "note": ( + "Hosted mode detected via X-CE-Host or ?host=. " + "Settings chrome split is deferred; see ADR-001." + ), + } diff --git a/skills/curiosity-engine/scripts/viewer_server.py b/skills/curiosity-engine/scripts/viewer_server.py index 0c3a9ad..adc9ba6 100644 --- a/skills/curiosity-engine/scripts/viewer_server.py +++ b/skills/curiosity-engine/scripts/viewer_server.py @@ -11,9 +11,19 @@ GET / static file (anything in the bundle) GET /api/page?path= raw markdown of a wiki page GET /api/vault/ vault/*.extracted.md basenames only + GET /api/hosted hosted-mode stub (host=switchbay|okbay) POST /api/page JSON {path, content} → overwrite file POST /api/upload-vault multipart form → save to vault/raw/ +Proxy / embed (Phase 2a) +──────────────────────── + Optional ``CE_PUBLIC_BASE`` (e.g. ``/embed/ce``) strips that prefix from + request paths and injects ``window.CE_PUBLIC_BASE`` / ``ceApi()`` into + HTML so absolute ``/api/*`` fetches stay same-origin under a reverse + proxy. Hosted shell: header ``X-CE-Host`` or ``?host=switchbay|okbay``. + Always binds ``127.0.0.1`` (loopback). Bare ``viewer.sh`` default port + remains 8090; embed hosts commonly use **8766**. + Writes are constrained: * /api/page only accepts paths that start with `notes/` or `todos/` and end in `.md`. The path is resolved + checked to ensure it @@ -49,6 +59,8 @@ from email.policy import default as default_email_policy from pathlib import Path +import public_base + SCRIPT_DIR = Path(__file__).resolve().parent BUNDLE_DIR: Path | None = None @@ -108,23 +120,74 @@ def log_message(self, fmt, *args): def __init__(self, *args, **kwargs): super().__init__(*args, directory=str(BUNDLE_DIR), **kwargs) + def _rewrite_path(self) -> urllib.parse.ParseResult: + """Strip CE_PUBLIC_BASE so routing + static map to the bundle root.""" + url = urllib.parse.urlparse(self.path) + stripped = public_base.strip_public_base(url.path) + if stripped != url.path: + self.path = urllib.parse.urlunparse( + (url.scheme, url.netloc, stripped, url.params, url.query, url.fragment) + ) + url = urllib.parse.urlparse(self.path) + return url + + def _hosted(self, url: urllib.parse.ParseResult) -> str | None: + return public_base.hosted_shell_from_request( + self.headers, urllib.parse.parse_qs(url.query) + ) + # ── routing ──────────────────────────────────────────────────── def do_GET(self): - url = urllib.parse.urlparse(self.path) + url = self._rewrite_path() if url.path == "/api/page": return self._handle_get_page(urllib.parse.parse_qs(url.query)) if url.path.startswith("/api/vault/"): return self._handle_get_vault(url.path[len("/api/vault/"):]) + if url.path == "/api/hosted": + return self._json(200, public_base.hosted_settings_policy(self._hosted(url))) + # Inject embed bootstrap into HTML so /api fetches honor CE_PUBLIC_BASE. + if self._looks_like_html(url.path): + return self._serve_html_with_bootstrap(url) return super().do_GET() def do_POST(self): - url = urllib.parse.urlparse(self.path) + url = self._rewrite_path() if url.path == "/api/page": return self._handle_post_page() if url.path == "/api/upload-vault": return self._handle_upload() return self._json(404, {"error": "not found"}) + def _looks_like_html(self, path: str) -> bool: + p = path.rstrip("/") or "/" + if p == "/" or p.endswith(".html") or p.endswith(".htm"): + return True + return False + + def _serve_html_with_bootstrap(self, url: urllib.parse.ParseResult) -> None: + # Resolve like SimpleHTTPRequestHandler: "" / "/" → index.html + rel = url.path.lstrip("/") + if not rel or rel.endswith("/"): + candidate = BUNDLE_DIR / rel / "index.html" + else: + candidate = BUNDLE_DIR / rel + if candidate.is_dir(): + candidate = candidate / "index.html" + try: + candidate = candidate.resolve() + candidate.relative_to(BUNDLE_DIR.resolve()) + except Exception: + return self.send_error(404, "File not found") + if not candidate.is_file(): + return self.send_error(404, "File not found") + raw = candidate.read_bytes() + body = public_base.inject_viewer_bootstrap(raw, hosted=self._hosted(url)) + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + # ── helpers ──────────────────────────────────────────────────── def _json(self, status: int, payload: dict) -> None: body = json.dumps(payload).encode("utf-8") @@ -289,9 +352,15 @@ def main() -> None: print(f"wiki dir missing: {WIKI_DIR}", file=sys.stderr) sys.exit(1) + base = public_base.public_base() sys.stderr.write( f"viewer-server: serving {BUNDLE_DIR} on http://127.0.0.1:{port}\n" ) + if base: + sys.stderr.write( + f"viewer-server: CE_PUBLIC_BASE={base} " + f"(public URLs under http://127.0.0.1:{port}{base}/)\n" + ) sys.stderr.write( f"viewer-server: edits go to {WIKI_DIR}, uploads to {VAULT_RAW_DIR}\n" ) diff --git a/skills/curiosity-engine/template/wiki-view/static/edit.js b/skills/curiosity-engine/template/wiki-view/static/edit.js index f809138..3f76953 100644 --- a/skills/curiosity-engine/template/wiki-view/static/edit.js +++ b/skills/curiosity-engine/template/wiki-view/static/edit.js @@ -19,6 +19,15 @@ * it and the host orchestrates the re-render. */ window.Edit = (function () { + /** Honor CE_PUBLIC_BASE under /embed/ce/ reverse-proxy (Phase 2a). */ + function apiUrl(path) { + if (typeof window.ceApi === "function") return window.ceApi(path); + var base = (window.CE_PUBLIC_BASE || "").replace(/\/$/, ""); + if (!path) path = "/"; + if (path.charAt(0) !== "/") path = "/" + path; + return base + path; + } + let _refetch = null; let _currentPage = null; let _editing = false; @@ -115,7 +124,7 @@ window.Edit = (function () { const path = _currentPage.path; let content = ''; try { - const res = await fetch('/api/page?path=' + encodeURIComponent(path)); + const res = await fetch(apiUrl('/api/page') + '?path=' + encodeURIComponent(path)); if (!res.ok) throw new Error(`HTTP ${res.status}`); const payload = await res.json(); content = payload.content || ''; @@ -162,7 +171,7 @@ window.Edit = (function () { saveBtn.disabled = true; saveBtn.textContent = 'Saving…'; try { - const res = await fetch('/api/page', { + const res = await fetch(apiUrl('/api/page'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path, content }), @@ -197,7 +206,7 @@ window.Edit = (function () { const form = new FormData(); for (const f of files) form.append('file', f, f.name); try { - const res = await fetch('/api/upload-vault', { method: 'POST', body: form }); + const res = await fetch(apiUrl('/api/upload-vault'), { method: 'POST', body: form }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || `HTTP ${res.status}`); diff --git a/skills/curiosity-engine/template/wiki-view/static/vault.js b/skills/curiosity-engine/template/wiki-view/static/vault.js index 918171f..fbee931 100644 --- a/skills/curiosity-engine/template/wiki-view/static/vault.js +++ b/skills/curiosity-engine/template/wiki-view/static/vault.js @@ -9,6 +9,14 @@ * CustomEvent / callback instead of window.open. */ window.VaultSources = (function () { + function apiUrl(path) { + if (typeof window.ceApi === "function") return window.ceApi(path); + var base = (window.CE_PUBLIC_BASE || "").replace(/\/$/, ""); + if (!path) path = "/"; + if (path.charAt(0) !== "/") path = "/" + path; + return base + path; + } + const MANIFEST_URL = 'vault/manifest.json.gz'; const shardCache = Object.create(null); // id -> Promise const shardBufCache = Object.create(null); // id -> ArrayBuffer @@ -189,7 +197,7 @@ window.VaultSources = (function () { } function fetchApiVault(filename) { - return fetch('/api/vault/' + encodeURIComponent(filename)).then(function (res) { + return fetch(apiUrl('/api/vault/' + encodeURIComponent(filename))).then(function (res) { if (!res.ok) throw new Error('api vault HTTP ' + res.status); return res.text(); }); diff --git a/tests/test_public_base.py b/tests/test_public_base.py new file mode 100644 index 0000000..b8b8ba5 --- /dev/null +++ b/tests/test_public_base.py @@ -0,0 +1,142 @@ +"""Phase 2a: CE_PUBLIC_BASE + hosted-shell contract.""" +from __future__ import annotations + +import os +import sys +import tempfile +import threading +import unittest +import urllib.error +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "skills" / "curiosity-engine" / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +import public_base # noqa: E402 +import viewer_server # noqa: E402 + + +class PublicBaseNormalize(unittest.TestCase): + def tearDown(self): + os.environ.pop("CE_PUBLIC_BASE", None) + + def test_empty_default(self): + os.environ.pop("CE_PUBLIC_BASE", None) + self.assertEqual(public_base.public_base(), "") + + def test_normalizes_slash(self): + os.environ["CE_PUBLIC_BASE"] = "embed/ce/" + self.assertEqual(public_base.public_base(), "/embed/ce") + + def test_strip_prefix(self): + os.environ["CE_PUBLIC_BASE"] = "/embed/ce" + self.assertEqual(public_base.strip_public_base("/embed/ce"), "/") + self.assertEqual(public_base.strip_public_base("/embed/ce/api/page"), "/api/page") + self.assertEqual(public_base.strip_public_base("/api/page"), "/api/page") + + +class HostedShell(unittest.TestCase): + def test_header_wins(self): + h = public_base.hosted_shell_from_request( + {"X-CE-Host": "switchbay"}, + {"host": ["okbay"]}, + ) + self.assertEqual(h, "switchbay") + + def test_query_fallback(self): + self.assertEqual( + public_base.hosted_shell_from_request(None, {"host": ["okbay"]}), + "okbay", + ) + + def test_rejects_unknown(self): + self.assertIsNone(public_base.normalize_hosted_shell("evil")) + + def test_settings_policy_stub(self): + p = public_base.hosted_settings_policy("switchbay") + self.assertEqual(p["hosted"], "switchbay") + self.assertTrue(p["shell_owns_workspace_settings"]) + self.assertTrue(p["ce_viewer_knobs_enabled"]) + + +class BootstrapInject(unittest.TestCase): + def tearDown(self): + os.environ.pop("CE_PUBLIC_BASE", None) + + def test_injects_before_head_close(self): + os.environ["CE_PUBLIC_BASE"] = "/embed/ce" + out = public_base.inject_viewer_bootstrap( + b"t", + hosted="okbay", + ).decode() + self.assertIn('window.CE_PUBLIC_BASE="/embed/ce"', out) + self.assertIn('window.CE_HOSTED="okbay"', out) + self.assertIn("window.ceApi=", out) + self.assertLess(out.index("CE_PUBLIC_BASE"), out.index("")) + + +class ViewerServerEmbed(unittest.TestCase): + """Smoke: strip prefix + /api/hosted on loopback (not 8766 — avoid clashing).""" + + def tearDown(self): + os.environ.pop("CE_PUBLIC_BASE", None) + + def test_hosted_and_prefixed_static(self): + os.environ["CE_PUBLIC_BASE"] = "/embed/ce" + with tempfile.TemporaryDirectory() as td: + root = Path(td) + bundle = root / "bundle" + wiki = root / "wiki" + bundle.mkdir() + wiki.mkdir() + (wiki / "notes").mkdir() + (bundle / "index.html").write_text( + "cehi", + encoding="utf-8", + ) + (bundle / "data.json").write_text("{}", encoding="utf-8") + + viewer_server.BUNDLE_DIR = bundle.resolve() + viewer_server.WORKSPACE_DIR = root.resolve() + viewer_server.WIKI_DIR = wiki.resolve() + viewer_server.VAULT_DIR = (root / "vault").resolve() + viewer_server.VAULT_RAW_DIR = (root / "vault" / "raw").resolve() + + httpd = viewer_server.ReusableThreadingServer( + ("127.0.0.1", 0), viewer_server.Handler + ) + port = httpd.server_address[1] + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + try: + # Prefixed HTML gets bootstrap + with urllib.request.urlopen( + f"http://127.0.0.1:{port}/embed/ce/", timeout=2 + ) as resp: + body = resp.read().decode() + self.assertIn("CE_PUBLIC_BASE", body) + self.assertIn("hi", body) + + # Hosted probe + req = urllib.request.Request( + f"http://127.0.0.1:{port}/embed/ce/api/hosted?host=switchbay", + headers={"X-CE-Host": "okbay"}, + ) + with urllib.request.urlopen(req, timeout=2) as resp: + payload = resp.read().decode() + self.assertIn('"hosted": "okbay"', payload) + + # Unprefixed still works (proxy may strip) + with urllib.request.urlopen( + f"http://127.0.0.1:{port}/api/hosted", timeout=2 + ) as resp: + self.assertEqual(resp.status, 200) + finally: + httpd.shutdown() + httpd.server_close() + + +if __name__ == "__main__": + unittest.main() From da0523a946c11d085a893b2faad19f95c2f97c45 Mon Sep 17 00:00:00 2001 From: benjsmith Date: Fri, 18 Sep 2026 20:35:17 +0000 Subject: [PATCH 02/19] feat(ce): Phase 2b filebrowser slice + replay timeline hook Land GET /api/tree (vault/ + wiki/), wiki-view Pages|Files browser with search/highlight/context menu, and knowledge-atlas playReplayTimeline. Keep CE_PUBLIC_BASE/hosted working; split deferred. Update migration intake. --- .../src/animation/replayTimeline.ts | 85 ++++ packages/knowledge-atlas/src/index.ts | 7 + .../tests/replayTimeline.test.ts | 90 ++++ .../curiosity-engine/docs/MIGRATION-INTAKE.md | 55 ++- skills/curiosity-engine/docs/architecture.md | 7 +- skills/curiosity-engine/docs/viewers.md | 6 + .../scripts/filebrowser_match.py | 61 +++ .../scripts/filebrowser_tree.py | 77 +++ .../curiosity-engine/scripts/viewer_server.py | 10 + .../template/wiki-view/index.html | 15 + .../template/wiki-view/static/filebrowser.js | 449 ++++++++++++++++++ .../template/wiki-view/static/main.css | 127 +++++ .../template/wiki-view/static/main.js | 2 + .../template/wiki-view/static/search.js | 1 + tests/test_filebrowser.py | 139 ++++++ 15 files changed, 1121 insertions(+), 10 deletions(-) create mode 100644 packages/knowledge-atlas/src/animation/replayTimeline.ts create mode 100644 packages/knowledge-atlas/tests/replayTimeline.test.ts create mode 100644 skills/curiosity-engine/scripts/filebrowser_match.py create mode 100644 skills/curiosity-engine/scripts/filebrowser_tree.py create mode 100644 skills/curiosity-engine/template/wiki-view/static/filebrowser.js create mode 100644 tests/test_filebrowser.py diff --git a/packages/knowledge-atlas/src/animation/replayTimeline.ts b/packages/knowledge-atlas/src/animation/replayTimeline.ts new file mode 100644 index 0000000..4f6a8a7 --- /dev/null +++ b/packages/knowledge-atlas/src/animation/replayTimeline.ts @@ -0,0 +1,85 @@ +/** + * Curation-replay timeline hook (Phase 2b spike). + * + * Pure scheduler extracted from Switchbay `curationReplayAnim.ts` event + * model — no SVG/d3. Hosts (wiki-view / Graph tab) own rendering; CE owns + * the timing contract so animation can later drive atlas/canvas. + */ + +export type ReplayEvent = + | { t: number; op: "node"; id: string; title: string; type: string } + | { t: number; op: "edge"; source: string; target: string }; + +export type HistoryDoc = { + duration: number; + events: ReplayEvent[]; + source: string; + generated_at?: number; + node_count?: number; + degree?: Record; +}; + +export type ReplayHandlers = { + onEvent: (ev: ReplayEvent, index: number) => void; + onDone?: () => void; + /** Wall-clock multiplier; 1 = real time vs HistoryDoc.t units. */ + rate?: number; + /** Inject clock for tests (defaults to performance/Date). */ + now?: () => number; + /** Inject timer APIs for tests. */ + schedule?: (fn: () => void, ms: number) => number; + cancel?: (id: number) => void; +}; + +/** + * Play events in ascending `t` order. Returns a cancel function. + * Empty/missing history completes on the next microtask-equivalent tick. + */ +export function playReplayTimeline( + history: HistoryDoc | null | undefined, + handlers: ReplayHandlers, +): () => void { + const events = (history?.events ?? []).slice().sort((a, b) => a.t - b.t); + const rate = handlers.rate && handlers.rate > 0 ? handlers.rate : 1; + const now = handlers.now ?? (() => (typeof performance !== "undefined" ? performance.now() : Date.now())); + const schedule = + handlers.schedule ?? + ((fn, ms) => setTimeout(fn, ms) as unknown as number); + const cancel = + handlers.cancel ?? + ((id) => { + clearTimeout(id as unknown as ReturnType); + }); + + let cancelled = false; + let timer: number | null = null; + let index = 0; + const t0 = now(); + + const tick = () => { + if (cancelled) return; + if (index >= events.length) { + handlers.onDone?.(); + return; + } + const wall = (now() - t0) * rate; + while (index < events.length && events[index]!.t <= wall) { + handlers.onEvent(events[index]!, index); + index += 1; + } + if (index >= events.length) { + handlers.onDone?.(); + return; + } + const nextT = events[index]!.t; + const delay = Math.max(0, (nextT - wall) / rate); + timer = schedule(tick, delay); + }; + + timer = schedule(tick, 0); + + return () => { + cancelled = true; + if (timer != null) cancel(timer); + }; +} diff --git a/packages/knowledge-atlas/src/index.ts b/packages/knowledge-atlas/src/index.ts index d41ea21..f0bc01c 100644 --- a/packages/knowledge-atlas/src/index.ts +++ b/packages/knowledge-atlas/src/index.ts @@ -55,3 +55,10 @@ export { export { LocalSceneSource } from "./datasources/local.ts"; export { ScaledDataSource, SCALED_TOTAL_LEAVES } from "./datasources/scaled.ts"; export { RemoteDataSource } from "./datasources/remote.ts"; + +export { + playReplayTimeline, + type ReplayEvent, + type HistoryDoc, + type ReplayHandlers, +} from "./animation/replayTimeline.ts"; diff --git a/packages/knowledge-atlas/tests/replayTimeline.test.ts b/packages/knowledge-atlas/tests/replayTimeline.test.ts new file mode 100644 index 0000000..fee42da --- /dev/null +++ b/packages/knowledge-atlas/tests/replayTimeline.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from "vitest"; +import { playReplayTimeline, type ReplayEvent } from "../src/animation/replayTimeline.ts"; + +describe("playReplayTimeline", () => { + it("emits events in time order and calls onDone", () => { + const events: ReplayEvent[] = [ + { t: 0, op: "node", id: "a", title: "A", type: "entity" }, + { t: 10, op: "node", id: "b", title: "B", type: "concept" }, + { t: 15, op: "edge", source: "a", target: "b" }, + ]; + let wall = 0; + const seen: string[] = []; + const timers: Array<{ id: number; ms: number; fn: () => void }> = []; + let nextId = 1; + + const cancel = playReplayTimeline( + { duration: 20, events, source: "test" }, + { + now: () => wall, + schedule: (fn, ms) => { + const id = nextId++; + timers.push({ id, ms, fn }); + return id; + }, + cancel: (id) => { + const i = timers.findIndex((t) => t.id === id); + if (i >= 0) timers.splice(i, 1); + }, + onEvent: (ev) => { + seen.push(ev.op === "node" ? `n:${ev.id}` : `e:${ev.source}->${ev.target}`); + }, + onDone: () => seen.push("done"), + }, + ); + + // Drain: run scheduled callbacks, advancing wall to cover delays. + while (timers.length) { + const t = timers.shift()!; + wall += t.ms; + t.fn(); + } + expect(seen).toEqual(["n:a", "n:b", "e:a->b", "done"]); + cancel(); + }); + + it("cancel stops further events", () => { + let wall = 0; + const seen: string[] = []; + const timers: Array<{ id: number; ms: number; fn: () => void }> = []; + let nextId = 1; + const stop = playReplayTimeline( + { + duration: 100, + source: "test", + events: [ + { t: 0, op: "node", id: "a", title: "A", type: "entity" }, + { t: 50, op: "node", id: "b", title: "B", type: "entity" }, + ], + }, + { + now: () => wall, + schedule: (fn, ms) => { + const id = nextId++; + timers.push({ id, ms, fn }); + return id; + }, + cancel: (id) => { + const i = timers.findIndex((t) => t.id === id); + if (i >= 0) timers.splice(i, 1); + }, + onEvent: (ev) => { + if (ev.op === "node") seen.push(ev.id); + }, + }, + ); + // First tick at t=0 + const first = timers.shift()!; + wall += first.ms; + first.fn(); + expect(seen).toEqual(["a"]); + stop(); + // Remaining timers cleared by cancel path — drain whatever is left should not add b after stop if cancel removed them + while (timers.length) { + const t = timers.shift()!; + wall += t.ms; + t.fn(); + } + expect(seen).toEqual(["a"]); + }); +}); diff --git a/skills/curiosity-engine/docs/MIGRATION-INTAKE.md b/skills/curiosity-engine/docs/MIGRATION-INTAKE.md index 7bad733..fb3c92e 100644 --- a/skills/curiosity-engine/docs/MIGRATION-INTAKE.md +++ b/skills/curiosity-engine/docs/MIGRATION-INTAKE.md @@ -1,4 +1,4 @@ -# Migration intake map — Switchbay → CE (Phase 2a) +# Migration intake map — Switchbay → CE (Phase 2a / 2b) Charter north star: **CE** owns graph, atlas, wiki, search, source viewer, **filebrowser**, **graph animation + split**. Switchbay keeps PWA shell (workspaces, watcher, tabs chrome, rail UI) and reverse-proxies CE under `/embed/ce/`. @@ -10,9 +10,9 @@ Charter north star: **CE** owns graph, atlas, wiki, search, source viewer, **fil |------------|----------------|-------| | Atlas / graph engine | `packages/knowledge-atlas` | Already the shared engine; Switchbay Graph tab adapts via examples/switchbay | | Classic wiki viewer + APIs | `skills/curiosity-engine/template/wiki-view` + `scripts/viewer_server.py` | Phase 2a: `CE_PUBLIC_BASE` / hosted hook | -| Filebrowser | New CE surface (likely wiki-view sidebar or `packages/` module) | Intake from Switchbay sidebar | -| Graph opening animation | knowledge-atlas and/or wiki-view | Intake from curation replay | -| Workspace split | CE API + atlas multi-select; shell may keep workspace registry UX | Intake from Switchbay split API | +| Filebrowser | wiki-view sidebar **Files** mode + `GET /api/tree` | Phase 2b spike landed (browse/search/highlight/ctx) | +| Graph opening animation | knowledge-atlas `animation/replayTimeline` (+ later wiki-view UI) | Phase 2b: pure timeline hook; SVG/d3 replay UI deferred | +| Workspace split | CE API + atlas multi-select; shell may keep workspace registry UX | **Deferred** past 2b spike | ## Switchbay source map (intake) @@ -30,6 +30,35 @@ Paths relative to the Switchbay repo root (branch `feat/skill-shell-rationalizat **CE intake notes:** Prefer CE vault/wiki path semantics (`vault/`, `wiki/`) over Switchbay workspace-relative pack routes. Right-click / highlight / search parity is on the umbrella checklist. Pack-specific `file_routes` may stay shell-side or become CE extension hooks later. +#### Phase 2b landed (CE) + +| Artifact | Role | +|----------|------| +| `scripts/filebrowser_tree.py` | Walk `vault/` + `wiki/` only; prune SKIP_DIRS / dot dirs | +| `scripts/filebrowser_match.py` | Substring / `/re/flags` / `*.ext` matchers (Switchbay parity) | +| `GET /api/tree` in `viewer_server.py` | `{ ok, roots, files, count }` — honors `CE_PUBLIC_BASE` strip | +| `template/wiki-view/static/filebrowser.js` | Pages\|Files toggle, tree, filter, highlight, context menu | +| Tests | `tests/test_filebrowser.py` | + +**Parity vs Switchbay (filebrowser):** + +| Behavior | Status | +|----------|--------| +| Browse vault + wiki tree | ✅ CE (`/api/tree`) | +| Search substring / regex / `*.ext` | ✅ | +| Highlight from graph search | ✅ (`FileBrowser.setSearchHits`) | +| Right-click Open / Copy / Reveal | ✅ (minimal) | +| Sort A↔Z | ✅ | +| Open wiki page → modal | ✅ | +| Open `*.extracted.md` via vault API | ✅ (`VaultSources.open`) | +| Pack `file_routes` / ext handlers | ❌ deferred (shell) | +| FS mutate (delete/dup/reveal-in-OS) | ❌ deferred | +| Drop-to-ingest | ❌ deferred | +| SourceBrowser / WikiPane dual pane | ❌ deferred | +| Ext filter chip UI | ❌ deferred | + +Deep-link: `?filebrowser=1` opens Files mode. + ### Graph animation (curation replay) | Artifact | Role | @@ -40,6 +69,15 @@ Paths relative to the Switchbay repo root (branch `feat/skill-shell-rationalizat **CE intake notes:** Animation should consume CE graph/atlas data (or OKF/CE `data.json`), not Switchbay-only tab state. Ship behind a viewer flag; keep Switchbay button until parity. +#### Phase 2b landed (CE) + +| Artifact | Role | +|----------|------| +| `packages/knowledge-atlas/src/animation/replayTimeline.ts` | Pure `playReplayTimeline` + `ReplayEvent` / `HistoryDoc` types | +| `packages/knowledge-atlas/tests/replayTimeline.test.ts` | Scheduler unit tests | + +**Deferred:** SVG/d3 force replay UI, history JSON API from CE curator state, wiki-view chrome button, atlas canvas binding. + ### Split (subgraph → new workspace) | Artifact | Role | @@ -51,6 +89,8 @@ Paths relative to the Switchbay repo root (branch `feat/skill-shell-rationalizat **CE intake notes:** Engine multi-select already exists in knowledge-atlas (`select` / `selection-changed`). Move **page move/copy + wiki partition** logic into CE; Switchbay may retain workspace registry / tab open after split. Feature-flag dual-stack until checklist green. +**Phase 2b:** split **not started** (deferred). + ## Embed contract (for Switchbay Phase 4a) - Upstream: `http://127.0.0.1:8766` (loopback only) @@ -60,6 +100,7 @@ Paths relative to the Switchbay repo root (branch `feat/skill-shell-rationalizat ## Sequencing -1. **2a (this branch):** proxy prefix + hosted stub + this map + ADR -2. **2b+:** land filebrowser / animation / split behind flags in CE -3. **4b:** Switchbay tabs thin to `/embed/ce/` once parity checklist passes +1. **2a (landed):** proxy prefix + hosted stub + this map + ADR +2. **2b (this spike):** filebrowser shell API + minimal UI; animation timeline hook; split deferred +3. **2b+:** pack routes / FS ops / SVG replay UI / split API behind flags +4. **4b:** Switchbay tabs thin to `/embed/ce/` once parity checklist passes diff --git a/skills/curiosity-engine/docs/architecture.md b/skills/curiosity-engine/docs/architecture.md index 79325bc..a2bf21f 100644 --- a/skills/curiosity-engine/docs/architecture.md +++ b/skills/curiosity-engine/docs/architecture.md @@ -95,7 +95,7 @@ Known failure modes, in rough order of when you'll hit them: - **~500-page wiki ceiling.** Beyond that, plan latency grows. The incremental score cache helps; the tiered-vault design (bounded wiki + unbounded indexed vault) unlocks more; cluster-scoped CURATE (see below) keeps individual waves coherent past the threshold. - **Single user.** No protocol for multiple humans editing the same wiki live. Two asynchronous export paths cover different audiences: **OKF** (`okf_export.py build wiki --output-dir `) projects the wiki to a vendor-neutral [Open Knowledge Format](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md) bundle for cross-tool / cross-org exchange (read-only; CE structure rides in `x_ce_*` keys — see [`okf-interop.md`](okf-interop.md)); **curiosity-merge** subgraph-export/merge shares whole or sub-wikis with other CE workspaces. Neither is concurrent multi-user editing. -## Shell embed + hosted mode (Phase 2a) +## Shell embed + hosted mode (Phase 2a / 2b) Umbrella charter: Switchbay/okbay **same-origin reverse-proxy** CE (typically `127.0.0.1:8766`) under `/embed/ce/` — **no iframes**. CE readiness: @@ -104,8 +104,9 @@ Umbrella charter: Switchbay/okbay **same-origin reverse-proxy** CE (typically `viewer_server.py` - Hosted hook: `X-CE-Host` / `?host=switchbay|okbay` and `GET /api/hosted` - ADR: [`ADR-001-proxy-embed-and-hosted.md`](ADR-001-proxy-embed-and-hosted.md) -- Feature migration intake (filebrowser, graph animation, split): - [`MIGRATION-INTAKE.md`](MIGRATION-INTAKE.md) +- **Phase 2b:** `GET /api/tree` filebrowser (vault/ + wiki/) + wiki-view Files + mode; knowledge-atlas `playReplayTimeline` animation hook. Split deferred. + Details: [`MIGRATION-INTAKE.md`](MIGRATION-INTAKE.md) Bare `viewer.sh` loopback default remains port **8090**; bind host is always `127.0.0.1`. diff --git a/skills/curiosity-engine/docs/viewers.md b/skills/curiosity-engine/docs/viewers.md index 25b5a85..931b390 100644 --- a/skills/curiosity-engine/docs/viewers.md +++ b/skills/curiosity-engine/docs/viewers.md @@ -69,6 +69,12 @@ Optional hosted signal: `X-CE-Host: switchbay|okbay` or `?host=`. See [`ADR-001-proxy-embed-and-hosted.md`](ADR-001-proxy-embed-and-hosted.md). Bare local `viewer.sh` is unchanged (default port 8090, `127.0.0.1`). +**Filebrowser (Phase 2b).** Sidebar **Pages | Files** toggle. Files mode +calls `GET /api/tree` (vault/ + wiki/ only), supports substring / `/re/` / +`*.ext` filter, graph-search highlight, and a minimal context menu. +Honors `ceApi()` / `CE_PUBLIC_BASE`. Open `?filebrowser=1` to land in Files +mode. Pack routes and FS mutate ops remain Switchbay-side until parity. + ## Obsidian (alternative — same underlying markdown) `wiki/` is plain markdown with `[[wikilinks]]`. Open Obsidian → diff --git a/skills/curiosity-engine/scripts/filebrowser_match.py b/skills/curiosity-engine/scripts/filebrowser_match.py new file mode 100644 index 0000000..8be9906 --- /dev/null +++ b/skills/curiosity-engine/scripts/filebrowser_match.py @@ -0,0 +1,61 @@ +"""Filebrowser path matchers — parity with Switchbay FileBrowser buildMatcher. + +Empty query → match all. Supports: + - /pattern/flags (invalid regex → None, so UI can show feedback) + - *.ext filetype-only (case-insensitive) + - plain substring (case-insensitive against the full relative path) +""" +from __future__ import annotations + +import re +from collections.abc import Callable + +Matcher = Callable[[str], bool] + + +def file_ext(path: str) -> str: + """Match Switchbay FileBrowser.fileExt (last dot after last slash).""" + slash = path.rfind("/") + dot = path.rfind(".") + if dot <= slash: + return "" + return path[dot + 1 :].lower() + + +def build_matcher(query: str) -> Matcher | None: + """Return a matcher, or None when the regex form is invalid.""" + q = (query or "").strip() + if not q: + return lambda _p: True + + m = re.fullmatch(r"/(.+)/([gimsuy]*)", q) + if m: + pattern, flags_s = m.group(1), m.group(2) or "i" + flag = 0 + if "i" in flags_s: + flag |= re.IGNORECASE + if "m" in flags_s: + flag |= re.MULTILINE + if "s" in flags_s: + flag |= re.DOTALL + try: + rx = re.compile(pattern, flag) + except re.error: + return None + return lambda p, _rx=rx: _rx.search(p) is not None + + glob = re.fullmatch(r"\*\.([A-Za-z0-9_]+)", q) + if glob: + ext = glob.group(1).lower() + return lambda p, _e=ext: file_ext(p) == _e + + lower = q.lower() + return lambda p, _l=lower: _l in p.lower() + + +def filter_paths(paths: list[str], query: str) -> tuple[list[str] | None, bool]: + """Filter paths. Returns (matches_or_None_on_bad_regex, ok).""" + matcher = build_matcher(query) + if matcher is None: + return None, False + return [p for p in paths if matcher(p)], True diff --git a/skills/curiosity-engine/scripts/filebrowser_tree.py b/skills/curiosity-engine/scripts/filebrowser_tree.py new file mode 100644 index 0000000..4dfd8c5 --- /dev/null +++ b/skills/curiosity-engine/scripts/filebrowser_tree.py @@ -0,0 +1,77 @@ +"""CE filebrowser tree walk — vault/ + wiki/ only (Phase 2b). + +Switchbay's /api/tree walks the whole workspace. CE intake prefers vault +and wiki path semantics so the embed surface matches the three-object +model (architecture.md). Hidden/dot dirs and a small SKIP set are pruned +during os.walk (never descend). +""" +from __future__ import annotations + +import os +from pathlib import Path + +# Parallel Switchbay daemon.SKIP_DIRS / fileops._SKIP_DIRS (subset relevant +# under vault/wiki; kept for safety if a nested tool tree appears). +SKIP_DIRS = frozenset( + { + ".git", + ".workbench", + "node_modules", + ".venv", + "venv", + "__pycache__", + "dist", + "build", + ".vite", + ".cache", + ".idea", + ".vscode", + ".pytest_cache", + ".curator", + } +) + +# Roots relative to the workspace. Order is stable for UI grouping. +TREE_ROOTS = ("vault", "wiki") + + +def walk_ce_tree(workspace: Path) -> list[str]: + """Return sorted relative paths under vault/ and wiki/. + + Paths use forward slashes and always start with ``vault/`` or + ``wiki/``. Missing roots are skipped (empty list for that root). + """ + ws = Path(workspace).resolve() + out: list[str] = [] + for root_name in TREE_ROOTS: + root = ws / root_name + if not root.is_dir(): + continue + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [ + d + for d in dirnames + if not d.startswith(".") and d not in SKIP_DIRS + ] + for fn in filenames: + if fn.startswith("."): + continue + full = Path(dirpath) / fn + try: + rel = full.resolve().relative_to(ws).as_posix() + except ValueError: + continue + out.append(rel) + out.sort() + return out + + +def tree_payload(workspace: Path) -> dict: + """JSON body for GET /api/tree.""" + files = walk_ce_tree(workspace) + return { + "ok": True, + "roots": list(TREE_ROOTS), + "files": files, + "count": len(files), + } diff --git a/skills/curiosity-engine/scripts/viewer_server.py b/skills/curiosity-engine/scripts/viewer_server.py index adc9ba6..c68ebf3 100644 --- a/skills/curiosity-engine/scripts/viewer_server.py +++ b/skills/curiosity-engine/scripts/viewer_server.py @@ -12,6 +12,7 @@ GET /api/page?path= raw markdown of a wiki page GET /api/vault/ vault/*.extracted.md basenames only GET /api/hosted hosted-mode stub (host=switchbay|okbay) + GET /api/tree vault/ + wiki/ relative paths (filebrowser) POST /api/page JSON {path, content} → overwrite file POST /api/upload-vault multipart form → save to vault/raw/ @@ -60,6 +61,7 @@ from pathlib import Path import public_base +import filebrowser_tree SCRIPT_DIR = Path(__file__).resolve().parent @@ -145,6 +147,8 @@ def do_GET(self): return self._handle_get_vault(url.path[len("/api/vault/"):]) if url.path == "/api/hosted": return self._json(200, public_base.hosted_settings_policy(self._hosted(url))) + if url.path == "/api/tree": + return self._handle_get_tree() # Inject embed bootstrap into HTML so /api fetches honor CE_PUBLIC_BASE. if self._looks_like_html(url.path): return self._serve_html_with_bootstrap(url) @@ -233,6 +237,12 @@ def _rebuild(self) -> None: sys.stderr.write(f"viewer-server: rebuild failed: {e}\n") # ── handlers ─────────────────────────────────────────────────── + def _handle_get_tree(self) -> None: + """List vault/ + wiki/ files for the CE filebrowser (Phase 2b).""" + if WORKSPACE_DIR is None: + return self._json(500, {"error": "workspace unset"}) + return self._json(200, filebrowser_tree.tree_payload(WORKSPACE_DIR)) + def _handle_get_page(self, qs: dict) -> None: rel = (qs.get("path") or [""])[0] try: diff --git a/skills/curiosity-engine/template/wiki-view/index.html b/skills/curiosity-engine/template/wiki-view/index.html index d0b4fa7..5b5bc3e 100644 --- a/skills/curiosity-engine/template/wiki-view/index.html +++ b/skills/curiosity-engine/template/wiki-view/index.html @@ -26,6 +26,10 @@ {{WORKSPACE}} + + +