diff --git a/plugins/omi-hacker-news-app/Procfile b/plugins/omi-hacker-news-app/Procfile
new file mode 100644
index 00000000000..8f0921228d8
--- /dev/null
+++ b/plugins/omi-hacker-news-app/Procfile
@@ -0,0 +1 @@
+web: uvicorn main:app --host 0.0.0.0 --port ${PORT:-8080}
diff --git a/plugins/omi-hacker-news-app/README.md b/plugins/omi-hacker-news-app/README.md
new file mode 100644
index 00000000000..e8b52089bba
--- /dev/null
+++ b/plugins/omi-hacker-news-app/README.md
@@ -0,0 +1,44 @@
+# Hacker News Omi Integration
+
+Read Hacker News from Omi with chat tools. This app does not require user auth.
+
+## Tools
+
+- `get_front_page`: returns current front page stories.
+- `search_stories`: searches Hacker News stories by keyword, sorted by relevance or date.
+- `get_discussion`: fetches a story/item plus top-level comments.
+
+## Local Development
+
+```bash
+python -m venv .venv
+source .venv/bin/activate
+pip install -r requirements.txt
+uvicorn main:app --reload --port 8080
+```
+
+Open `http://localhost:8080/.well-known/omi-tools.json` to inspect the Omi tools manifest.
+
+## Deployment
+
+Deploy this folder as a standalone FastAPI service. No environment variables are required.
+
+## Example Requests
+
+```bash
+curl -X POST http://localhost:8080/tools/get_front_page \
+ -H 'Content-Type: application/json' \
+ -d '{"limit": 5}'
+```
+
+```bash
+curl -X POST http://localhost:8080/tools/search_stories \
+ -H 'Content-Type: application/json' \
+ -d '{"query": "open source", "sort_by": "date", "limit": 5}'
+```
+
+```bash
+curl -X POST http://localhost:8080/tools/get_discussion \
+ -H 'Content-Type: application/json' \
+ -d '{"item_id": 8863, "comment_limit": 3}'
+```
diff --git a/plugins/omi-hacker-news-app/main.py b/plugins/omi-hacker-news-app/main.py
new file mode 100644
index 00000000000..d8f219821c2
--- /dev/null
+++ b/plugins/omi-hacker-news-app/main.py
@@ -0,0 +1,262 @@
+"""
+Hacker News Integration App for Omi.
+
+Provides chat tools for reading the Hacker News front page, searching stories,
+and fetching an item with top-level comments.
+"""
+
+from html import unescape
+import re
+from typing import Any, Optional
+
+import httpx
+from fastapi import FastAPI
+from fastapi.responses import HTMLResponse
+from pydantic import BaseModel
+
+
+ALGOLIA_BASE_URL = "https://hn.algolia.com/api/v1"
+REQUEST_TIMEOUT_SECONDS = 10
+MAX_LIMIT = 20
+
+
+app = FastAPI(
+ title="Omi Hacker News Integration",
+ description="Read and search Hacker News from Omi chat tools",
+ version="1.0.0",
+)
+
+
+class ChatToolResponse(BaseModel):
+ """Response model for Omi chat tool endpoints."""
+
+ result: Optional[str] = None
+ error: Optional[str] = None
+
+
+def _clean_text(value: Optional[str]) -> str:
+ """Clean basic HTML entities/tags commonly returned by the HN API."""
+ if not value:
+ return ""
+
+ text = unescape(value)
+ text = re.sub(r"?(p|pre|blockquote|ul|ol|li)[^>]*>", "\n", text, flags=re.IGNORECASE)
+ text = re.sub(r"
", "\n", text, flags=re.IGNORECASE)
+ text = re.sub(r"]*>", "`", text, flags=re.IGNORECASE)
+ text = re.sub(r"", "`", text, flags=re.IGNORECASE)
+ text = re.sub(r"<[^>]+>", "", text)
+ text = re.sub(r"[ \t]+", " ", text)
+ text = re.sub(r"\n{3,}", "\n\n", text)
+ return text.strip()
+
+
+def _safe_limit(limit: Any) -> int:
+ if limit is None or limit == "":
+ return 10
+ try:
+ limit = int(limit)
+ except (TypeError, ValueError):
+ return 10
+ return max(1, min(limit, MAX_LIMIT))
+
+
+async def _request_json(path: str, params: Optional[dict[str, Any]] = None) -> dict[str, Any]:
+ async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS) as client:
+ response = await client.get(f"{ALGOLIA_BASE_URL}{path}", params=params)
+ response.raise_for_status()
+ return response.json()
+
+
+def _format_story(hit: dict[str, Any], index: int) -> str:
+ title = hit.get("title") or hit.get("story_title") or "(untitled)"
+ author = hit.get("author") or "unknown"
+ points = hit.get("points") or 0
+ comments = hit.get("num_comments") or 0
+ object_id = hit.get("objectID") or hit.get("story_id")
+ url = hit.get("url") or hit.get("story_url") or f"https://news.ycombinator.com/item?id={object_id}"
+
+ return (
+ f"{index}. {title}\n"
+ f" by {author} | {points} points | {comments} comments\n"
+ f" {url}\n"
+ f" HN: https://news.ycombinator.com/item?id={object_id}"
+ )
+
+
+@app.get("/")
+async def root():
+ return HTMLResponse(
+ """
+
+
Read the Hacker News front page, search stories, and fetch discussions from Omi.
+No sign-in is required.
+ + + """ + ) + + +@app.get("/health") +async def health(): + return {"status": "ok"} + + +@app.get("/.well-known/omi-tools.json") +async def get_omi_tools_manifest(): + return { + "tools": [ + { + "name": "get_front_page", + "description": "Get current Hacker News front page stories. Use this when the user asks for top tech/startup/programming news or Hacker News headlines.", + "endpoint": "/tools/get_front_page", + "method": "POST", + "parameters": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "description": "Maximum stories to return. Defaults to 10, maximum 20.", + } + }, + "required": [], + }, + "auth_required": False, + "status_message": "Fetching Hacker News front page...", + }, + { + "name": "search_stories", + "description": "Search Hacker News stories and discussions by keyword. Use this when the user mentions a company, project, technology, product, person, or topic and wants relevant HN discussions.", + "endpoint": "/tools/search_stories", + "method": "POST", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query, such as a project name, company, technology, or topic.", + }, + "limit": { + "type": "integer", + "description": "Maximum results to return. Defaults to 10, maximum 20.", + }, + "sort_by": { + "type": "string", + "enum": ["relevance", "date"], + "description": "Sort by relevance or date. Defaults to relevance.", + }, + }, + "required": ["query"], + }, + "auth_required": False, + "status_message": "Searching Hacker News...", + }, + { + "name": "get_discussion", + "description": "Fetch a Hacker News item and its top-level comments. Use this when the user wants details, comments, or discussion for a specific HN item ID.", + "endpoint": "/tools/get_discussion", + "method": "POST", + "parameters": { + "type": "object", + "properties": { + "item_id": { + "type": "integer", + "description": "Hacker News item ID.", + }, + "comment_limit": { + "type": "integer", + "description": "Maximum top-level comments to include. Defaults to 5, maximum 20.", + }, + }, + "required": ["item_id"], + }, + "auth_required": False, + "status_message": "Fetching Hacker News discussion...", + }, + ] + } + + +@app.post("/tools/get_front_page", tags=["chat_tools"], response_model=ChatToolResponse) +async def get_front_page(payload: dict[str, Any]): + try: + limit = _safe_limit(payload.get("limit")) + data = await _request_json("/search", {"tags": "front_page", "hitsPerPage": limit}) + hits = data.get("hits", [])[:limit] + + if not hits: + return ChatToolResponse(result="No Hacker News front page stories were returned.") + + stories = [_format_story(hit, index) for index, hit in enumerate(hits, start=1)] + return ChatToolResponse(result="Current Hacker News front page:\n\n" + "\n\n".join(stories)) + except httpx.HTTPError as exc: + return ChatToolResponse(error=f"Hacker News request failed: {exc}") + + +@app.post("/tools/search_stories", tags=["chat_tools"], response_model=ChatToolResponse) +async def search_stories(payload: dict[str, Any]): + query = (payload.get("query") or "").strip() + if not query: + return ChatToolResponse(error="Missing required field: query") + + try: + limit = _safe_limit(payload.get("limit")) + sort_by = payload.get("sort_by") or "relevance" + endpoint = "/search_by_date" if sort_by == "date" else "/search" + data = await _request_json(endpoint, {"query": query, "tags": "story", "hitsPerPage": limit}) + hits = data.get("hits", [])[:limit] + + if not hits: + return ChatToolResponse(result=f"No Hacker News stories found for '{query}'.") + + stories = [_format_story(hit, index) for index, hit in enumerate(hits, start=1)] + return ChatToolResponse(result=f"Hacker News stories for '{query}':\n\n" + "\n\n".join(stories)) + except httpx.HTTPError as exc: + return ChatToolResponse(error=f"Hacker News search failed: {exc}") + + +@app.post("/tools/get_discussion", tags=["chat_tools"], response_model=ChatToolResponse) +async def get_discussion(payload: dict[str, Any]): + item_id = payload.get("item_id") + if item_id is None: + return ChatToolResponse(error="Missing required field: item_id") + + try: + comment_limit = _safe_limit(payload.get("comment_limit")) + item = await _request_json(f"/items/{int(item_id)}") + + title = item.get("title") or "(untitled)" + author = item.get("author") or "unknown" + points = item.get("points") or 0 + url = item.get("url") or f"https://news.ycombinator.com/item?id={item_id}" + comments = item.get("children", [])[:comment_limit] + + lines = [ + f"{title}", + f"by {author} | {points} points", + url, + f"HN: https://news.ycombinator.com/item?id={item_id}", + ] + + text = _clean_text(item.get("text")) + if text: + lines.extend(["", "Post text:", text]) + + if comments: + lines.append("") + lines.append(f"Top {len(comments)} comments:") + for index, comment in enumerate(comments, start=1): + comment_author = comment.get("author") or "unknown" + comment_text = _clean_text(comment.get("text")) + if comment_text: + lines.append(f"\n{index}. {comment_author}: {comment_text[:1200]}") + else: + lines.extend(["", "No top-level comments returned."]) + + return ChatToolResponse(result="\n".join(lines)) + except (ValueError, TypeError): + return ChatToolResponse(error="item_id must be an integer") + except httpx.HTTPError as exc: + return ChatToolResponse(error=f"Hacker News discussion request failed: {exc}") diff --git a/plugins/omi-hacker-news-app/railway.toml b/plugins/omi-hacker-news-app/railway.toml new file mode 100644 index 00000000000..76203b4a34d --- /dev/null +++ b/plugins/omi-hacker-news-app/railway.toml @@ -0,0 +1,7 @@ +[build] +builder = "NIXPACKS" + +[deploy] +startCommand = "uvicorn main:app --host 0.0.0.0 --port $PORT" +healthcheckPath = "/health" +healthcheckTimeout = 300 diff --git a/plugins/omi-hacker-news-app/requirements.txt b/plugins/omi-hacker-news-app/requirements.txt new file mode 100644 index 00000000000..534f16ca774 --- /dev/null +++ b/plugins/omi-hacker-news-app/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +httpx==0.28.1 +pydantic==2.10.4 diff --git a/plugins/omi-hacker-news-app/runtime.txt b/plugins/omi-hacker-news-app/runtime.txt new file mode 100644 index 00000000000..546f3c8de17 --- /dev/null +++ b/plugins/omi-hacker-news-app/runtime.txt @@ -0,0 +1 @@ +python-3.11.9