5 Commits

7 changed files with 381 additions and 45 deletions

55
404.html Normal file
View File

@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lost in the Tower</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #0a0a0f;
color: #e0d8c8;
font-family: Georgia, 'Times New Roman', serif;
}
h1 {
font-size: 1.4rem;
font-weight: normal;
letter-spacing: 0.15em;
text-transform: uppercase;
margin-bottom: 1.5rem;
color: #8a7f6a;
}
p {
font-size: 0.9rem;
color: #6a6050;
margin-bottom: 2rem;
}
a {
color: #8a7f6a;
text-decoration: none;
padding: 0.8rem 2rem;
border: 1px solid #2a2520;
transition: border-color 0.3s, color 0.3s;
}
a:hover {
border-color: #8a7f6a;
color: #fff;
}
</style>
</head>
<body>
<h1>Lost in the Tower</h1>
<p>This room doesn't exist.</p>
<a href="/">Return to the Entry Hall</a>
</body>
</html>

10
api/health/index.json Normal file
View File

@@ -0,0 +1,10 @@
{
"status": "ok",
"services": {
"api": true,
"agent_loop": false,
"websocket": false
},
"uptime": null,
"version": "20260322.230710"
}

View File

@@ -4,7 +4,7 @@
<link href="https://alexanderwhitestone.com/blog/"/> <link href="https://alexanderwhitestone.com/blog/"/>
<link rel="self" href="https://alexanderwhitestone.com/blog/feed.xml"/> <link rel="self" href="https://alexanderwhitestone.com/blog/feed.xml"/>
<id>https://alexanderwhitestone.com/blog/</id> <id>https://alexanderwhitestone.com/blog/</id>
<updated>2026-03-19T01:42:22Z</updated> <updated>2026-03-19T01:45:33Z</updated>
<author> <author>
<name>Alexander Whitestone</name> <name>Alexander Whitestone</name>
</author> </author>

View File

