#!/usr/bin/env python3 """ Project Monitor Agent Reads project data from wiki, Gitea, and custom URLs. Uses LLM to generate intelligent status reports with analysis and next steps. Posts full report to wiki, returns summary for daily briefing. """ import json import sys from datetime import datetime from urllib import request as urlreq, error as urlerror from shared import ( MT, DASHBOARD_API, WIKI_API, WIKI_TOKEN, api_request, log_run, wiki_headers, ) from llm_client import complete as llm_complete AGENT_ID = "project-monitor" GITEA_API = "http://192.168.1.204:3000/api/v1" GITEA_TOKEN = "a03bb836c58010c4de35ac9c1f242292108c9776" SYSTEM_PROMPT = """You are a project analyst for a home lab and software development environment. You review project documentation, code activity, and issues to produce clear, actionable status reports. Be specific — reference actual document names, commit messages, and issue titles. Be concise but thorough. Prioritize what matters most.""" ANALYSIS_PROMPT = """Review the current state of this project and generate a status report. Project: {project_name} {custom_notes} === PROJECT DOCUMENTATION === {wiki_content} === CODE ACTIVITY (last 7 days) === {gitea_content} === ADDITIONAL CONTEXT === {url_content} Generate a report with these sections: ## Status Summary 2-3 sentences on overall health, momentum, and any blockers. ## Recent Activity What changed recently — commits, doc updates, new issues. ## Open Issues / Blockers Anything stalled, broken, or needing attention. ## Recommended Next Steps 3-5 prioritized, actionable items. ## Ideas & Opportunities Things to consider — improvements, risks, opportunities. """ def parse_wiki_id(raw_id): """Extract a wiki doc/collection ID from a UUID, URL slug, or full URL. Accepts: ae11e785-b110-4a86-985d-804f35bf3d7c (UUID) bVLzs4hbbS (URL slug) https://wiki.jfamily.io/doc/title-bVLzs4hbbS (full URL) https://wiki.jfamily.io/collection/name-bVLzs4hbbS (full collection URL) """ raw_id = raw_id.strip() if "/" in raw_id: # Full URL — grab the last segment after the last dash last_segment = raw_id.rstrip("/").split("/")[-1] # URL format is "title-slug" — the slug is after the last dash if "-" in last_segment: return last_segment.split("-")[-1] return last_segment return raw_id def fetch_wiki_docs(collection_id=None, doc_ids=None): """Fetch wiki documents recursively. Returns combined text content.""" headers = wiki_headers() texts = [] if doc_ids: for raw_id in doc_ids: doc_id = parse_wiki_id(raw_id) if not doc_id: continue try: result = api_request( f"{WIKI_API}/documents.info", data={"id": doc_id}, headers=headers, method="POST", ) doc = result.get("data", {}) texts.append(f"### {doc.get('title', 'Untitled')}\n{doc.get('text', '')[:3000]}") except Exception as e: texts.append(f"### [Error fetching doc {doc_id}: {e}]") elif collection_id: collection_id = parse_wiki_id(collection_id) try: result = api_request( f"{WIKI_API}/documents.list", data={"collectionId": collection_id, "limit": 50}, headers=headers, method="POST", ) for doc in result.get("data", [])[:25]: try: full = api_request( f"{WIKI_API}/documents.info", data={"id": doc["id"]}, headers=headers, method="POST", ) d = full.get("data", {}) texts.append(f"### {d.get('title', 'Untitled')}\n{d.get('text', '')[:2000]}") except Exception: texts.append(f"### {doc.get('title', 'Untitled')}\n[Content not available]") except Exception as e: texts.append(f"[Error fetching collection: {e}]") return "\n\n".join(texts) if texts else "No wiki documentation configured." def fetch_gitea_activity(repo): """Fetch recent commits and open issues from a Gitea repo.""" if not repo: return "No Gitea repo configured." parts = [] headers = {"Authorization": f"token {GITEA_TOKEN}"} # Recent commits try: url = f"{GITEA_API}/repos/{repo}/commits?limit=15&token={GITEA_TOKEN}" req = urlreq.Request(url) with urlreq.urlopen(req, timeout=15) as resp: commits = json.loads(resp.read().decode()) if commits: parts.append("Recent commits:") for c in commits[:10]: msg = c.get("commit", {}).get("message", "").split("\n")[0][:80] date = c.get("commit", {}).get("author", {}).get("date", "")[:10] parts.append(f" - [{date}] {msg}") except Exception as e: parts.append(f"[Could not fetch commits: {e}]") # Open issues try: url = f"{GITEA_API}/repos/{repo}/issues?state=open&limit=20&token={GITEA_TOKEN}" req = urlreq.Request(url) with urlreq.urlopen(req, timeout=15) as resp: issues = json.loads(resp.read().decode()) if issues: parts.append(f"\nOpen issues ({len(issues)}):") for i in issues[:10]: labels = ", ".join(l.get("name", "") for l in i.get("labels", [])) parts.append(f" - #{i['number']}: {i['title']}" + (f" [{labels}]" if labels else "")) except Exception as e: parts.append(f"[Could not fetch issues: {e}]") return "\n".join(parts) if parts else "No recent Gitea activity." def fetch_url_content(urls_text): """Fetch text content from custom URLs.""" if not urls_text: return "No additional URLs configured." urls = [u.strip() for u in urls_text.strip().split("\n") if u.strip()] if not urls: return "No additional URLs configured." parts = [] for url in urls[:5]: try: req = urlreq.Request(url, headers={"User-Agent": "AgentCommandCenter/1.0"}) with urlreq.urlopen(req, timeout=15) as resp: content = resp.read().decode("utf-8", errors="replace")[:2000] parts.append(f"### {url}\n{content}") except Exception as e: parts.append(f"### {url}\n[Error: {e}]") return "\n\n".join(parts) def post_report_to_wiki(report_md, project_name, collection_id): """Post the full report to a wiki collection under 'Project Status Reports' parent.""" if not collection_id: return None from shared import ensure_child_doc, find_child_doc now = datetime.now(MT) headers = wiki_headers() # Ensure "Project Status Reports" parent doc exists in this collection reports_parent_id = ensure_child_doc( None, "Project Status Reports", "Automated project status reports generated by the Project Monitor agent.", collection_id=collection_id, ) # ensure_child_doc with parent_id=None won't find via parentDocumentId match. # Search manually for root-level doc in this collection. if not reports_parent_id: try: search = api_request( f"{WIKI_API}/documents.search", data={"query": "Project Status Reports", "collectionId": collection_id}, headers=headers, method="POST", ) for doc in search.get("data", []): d = doc.get("document", {}) if d.get("title") == "Project Status Reports" and d.get("collectionId") == collection_id: reports_parent_id = d["id"] break except Exception: pass if not reports_parent_id: try: result = api_request( f"{WIKI_API}/documents.create", data={"title": "Project Status Reports", "text": "Automated project status reports.", "collectionId": collection_id, "publish": True}, headers=headers, method="POST", ) reports_parent_id = result["data"]["id"] except Exception: reports_parent_id = None title = f"Project Status — {project_name} — {now.strftime('%Y-%m-%d')}" # Check for existing report today try: search = api_request( f"{WIKI_API}/documents.search", data={"query": title, "collectionId": collection_id}, headers=headers, method="POST", ) for doc in search.get("data", []): if doc.get("document", {}).get("title") == title: api_request( f"{WIKI_API}/documents.update", data={"id": doc["document"]["id"], "text": report_md, "publish": True}, headers=headers, method="POST", ) return doc["document"]["id"] except Exception: pass # Create new under the reports parent try: create_data = {"title": title, "text": report_md, "collectionId": collection_id, "publish": True} if reports_parent_id: create_data["parentDocumentId"] = reports_parent_id result = api_request( f"{WIKI_API}/documents.create", data=create_data, headers=headers, method="POST", ) return result["data"]["id"] except Exception as e: print(f" Warning: could not post report to wiki: {e}", file=sys.stderr) return None def run(config, user_id=None, instance_id=None): """Run the project monitor agent. Config keys: project_name (str): Display name wiki_collection_id (str): Outline collection to read wiki_doc_ids (str): Comma-separated specific doc IDs gitea_repo (str): owner/repo custom_urls (str): Newline-separated URLs custom_notes (str): Free text context report_collection_id (str): Where to post the full report include_in_briefing (str): "true"/"false" Returns: (markdown_section, summary) for daily briefing integration """ project_name = config.get("project_name", "Unknown Project") if not user_id: return f"## {project_name}\n\n*No user context for LLM.*\n", "error: no user_id" print(f" Collecting data for {project_name}...") # Collect data wiki_collection = config.get("wiki_collection_id", "") wiki_docs = config.get("wiki_doc_ids", "") doc_ids = [d.strip() for d in wiki_docs.split(",") if d.strip()] if wiki_docs else None wiki_content = fetch_wiki_docs( collection_id=wiki_collection if not doc_ids else None, doc_ids=doc_ids, ) gitea_content = fetch_gitea_activity(config.get("gitea_repo", "")) url_content = fetch_url_content(config.get("custom_urls", "")) custom_notes = config.get("custom_notes", "") if custom_notes: custom_notes = f"Project context/goals:\n{custom_notes}" # Build prompt prompt = ANALYSIS_PROMPT.format( project_name=project_name, custom_notes=custom_notes, wiki_content=wiki_content[:8000], gitea_content=gitea_content[:3000], url_content=url_content[:3000], ) print(f" Calling LLM for analysis...") try: result = llm_complete(user_id, prompt, system=SYSTEM_PROMPT, max_tokens=2000) except RuntimeError as e: err = str(e) log_run(AGENT_ID, "failed", err=err, instance_id=instance_id) return f"## {project_name}\n\n*LLM error: {err}*\n", f"error: {err}" report_md = result["text"] model = result["model"] tokens_in = result["input_tokens"] tokens_out = result["output_tokens"] print(f" LLM: {model}, {tokens_in}+{tokens_out} tokens") # Build quick links app_url = config.get("app_url", "") gitea_repo = config.get("gitea_repo", "") wiki_collection = config.get("wiki_collection_id", "") links = [] if app_url: links.append(f"[Live App]({app_url})") if wiki_collection: links.append(f"[Wiki](https://wiki.jfamily.io/collection/{wiki_collection})") if gitea_repo: links.append(f"[Gitea](http://192.168.1.204:3000/{gitea_repo})") links_md = " | ".join(links) if links else "" # Post full report to wiki report_collection = config.get("report_collection_id", "") or wiki_collection doc_id = None if report_collection: now = datetime.now(MT) full_report = f"# Project Status — {project_name}\n\n" full_report += f"**{now.strftime('%A, %B %d, %Y')}** | Generated by Project Monitor Agent\n\n" if links_md: full_report += f"{links_md}\n\n" full_report += f"---\n\n{report_md}\n\n" full_report += f"---\n*Model: {model} | Tokens: {tokens_in} in, {tokens_out} out*\n" doc_id = post_report_to_wiki(full_report, project_name, report_collection) if doc_id: print(f" Wiki report posted: {doc_id}") # Extract just the status summary for the briefing summary_lines = [] in_summary = False for line in report_md.split("\n"): if "## Status Summary" in line: in_summary = True continue if in_summary and line.startswith("## "): break if in_summary and line.strip(): summary_lines.append(line.strip()) summary = " ".join(summary_lines)[:200] if summary_lines else report_md[:200] # Briefing section with links section = f"### {project_name}\n\n{summary}\n" if links_md: section += f"\n{links_md}\n" log_run(AGENT_ID, "success", output=f"{project_name}: {summary[:100]}", instance_id=instance_id, metadata={ "project": project_name, "model": model, "tokens_in": tokens_in, "tokens_out": tokens_out, "wiki_report_id": doc_id, }) return section, summary if __name__ == "__main__": import argparse parser = argparse.ArgumentParser() parser.add_argument("--user-id", type=int, required=True) parser.add_argument("--instance-id", type=int, default=0) parser.add_argument("--project-name", required=True) parser.add_argument("--wiki-collection", default="") parser.add_argument("--gitea-repo", default="") args = parser.parse_args() config = { "project_name": args.project_name, "wiki_collection_id": args.wiki_collection, "gitea_repo": args.gitea_repo, } section, summary = run(config, user_id=args.user_id, instance_id=args.instance_id) print(section) print(f"\nSummary: {summary}")