diff --git a/.pi/extensions/prompt-url-widget.ts b/.pi/extensions/prompt-url-widget.ts index 20406e937..4c3c31c3d 100644 --- a/.pi/extensions/prompt-url-widget.ts +++ b/.pi/extensions/prompt-url-widget.ts @@ -1,12 +1,10 @@ import { DynamicBorder, type ExtensionAPI, type ExtensionContext } from "@mariozechner/pi-coding-agent"; import { Container, Text } from "@mariozechner/pi-tui"; -const PR_PROMPT_PATTERN = /^\s*You are given one or more GitHub PR URLs:\s*(\S+)/im; -const ISSUE_PROMPT_PATTERN = /^\s*Analyze GitHub issue\(s\):\s*(\S+)/im; - type PromptMatch = { - kind: "pr" | "issue"; + kind: "pr" | "issue" | "forum"; url: string; + forumId?: string; }; type GhMetadata = { @@ -17,18 +15,37 @@ type GhMetadata = { }; }; +const URL_PATTERNS: { kind: PromptMatch["kind"]; pattern: RegExp }[] = [ + { kind: "pr", pattern: /https?:\/\/github\.com\/[^\s/]+\/[^\s/]+\/pull\/\d+[^\s]*/gi }, + { kind: "issue", pattern: /https?:\/\/github\.com\/[^\s/]+\/[^\s/]+\/issues\/\d+[^\s]*/gi }, + { kind: "forum", pattern: /https?:\/\/(?:www\.)?esotericsoftware\.com\/forum\/d\/(\d+)[^\s]*/gi }, +]; + function extractPromptMatch(prompt: string): PromptMatch | undefined { - const prMatch = prompt.match(PR_PROMPT_PATTERN); - if (prMatch?.[1]) { - return { kind: "pr", url: prMatch[1].trim() }; + let last: PromptMatch | undefined; + + for (const { kind, pattern } of URL_PATTERNS) { + pattern.lastIndex = 0; + let m; + while ((m = pattern.exec(prompt)) !== null) { + const url = m[0].trim(); + const forumId = kind === "forum" ? m[1] : undefined; + last = { kind, url, forumId }; + } } - const issueMatch = prompt.match(ISSUE_PROMPT_PATTERN); - if (issueMatch?.[1]) { - return { kind: "issue", url: issueMatch[1].trim() }; - } + return last; +} - return undefined; +async function fetchForumMetadata(forumId: string): Promise<{ title?: string } | undefined> { + try { + const res = await fetch(`https://esotericsoftware.com/forum/api/discussions/${forumId}`); + if (!res.ok) return undefined; + const data = (await res.json()) as { data?: { attributes?: { title?: string } } }; + return { title: data?.data?.attributes?.title }; + } catch { + return undefined; + } } async function fetchGhMetadata( @@ -76,8 +93,20 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) { }); }; + const fetchMetadata = async ( + api: ExtensionAPI, + match: PromptMatch, + ): Promise<{ title?: string; authorText?: string }> => { + if (match.kind === "forum" && match.forumId) { + const meta = await fetchForumMetadata(match.forumId); + return { title: meta?.title?.trim() }; + } + const meta = await fetchGhMetadata(api, match.kind as "pr" | "issue", match.url); + return { title: meta?.title?.trim(), authorText: formatAuthor(meta?.author) }; + }; + const applySessionName = (ctx: ExtensionContext, match: PromptMatch, title?: string) => { - const label = match.kind === "pr" ? "PR" : "Issue"; + const label = match.kind === "pr" ? "PR" : match.kind === "issue" ? "Issue" : "Forum"; const trimmedTitle = title?.trim(); const fallbackName = `${label}: ${match.url}`; const desiredName = trimmedTitle ? `${label}: ${trimmedTitle} (${match.url})` : fallbackName; @@ -100,9 +129,7 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) { setWidget(ctx, match); applySessionName(ctx, match); - void fetchGhMetadata(pi, match.kind, match.url).then((meta) => { - const title = meta?.title?.trim(); - const authorText = formatAuthor(meta?.author); + void fetchMetadata(pi, match).then(({ title, authorText }) => { setWidget(ctx, match, title, authorText); applySessionName(ctx, match, title); }); @@ -144,9 +171,7 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) { setWidget(ctx, match); applySessionName(ctx, match); - void fetchGhMetadata(pi, match.kind, match.url).then((meta) => { - const title = meta?.title?.trim(); - const authorText = formatAuthor(meta?.author); + void fetchMetadata(pi, match).then(({ title, authorText }) => { setWidget(ctx, match, title, authorText); applySessionName(ctx, match, title); }); diff --git a/.pi/skills/forum/SKILL.md b/.pi/skills/forum/SKILL.md new file mode 100644 index 000000000..d61bb3b0c --- /dev/null +++ b/.pi/skills/forum/SKILL.md @@ -0,0 +1,34 @@ +--- +name: forum +description: Fetch Spine forum discussion threads. Use when the user shares a forum URL (esotericsoftware.com/forum/d/...) or asks about a forum thread. +--- + +# Spine Forum + +Fetch discussion threads from the Spine forum (Flarum) via its public REST API. No authentication needed. + +## Fetch a thread + +```bash +{baseDir}/fetch.js +``` + +Accepts a full forum URL or just the discussion ID: + +```bash +{baseDir}/fetch.js https://esotericsoftware.com/forum/d/29888-spine-ue-world-movement-not-affecting-physics/5 +{baseDir}/fetch.js 29888 +``` + +Output includes all posts as plain text with code blocks preserved. Image and video URLs are listed after each post as `[IMAGE]` / `[VIDEO]` lines. Use the `read` tool on image URLs to view them. + +### Options + +- `--html` - Print raw HTML instead of converting to text +- `--json` - Print the raw JSON API response + +## Extracting the discussion ID from a URL + +Forum URLs: `https://esotericsoftware.com/forum/d/{ID}-{slug}/{postIndex}` + +The ID is the number after `/d/`. The slug and post index are irrelevant for API access. diff --git a/.pi/skills/forum/fetch.js b/.pi/skills/forum/fetch.js new file mode 100755 index 000000000..b508ce49b --- /dev/null +++ b/.pi/skills/forum/fetch.js @@ -0,0 +1,147 @@ +#!/usr/bin/env node + +/** + * Fetch Spine forum discussion threads via the Flarum REST API. + * + * Usage: + * fetch.js Print all posts as plain text with media URLs + * fetch.js --html Print raw HTML instead of stripping tags + * fetch.js --json Print raw JSON API response + * + * Examples: + * fetch.js https://esotericsoftware.com/forum/d/29888-spine-ue-world-movement-not-affecting-physics/5 + * fetch.js 29888 + * fetch.js 29888 --html + */ + +const https = require("https"); +const http = require("http"); + +const BASE = "https://esotericsoftware.com/forum/api"; + +function extractId(arg) { + const m = arg.match(/\/d\/(\d+)/); + if (m) return m[1]; + if (/^\d+$/.test(arg)) return arg; + console.error(`Error: cannot extract discussion ID from: ${arg}`); + process.exit(1); +} + +function fetch(url) { + return new Promise((resolve, reject) => { + const lib = url.startsWith("https") ? https : http; + lib.get(url, { headers: { Accept: "application/json" } }, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + return fetch(res.headers.location).then(resolve, reject); + } + const chunks = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => { + const body = Buffer.concat(chunks).toString(); + try { resolve(JSON.parse(body)); } + catch (e) { reject(new Error(`JSON parse error: ${e.message}\n${body.slice(0, 200)}`)); } + }); + res.on("error", reject); + }).on("error", reject); + }); +} + +function decodeEntities(s) { + return s + .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n))) + .replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16))) + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, " "); +} + +function htmlToText(h) { + // Code blocks + h = h.replace(/
]*>([\s\S]*?)<\/code><\/pre>/gi, (_, code) => {
+		return "\n```\n" + decodeEntities(code.replace(/<[^>]+>/g, "")) + "\n```\n";
+	});
+	// Inline code
+	h = h.replace(/(.*?)<\/code>/gi, (_, code) => "`" + decodeEntities(code) + "`");
+	// Strip script tags entirely
+	h = h.replace(//gi, "");
+	// Line breaks
+	h = h.replace(//gi, "\n");
+	// Block boundaries
+	h = h.replace(/<\/?(p|div|li|ol|ul|h[1-6]|blockquote)[^>]*>/gi, "\n");
+	// Strip remaining tags
+	h = h.replace(/<[^>]+>/g, "");
+	// Decode entities
+	h = decodeEntities(h);
+	// Collapse blank lines
+	h = h.replace(/\n{3,}/g, "\n\n");
+	return h.trim();
+}
+
+function extractMedia(h) {
+	const imgs = [...h.matchAll(/]+src="([^"]+)"/g)].map((m) => m[1]);
+	const vids = [...h.matchAll(/]+src="([^"]+)"/g)].map((m) => m[1]);
+	return { imgs, vids };
+}
+
+async function main() {
+	const args = process.argv.slice(2);
+	if (args.length === 0 || args[0] === "-h" || args[0] === "--help") {
+		console.log(`Usage: fetch.js  [--html|--json]`);
+		process.exit(0);
+	}
+
+	const discId = extractId(args[0]);
+	const mode = args.includes("--html") ? "html" : args.includes("--json") ? "json" : "text";
+
+	const url = `${BASE}/discussions/${discId}?include=posts,posts.user`;
+	const data = await fetch(url);
+
+	if (mode === "json") {
+		console.log(JSON.stringify(data, null, 2));
+		return;
+	}
+
+	const included = data.included || [];
+	const users = {};
+	for (const item of included) {
+		if (item.type === "users") {
+			users[item.id] = item.attributes.displayName || item.attributes.username || "?";
+		}
+	}
+
+	const posts = included
+		.filter((i) => i.type === "posts" && i.attributes.contentType === "comment")
+		.sort((a, b) => a.attributes.createdAt.localeCompare(b.attributes.createdAt));
+
+	const title = data.data.attributes.title;
+	console.log(`# ${title}\n`);
+
+	for (const p of posts) {
+		const uid = p.relationships.user.data.id;
+		const author = users[uid] || "?";
+		const created = p.attributes.createdAt;
+		const rawHtml = p.attributes.contentHtml || "";
+
+		console.log(`## ${author} (${created})\n`);
+
+		if (mode === "html") {
+			console.log(rawHtml);
+		} else {
+			console.log(htmlToText(rawHtml));
+		}
+
+		const { imgs, vids } = extractMedia(rawHtml);
+		for (const u of imgs) console.log(`\n[IMAGE] ${u}`);
+		for (const u of vids) console.log(`\n[VIDEO] ${u}`);
+
+		console.log("\n---\n");
+	}
+}
+
+main().catch((e) => {
+	console.error(e.message);
+	process.exit(1);
+});
diff --git a/AGENTS.md b/AGENTS.md
index 7038bba08..1a8677276 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -6,6 +6,9 @@
 - Before editing, read files in full, especially if the read tool truncates them.
 - Follow existing code style in touched files (naming, type usage, control flow, and error handling patterns).
 
+## Files to never commit
+- **NEVER** commit Eclipse settings files (`.settings/`, `.classpath`, `.project`). These are IDE-specific and must not be checked in. If `git status` shows changes to these files, revert them before committing.
+
 ## Git commit subject prefix (required)
 Every commit subject must start with a runtime prefix.