@@ -2,51 +2,104 @@
# aw-post — Quick-post a scroll from the command line # aw-post — Quick-post a scroll from the command line
# #
# Usage: # Usage:
# aw-post "Title of the Post" # aw-post "Title" # Create empty post, open for editing
# aw-post "Title" < body.md # aw-post "Title" < body.md # Pipe body from stdin
# echo "Post body here" | aw-post "Title" # echo "Post body" | aw-post "Title" # Pipe body from stdin
# aw-post --file thoughts.md # Post from a file (title from first line)
# aw-post --from-x "Tweet text here" # Port an X/Twitter post
# #
# Creates a new markdown file in blog/posts/ with frontmatter, # Creates a markdown file in blog/posts/, rebuilds the blog.
# then rebuilds the blog index, post pages, and RSS feed.
set -euo pipefail set -euo pipefail
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
BLOG_DIR="${REPO_DIR}/blog/posts" BLOG_DIR="${REPO_DIR}/blog/posts"
if [ $# -lt 1 ]; then
echo "Usage: aw-post \"Title of the Post\""
echo " Pipe or redirect body content via stdin."
exit 1
fi
TITLE="$1"
DATE=$(date +%Y-%m-%d) DATE=$(date +%Y-%m-%d)
SLUG=$(echo "$TITLE" | tr '[:upper:]' '[:lower:]' | tr ' ' '-' | tr -cd 'a-z0-9-')
FILENAME="${DATE}-${SLUG}.md"
FILEPATH="${BLOG_DIR}/${FILENAME}"
if [ -f "$FILEPATH" ]; then slugify() {
echo "Error: $FILEPATH already exists" echo "$1" | tr '[:upper:]' '[:lower:]' | tr ' ' '-' | tr -cd 'a-z0-9-' | head -c 60
exit 1 }
fi
# Read body from stdin if available create_post() {
BODY="" local title="$1"
if [ ! -t 0 ]; then local body="$2"
BODY=$(cat) local slug
fi slug=$(slugify "$title")
local filename="${DATE}-${slug}.md"
local filepath="${BLOG_DIR}/${filename}"
cat > "$FILEPATH" << EOF if [ -f "$filepath" ]; then
echo "Error: $filepath already exists"
exit 1
fi
cat > "$filepath" << EOF
--- ---
title: "${TITLE}" title: "${title}"
date: ${DATE} date: ${DATE}
--- ---
${BODY} ${body}
EOF EOF
echo "Created: ${FILEPATH}" echo "Created: ${filepath}"
python3 "${REPO_DIR}/scripts/build.py"
}
# Rebuild the blog # Parse arguments
python3 "${REPO_DIR}/scripts/build.py" case "${1:-}" in
--file)
# Post from a file — title from first non-empty line
if [ -z "${2:-}" ] || [ ! -f "${2:-}" ]; then
echo "Usage: aw-post --file <path-to-markdown>"
exit 1
fi
BODY=$(cat "$2")
# Extract title: first line starting with # or first non-empty line
TITLE=$(echo "$BODY" | grep -m1 '^#' | sed 's/^#* *//' || echo "$BODY" | grep -m1 '.' || echo "Untitled")
if [ -z "$TITLE" ]; then
TITLE="Untitled"
fi
# Strip the title line from body if it was a markdown header
BODY=$(echo "$BODY" | sed '1{/^#/d;}')
create_post "$TITLE" "$BODY"
;;
--from-x)
# Port an X/Twitter post — the text becomes both title and body
if [ -z "${2:-}" ]; then
echo "Usage: aw-post --from-x \"Tweet text here\""
exit 1
fi
TEXT="$2"
# Title: first 60 chars, cleaned up
TITLE=$(echo "$TEXT" | head -1 | cut -c1-60 | sed 's/[[:space:]]*$//')
create_post "$TITLE" "$TEXT"
;;
--help|-h)
echo "aw-post — Quick-post a scroll to The Scrolls"
echo ""
echo "Usage:"
echo " aw-post \"Title\" Create a post (body from stdin)"
echo " aw-post --file thoughts.md Post from a markdown file"
echo " aw-post --from-x \"Tweet text\" Port an X/Twitter post"
echo " aw-post --help Show this help"
;;
"")
echo "Usage: aw-post \"Title of the Post\""
echo " See: aw-post --help"
exit 1
;;
*)
# Standard mode: title as arg, body from stdin
TITLE="$1"
BODY=""
if [ ! -t 0 ]; then
BODY=$(cat)
fi
create_post "$TITLE" "$BODY"
;;
esac

View File

@@ -17,14 +17,17 @@ Generates:
""" """
import html import html
import json
import os import os
import re import re
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
SITE_URL = "https://alexanderwhitestone.com" SITE_URL = "https://alexanderwhitestone.com"
BLOG_DIR = Path(__file__).parent.parent / "blog" ROOT_DIR = Path(__file__).parent.parent
BLOG_DIR = ROOT_DIR / "blog"
POSTS_DIR = BLOG_DIR / "posts" POSTS_DIR = BLOG_DIR / "posts"
HEALTH_DIR = ROOT_DIR / "api" / "health"
PAGE_STYLE = """\ PAGE_STYLE = """\
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
@@ -291,6 +294,23 @@ def generate_feed(posts):
print(f" Generated feed with {len(entries)} entry/entries.") print(f" Generated feed with {len(entries)} entry/entries.")
def generate_health():
"""Generate api/health/index.json with build-time metadata."""
HEALTH_DIR.mkdir(parents=True, exist_ok=True)
health = {
"status": "ok",
"services": {
"api": True,
"agent_loop": False,
"websocket": False,
},
"uptime": None,
"version": datetime.now(timezone.utc).strftime("%Y%m%d.%H%M%S"),
}
(HEALTH_DIR / "index.json").write_text(json.dumps(health, indent=2) + "\n")
print(" Generated api/health endpoint.")
def main(): def main():
print("Building The Scrolls...") print("Building The Scrolls...")
posts = load_posts() posts = load_posts()
@@ -299,6 +319,7 @@ def main():
print(f" Built: {out.relative_to(BLOG_DIR.parent)}") print(f" Built: {out.relative_to(BLOG_DIR.parent)}")
generate_index(posts) generate_index(posts)
generate_feed(posts) generate_feed(posts)
generate_health()
print("Build complete.") print("Build complete.")

