[runtimes] Add forum skill, prompt-url extension, update AGENTS.md with eclipse file rule

This commit is contained in:
Mario Zechner 2026-03-24 21:09:26 +01:00
parent feceba3cb7
commit be5c905b7d
4 changed files with 228 additions and 19 deletions

View File

@ -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);
});

34
.pi/skills/forum/SKILL.md Normal file
View File

@ -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 <url_or_id>
```
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.

147
.pi/skills/forum/fetch.js Executable file
View File

@ -0,0 +1,147 @@
#!/usr/bin/env node
/**
* Fetch Spine forum discussion threads via the Flarum REST API.
*
* Usage:
* fetch.js <url_or_id> Print all posts as plain text with media URLs
* fetch.js <url_or_id> --html Print raw HTML instead of stripping tags
* fetch.js <url_or_id> --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(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&nbsp;/g, " ");
}
function htmlToText(h) {
// Code blocks
h = h.replace(/<pre><code[^>]*>([\s\S]*?)<\/code><\/pre>/gi, (_, code) => {
return "\n```\n" + decodeEntities(code.replace(/<[^>]+>/g, "")) + "\n```\n";
});
// Inline code
h = h.replace(/<code>(.*?)<\/code>/gi, (_, code) => "`" + decodeEntities(code) + "`");
// Strip script tags entirely
h = h.replace(/<script[\s\S]*?<\/script>/gi, "");
// Line breaks
h = h.replace(/<br\s*\/?>/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(/<img[^>]+src="([^"]+)"/g)].map((m) => m[1]);
const vids = [...h.matchAll(/<video[^>]+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 <url_or_id> [--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);
});

View File

@ -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.