View File

@@ -19,8 +19,57 @@
color: #6a6050; color: #6a6050;
} }
.placeholder h1 { font-size: 1.2rem; font-weight: normal; margin-bottom: 1rem; } .placeholder h1 { font-size: 1.2rem; font-weight: normal; margin-bottom: 1rem; }
.placeholder p { font-size: 0.85rem; } .placeholder p { font-size: 0.85rem; margin-bottom: 0.5rem; }
.placeholder a { color: #8a7f6a; } .placeholder a { color: #8a7f6a; }
/* Connection status HUD */
#status-hud {
position: fixed;
top: 12px;
right: 12px;
background: rgba(10, 10, 15, 0.85);
border: 1px solid #2a2520;
border-radius: 6px;
padding: 8px 14px;
font-family: 'Courier New', monospace;
font-size: 0.75rem;
color: #6a6050;
z-index: 100;
min-width: 180px;
}
#status-hud .status-line {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
#status-hud .status-line:last-child { margin-bottom: 0; }
#status-hud .dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
#status-hud .dot.connecting { background: #b8860b; animation: pulse 1.2s ease-in-out infinite; }
#status-hud .dot.online { background: #4a9; }
#status-hud .dot.offline { background: #a44; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
#retry-btn {
display: none;
margin-top: 6px;
padding: 3px 10px;
background: #2a2520;
border: 1px solid #4a4030;
border-radius: 3px;
color: #8a7f6a;
font-family: 'Courier New', monospace;
font-size: 0.7rem;
cursor: pointer;
}
#retry-btn:hover { background: #3a3530; color: #c0b8a8; }
</style> </style>
</head> </head>
<body> <body>
@@ -31,7 +80,45 @@
<p><a href="/">← Back to the Tower</a></p> <p><a href="/">← Back to the Tower</a></p>
</div> </div>
</div> </div>
<div id="status-hud">
<div class="status-line">
<span class="dot connecting" id="status-dot"></span>
<span id="status-text">INITIALIZING</span>
</div>
<div class="status-line">AGENTS: <span id="agent-count">0</span></div>
<button id="retry-btn">Retry connection</button>
<a id="fallback-link" href="/api/ui" style="display:none; margin-top:6px; color:#8a7f6a; font-size:0.7rem;">Open API dashboard →</a>
</div>
<!-- Reject unknown sub-paths: only /world/ is valid -->
<script>
(function() {
var path = window.location.pathname.replace(/\/+$/, '') || '/';
if (path !== '/world') {
window.location.replace('/404.html');
}
})();
</script>
<!-- Fallback: if main.js fails to load, don't leave user on INITIALIZING forever -->
<script>
(function() {
var INIT_TIMEOUT_MS = 8000;
window.__workshopBooted = false;
setTimeout(function() {
if (window.__workshopBooted) return;
var dot = document.getElementById('status-dot');
var text = document.getElementById('status-text');
var btn = document.getElementById('retry-btn');
var fallback = document.getElementById('fallback-link');
if (dot) { dot.className = 'dot offline'; }
if (text) { text.textContent = 'WORKSHOP UNREACHABLE'; }
if (btn) { btn.style.display = 'block'; }
if (fallback) { fallback.style.display = 'block'; }
}, INIT_TIMEOUT_MS);
})();
</script>
<!-- Three.js scene will mount to #scene --> <!-- Three.js scene will mount to #scene -->
<!-- <script type="module" src="main.js"></script> --> <script type="module" src="main.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,20 +1,130 @@
/** /**
* The Workshop — Three.js scene bootstrap * The Workshop — Three.js scene bootstrap
* *
* This file will initialize the 3D world where Timmy lives. * Initializes the 3D world where Timmy lives.
* Currently a placeholder until tech decisions are made: * Handles WebSocket connection to tower-hermes backend with
* - 3D engine confirmed (Three.js vs Babylon.js) * timeout, retry, and clear status display.
* - Character design direction chosen
* - WebSocket bridge to Timmy's soul designed (#243)
* *
* See: #242 (3D world), #243 (WebSocket bridge), #265 (presence schema) * See: #242 (3D world), #243 (WebSocket bridge), #265 (presence schema)
*/ */
// Future: import * as THREE from 'three'; // Future: import * as THREE from 'three';
export function initWorkshop(container) { const HERMES_WS_URL = (location.protocol === 'https:' ? 'wss://' : 'ws://') +
// TODO: Initialize 3D scene location.host + '/ws/tower';
// TODO: Load wizard character model const CONNECT_TIMEOUT_MS = 5000;
// TODO: Connect to Timmy presence WebSocket const RETRY_DELAY_MS = 3000;
console.log('[Workshop] Scene container ready:', container.id); const MAX_AUTO_RETRIES = 3;
const Status = { CONNECTING: 'connecting', ONLINE: 'online', OFFLINE: 'offline' };
const dom = {
dot: document.getElementById('status-dot'),
text: document.getElementById('status-text'),
agents: document.getElementById('agent-count'),
retryBtn: document.getElementById('retry-btn'),
fallback: document.getElementById('fallback-link'),
};
// Signal to inline fallback script that main.js loaded successfully
window.__workshopBooted = true;
let ws = null;
let autoRetries = 0;
let connectTimer = null;
function setStatus(state, message) {
dom.dot.className = 'dot ' + state;
dom.text.textContent = message;
var isOffline = state === Status.OFFLINE;
dom.retryBtn.style.display = isOffline ? 'block' : 'none';
if (dom.fallback) dom.fallback.style.display = isOffline ? 'block' : 'none';
} }
function setAgentCount(n) {
dom.agents.textContent = n;
}
function cleanup() {
clearTimeout(connectTimer);
if (ws) {
ws.onopen = null;
ws.onclose = null;
ws.onerror = null;
ws.onmessage = null;
if (ws.readyState <= WebSocket.OPEN) ws.close();
ws = null;
}
}
function connect() {
cleanup();
setStatus(Status.CONNECTING, 'CONNECTING\u2026');
setAgentCount(0);
try {
ws = new WebSocket(HERMES_WS_URL);
} catch (err) {
console.error('[Workshop] WebSocket creation failed:', err);
onFail();
return;
}
connectTimer = setTimeout(function () {
console.warn('[Workshop] Connection timeout after ' + CONNECT_TIMEOUT_MS + 'ms');
cleanup();
onFail();
}, CONNECT_TIMEOUT_MS);
ws.onopen = function () {
clearTimeout(connectTimer);
autoRetries = 0;
setStatus(Status.ONLINE, 'ONLINE');
console.log('[Workshop] Connected to tower-hermes');
};
ws.onmessage = function (evt) {
try {
var msg = JSON.parse(evt.data);
if (typeof msg.agents === 'number') setAgentCount(msg.agents);
} catch (_) {
// non-JSON messages are ignored
}
};
ws.onclose = function () {
clearTimeout(connectTimer);
console.log('[Workshop] Connection closed');
onFail();
};
ws.onerror = function () {
clearTimeout(connectTimer);
console.error('[Workshop] WebSocket error');
// onclose will fire after this, which calls onFail
};
}
function onFail() {
if (autoRetries < MAX_AUTO_RETRIES) {
autoRetries++;
setStatus(Status.CONNECTING, 'RETRYING (' + autoRetries + '/' + MAX_AUTO_RETRIES + ')\u2026');
setTimeout(connect, RETRY_DELAY_MS);
} else {
setStatus(Status.OFFLINE, 'WORKSHOP OFFLINE');
}
}
// Manual retry resets the counter
dom.retryBtn.addEventListener('click', function () {
autoRetries = 0;
connect();
});
// Boot
export function initWorkshop(container) {
console.log('[Workshop] Scene container ready:', container.id);
connect();
}
initWorkshop(document.getElementById('scene'));