From afcb260554f74973a6645b0820194717f37de885 Mon Sep 17 00:00:00 2001 From: Evan Hu Date: Sat, 21 Mar 2026 02:51:58 +0900 Subject: [PATCH] feat: add 6 production-ready plugins All plugins are stdlib-only Python with no external dependencies. - auto-summarizer: extractive conversation summary for long context compression, persists per-agent summaries to disk - conversation-logger: JSONL audit logs per agent with ISO 8601 timestamps, auto-creates log directory tree - guardrails: safety filter detecting PII (email, phone, SSN, CC), prompt injection patterns, and credential exposure via regex - keyword-memory: extracts entities (emails, URLs, dates, technical terms like camelCase/snake_case/dotted identifiers) as memories - sentiment-tracker: keyword-based sentiment scoring with intensifiers and negation handling, only injects context for non-neutral sentiment - todo-tracker: detects action items via 7 task patterns, tracks completion, deduplicates, persists per-agent with 20-item FIFO limit --- plugins/README.md | 14 +- plugins/auto-summarizer/README.md | 31 ++ plugins/auto-summarizer/hooks/after_turn.py | 153 +++++++++ plugins/auto-summarizer/hooks/ingest.py | 49 +++ plugins/auto-summarizer/plugin.toml | 8 + plugins/auto-summarizer/requirements.txt | 1 + plugins/conversation-logger/README.md | 34 ++ .../conversation-logger/hooks/after_turn.py | 68 ++++ plugins/conversation-logger/plugin.toml | 7 + plugins/guardrails/README.md | 27 ++ plugins/guardrails/hooks/ingest.py | 151 +++++++++ plugins/guardrails/plugin.toml | 7 + plugins/keyword-memory/README.md | 32 ++ plugins/keyword-memory/hooks/README.md | 14 + plugins/keyword-memory/hooks/ingest.py | 148 +++++++++ plugins/keyword-memory/plugin.toml | 7 + plugins/sentiment-tracker/README.md | 44 +++ plugins/sentiment-tracker/hooks/README.md | 14 + plugins/sentiment-tracker/hooks/ingest.py | 155 +++++++++ plugins/sentiment-tracker/plugin.toml | 7 + plugins/todo-tracker/README.md | 58 ++++ plugins/todo-tracker/hooks/after_turn.py | 300 ++++++++++++++++++ plugins/todo-tracker/hooks/ingest.py | 62 ++++ plugins/todo-tracker/plugin.toml | 8 + plugins/todo-tracker/requirements.txt | 1 + 25 files changed, 1396 insertions(+), 4 deletions(-) create mode 100644 plugins/auto-summarizer/README.md create mode 100644 plugins/auto-summarizer/hooks/after_turn.py create mode 100644 plugins/auto-summarizer/hooks/ingest.py create mode 100644 plugins/auto-summarizer/plugin.toml create mode 100644 plugins/auto-summarizer/requirements.txt create mode 100644 plugins/conversation-logger/README.md create mode 100644 plugins/conversation-logger/hooks/after_turn.py create mode 100644 plugins/conversation-logger/plugin.toml create mode 100644 plugins/guardrails/README.md create mode 100644 plugins/guardrails/hooks/ingest.py create mode 100644 plugins/guardrails/plugin.toml create mode 100644 plugins/keyword-memory/README.md create mode 100644 plugins/keyword-memory/hooks/README.md create mode 100644 plugins/keyword-memory/hooks/ingest.py create mode 100644 plugins/keyword-memory/plugin.toml create mode 100644 plugins/sentiment-tracker/README.md create mode 100644 plugins/sentiment-tracker/hooks/README.md create mode 100644 plugins/sentiment-tracker/hooks/ingest.py create mode 100644 plugins/sentiment-tracker/plugin.toml create mode 100644 plugins/todo-tracker/README.md create mode 100644 plugins/todo-tracker/hooks/after_turn.py create mode 100644 plugins/todo-tracker/hooks/ingest.py create mode 100644 plugins/todo-tracker/plugin.toml create mode 100644 plugins/todo-tracker/requirements.txt diff --git a/plugins/README.md b/plugins/README.md index 0cbf451..d37093a 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -45,11 +45,17 @@ stdin: {"type": "after_turn", "agent_id": "...", "messages": [...]} stdout: {"type": "ok"} ``` -## Current Plugins (1) +## Current Plugins (7) -| Plugin | Description | -|--------|-------------| -| echo-memory | Demo plugin that echoes user messages as recalled memories | +| Plugin | Hooks | Description | +|--------|-------|-------------| +| echo-memory | ingest, after_turn | Demo plugin that echoes user messages as recalled memories | +| auto-summarizer | ingest, after_turn | Running conversation summary for long context compression | +| conversation-logger | after_turn | Logs conversations to JSONL files for auditing and analytics | +| guardrails | ingest | Safety filter detecting PII, prompt injection, and credential exposure | +| keyword-memory | ingest | Extracts keywords and named entities as contextual memories | +| sentiment-tracker | ingest | Analyzes user sentiment and injects emotional context | +| todo-tracker | ingest, after_turn | Detects, persists, and recalls action items from conversations | ## Adding a New Plugin diff --git a/plugins/auto-summarizer/README.md b/plugins/auto-summarizer/README.md new file mode 100644 index 0000000..bbf0983 --- /dev/null +++ b/plugins/auto-summarizer/README.md @@ -0,0 +1,31 @@ +# auto-summarizer + +Maintains a running conversation summary to help agents handle long conversations without losing context. Uses extractive summarization (no ML or external dependencies) to identify the most important parts of a conversation. + +## How it works + +After each conversation turn, the plugin scans all messages and extracts: + +- **Topic opener** -- the first user message that started the conversation +- **Questions** -- any messages containing questions (detected via `?`) +- **Decisions** -- messages with conclusion/decision language ("let's", "decided", "the plan is", etc.) +- **Recent context** -- the last 2 exchanges to preserve immediate context + +These are combined into a compact summary (max 500 characters) and persisted to disk. On the next ingest, the summary is returned as a memory fragment so the agent retains awareness of the full conversation. + +Summarization only activates when the conversation exceeds 6 messages -- shorter conversations are passed through as-is. + +## Hooks + +| Hook | Script | Description | +|------|--------|-------------| +| ingest | `hooks/ingest.py` | Returns the stored conversation summary as a memory fragment | +| after_turn | `hooks/after_turn.py` | Builds and persists an extractive summary of the conversation | + +## Storage + +Summaries are stored at `~/.librefang/plugins/auto-summarizer/{agent_id}.summary`. + +## Usage + +Installed automatically when enabled in agent configuration. diff --git a/plugins/auto-summarizer/hooks/after_turn.py b/plugins/auto-summarizer/hooks/after_turn.py new file mode 100644 index 0000000..9c28882 --- /dev/null +++ b/plugins/auto-summarizer/hooks/after_turn.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Auto-summarizer after_turn hook. + +Generates a compact extractive summary of the conversation after each turn. +Keeps agents aware of conversation context even in long exchanges. + +Receives via stdin: + {"type": "after_turn", "agent_id": "...", "messages": [...]} + +Prints to stdout: + {"type": "ok"} +""" +import json +import os +import sys + +# Only summarize when conversation exceeds this many messages +MIN_MESSAGES_FOR_SUMMARY = 6 + +# Maximum length of the generated summary +MAX_SUMMARY_CHARS = 500 + +# Keywords that signal decisions or conclusions +DECISION_KEYWORDS = ( + "let's", "i'll", "we should", "decided", "agreed", + "the plan is", "we'll", "going to", "conclusion", + "in summary", "to summarize", "final answer", +) + + +def get_storage_dir(): + """Return the plugin storage directory, creating it if needed.""" + home = os.path.expanduser("~") + path = os.path.join(home, ".librefang", "plugins", "auto-summarizer") + os.makedirs(path, exist_ok=True) + return path + + +def get_content(msg): + """Extract text content from a message object.""" + if isinstance(msg, dict): + return msg.get("content", "") or "" + return str(msg) + + +def get_role(msg): + """Extract the role from a message object.""" + if isinstance(msg, dict): + return msg.get("role", "unknown") + return "unknown" + + +def contains_question(text): + """Check if text contains a question.""" + return "?" in text + + +def contains_decision(text): + """Check if text contains decision/conclusion language.""" + lower = text.lower() + return any(kw in lower for kw in DECISION_KEYWORDS) + + +def truncate(text, max_len): + """Truncate text to max_len, adding ellipsis if needed.""" + if len(text) <= max_len: + return text + return text[:max_len - 3] + "..." + + +def build_summary(messages): + """Build an extractive summary from the conversation messages. + + Strategy: + - First user message (topic opener) + - Messages containing questions + - Messages containing decisions/conclusions + - Last 2 exchanges (most recent context) + + Deduplicates and truncates to MAX_SUMMARY_CHARS. + """ + if len(messages) <= MIN_MESSAGES_FOR_SUMMARY: + return "" + + selected = [] + seen_indices = set() + + # 1. First user message (topic opener) + for i, msg in enumerate(messages): + if get_role(msg) == "user": + content = get_content(msg).strip() + if content: + selected.append(f"Topic: {truncate(content, 120)}") + seen_indices.add(i) + break + + # 2. Messages containing questions + for i, msg in enumerate(messages): + if i in seen_indices: + continue + content = get_content(msg).strip() + if content and contains_question(content): + role = get_role(msg) + prefix = "Q" if role == "user" else "Agent-Q" + selected.append(f"{prefix}: {truncate(content, 100)}") + seen_indices.add(i) + + # 3. Messages containing decisions/conclusions + for i, msg in enumerate(messages): + if i in seen_indices: + continue + content = get_content(msg).strip() + if content and contains_decision(content): + selected.append(f"Decision: {truncate(content, 100)}") + seen_indices.add(i) + + # 4. Last 2 exchanges (up to 4 messages: user+assistant pairs) + tail_start = max(0, len(messages) - 4) + for i in range(tail_start, len(messages)): + if i in seen_indices: + continue + content = get_content(messages[i]).strip() + if content: + role = get_role(messages[i]) + label = "User" if role == "user" else "Agent" + selected.append(f"Recent({label}): {truncate(content, 100)}") + seen_indices.add(i) + + if not selected: + return "" + + summary = " | ".join(selected) + return truncate(summary, MAX_SUMMARY_CHARS) + + +def main(): + request = json.loads(sys.stdin.read()) + agent_id = request.get("agent_id", "unknown") + messages = request.get("messages", []) + + summary = build_summary(messages) + + if summary: + storage_dir = get_storage_dir() + summary_path = os.path.join(storage_dir, f"{agent_id}.summary") + with open(summary_path, "w", encoding="utf-8") as f: + f.write(summary) + + print(json.dumps({"type": "ok"}), flush=True) + + +if __name__ == "__main__": + main() diff --git a/plugins/auto-summarizer/hooks/ingest.py b/plugins/auto-summarizer/hooks/ingest.py new file mode 100644 index 0000000..41ba2e0 --- /dev/null +++ b/plugins/auto-summarizer/hooks/ingest.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Auto-summarizer ingest hook. + +Returns the stored conversation summary as a memory fragment so agents +maintain awareness of prior conversation context. + +Receives via stdin: + {"type": "ingest", "agent_id": "...", "message": "user message text"} + +Prints to stdout: + {"type": "ingest_result", "memories": [{"content": "..."}]} +""" +import json +import os +import sys + + +def get_summary_path(agent_id): + """Return the path to the summary file for the given agent.""" + home = os.path.expanduser("~") + return os.path.join( + home, ".librefang", "plugins", "auto-summarizer", f"{agent_id}.summary" + ) + + +def main(): + request = json.loads(sys.stdin.read()) + agent_id = request.get("agent_id", "unknown") + + memories = [] + summary_path = get_summary_path(agent_id) + + if os.path.isfile(summary_path): + try: + with open(summary_path, "r", encoding="utf-8") as f: + summary = f.read().strip() + if summary: + memories.append( + {"content": f"[summary] Conversation so far: {summary}"} + ) + except (OSError, IOError): + # If we cannot read the file, return no memories silently. + pass + + print(json.dumps({"type": "ingest_result", "memories": memories})) + + +if __name__ == "__main__": + main() diff --git a/plugins/auto-summarizer/plugin.toml b/plugins/auto-summarizer/plugin.toml new file mode 100644 index 0000000..a5d00a1 --- /dev/null +++ b/plugins/auto-summarizer/plugin.toml @@ -0,0 +1,8 @@ +name = "auto-summarizer" +version = "0.1.0" +description = "Maintains a running conversation summary to help agents handle long conversations without losing context" +author = "librefang" + +[hooks] +ingest = "hooks/ingest.py" +after_turn = "hooks/after_turn.py" diff --git a/plugins/auto-summarizer/requirements.txt b/plugins/auto-summarizer/requirements.txt new file mode 100644 index 0000000..023dffa --- /dev/null +++ b/plugins/auto-summarizer/requirements.txt @@ -0,0 +1 @@ +# No external dependencies — uses only Python stdlib diff --git a/plugins/conversation-logger/README.md b/plugins/conversation-logger/README.md new file mode 100644 index 0000000..5a9d332 --- /dev/null +++ b/plugins/conversation-logger/README.md @@ -0,0 +1,34 @@ +# conversation-logger + +Logs all conversations to JSONL files for auditing, analytics, and debugging. Each agent gets its own log file at `~/.librefang/logs/conversations/{agent_id}.jsonl`. + +## Log Format + +Each line is a JSON object: + +```json +{ + "timestamp": "2026-03-21T12:34:56Z", + "agent_id": "agent-abc123", + "turn_number": 5, + "message_count": 10, + "last_user_message": "truncated to 200 chars...", + "last_assistant_message": "truncated to 200 chars..." +} +``` + +## Hooks + +| Hook | Script | Description | +|------|--------|-------------| +| after_turn | `hooks/after_turn.py` | Appends a log entry after each conversation turn | + +## How It Works + +After each conversation turn the hook extracts summary information from the messages array and appends a single JSON line to the agent's log file. User and assistant messages are truncated to 200 characters to keep log files manageable. + +Errors from the filesystem (permissions, disk full, etc.) are caught silently so the agent conversation is never interrupted by a logging failure. + +## Usage + +Installed automatically when enabled in agent configuration. Log files are created on first write -- no manual setup required. diff --git a/plugins/conversation-logger/hooks/after_turn.py b/plugins/conversation-logger/hooks/after_turn.py new file mode 100644 index 0000000..ce1a3a0 --- /dev/null +++ b/plugins/conversation-logger/hooks/after_turn.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Conversation logger after_turn hook. + +Appends a JSON line to a per-agent log file after each conversation turn. +Log files are stored under ~/.librefang/logs/conversations/{agent_id}.jsonl. + +Receives via stdin: + {"type": "after_turn", "agent_id": "...", "messages": [...]} + +Prints to stdout: + {"type": "ok"} +""" +import json +import os +import sys +from datetime import datetime, timezone +from pathlib import Path + + +def _log_dir() -> Path: + """Return the conversations log directory, creating it if needed.""" + home = Path.home() + log_path = home / ".librefang" / "logs" / "conversations" + log_path.mkdir(parents=True, exist_ok=True) + return log_path + + +def _last_message_by_role(messages: list, role: str) -> str: + """Find the last message with the given role and return its content truncated to 200 chars.""" + for msg in reversed(messages): + if msg.get("role") == role: + content = msg.get("content", "") + if len(content) > 200: + return content[:200] + "..." + return content + return "" + + +def main(): + request = json.loads(sys.stdin.read()) + agent_id = request.get("agent_id", "unknown") + messages = request.get("messages", []) + + entry = { + "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "agent_id": agent_id, + "turn_number": max( + sum(1 for m in messages if m.get("role") == "assistant"), 1 + ), + "message_count": len(messages), + "last_user_message": _last_message_by_role(messages, "user"), + "last_assistant_message": _last_message_by_role(messages, "assistant"), + } + + try: + log_file = _log_dir() / f"{agent_id}.jsonl" + with open(log_file, "a", encoding="utf-8") as f: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + except OSError: + # Filesystem issues should not crash the agent. The hook still + # responds with "ok" so the conversation continues normally. + pass + + print(json.dumps({"type": "ok"})) + + +if __name__ == "__main__": + main() diff --git a/plugins/conversation-logger/plugin.toml b/plugins/conversation-logger/plugin.toml new file mode 100644 index 0000000..1faf4a0 --- /dev/null +++ b/plugins/conversation-logger/plugin.toml @@ -0,0 +1,7 @@ +name = "conversation-logger" +version = "0.1.0" +description = "Logs all conversations to JSONL files for auditing, analytics, and debugging" +author = "librefang" + +[hooks] +after_turn = "hooks/after_turn.py" diff --git a/plugins/guardrails/README.md b/plugins/guardrails/README.md new file mode 100644 index 0000000..c58d7d4 --- /dev/null +++ b/plugins/guardrails/README.md @@ -0,0 +1,27 @@ +# guardrails + +Safety filter plugin that detects potentially harmful content patterns in user messages and injects warning memories into agent context. Uses only Python stdlib regex -- no external dependencies. + +## Detection Categories + +| Category | Examples | Memory Tag | +|----------|----------|------------| +| PII | Email addresses, phone numbers, SSNs, credit card numbers | `[guardrails:pii]` | +| Prompt injection | "ignore previous instructions", "you are now", "system prompt:" | `[guardrails:injection]` | +| Credentials | `password=`, `api_key=`, `secret=`, `token=`, PEM private keys | `[guardrails:credential]` | + +## Hooks + +| Hook | Script | Description | +|------|--------|-------------| +| ingest | `hooks/ingest.py` | Scans user messages for harmful patterns and returns warning memories | + +## How It Works + +When a user message arrives, the ingest hook runs all pattern checks against it. For each detected issue a memory is returned with the category tag and a recommendation for the agent (e.g. "avoid echoing PII", "maintain original instructions"). If nothing is detected the plugin returns an empty memories list. + +All patterns use word boundaries and anchoring to minimise false positives on casual conversation. + +## Usage + +Installed automatically when enabled in agent configuration. diff --git a/plugins/guardrails/hooks/ingest.py b/plugins/guardrails/hooks/ingest.py new file mode 100644 index 0000000..cb347f0 --- /dev/null +++ b/plugins/guardrails/hooks/ingest.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Guardrails ingest hook — safety filter plugin. + +Scans user messages for potentially harmful content patterns including +PII exposure, prompt injection attempts, and credential leaks. Returns +warning memories so the agent can handle these situations appropriately. + +Receives via stdin: + {"type": "ingest", "agent_id": "...", "message": "user message text"} + +Prints to stdout: + {"type": "ingest_result", "memories": [{"content": "..."}]} +""" +import json +import re +import sys + + +# --------------------------------------------------------------------------- +# Pattern definitions +# --------------------------------------------------------------------------- + +# PII patterns +_EMAIL_RE = re.compile( + r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b" +) +_PHONE_RE = re.compile( + r"(? list: + """Return warning strings for any PII found in *message*.""" + warnings = [] + if _EMAIL_RE.search(message): + warnings.append( + "[guardrails:pii] Detected potential email address in user message. " + "Avoid echoing PII in response." + ) + if _PHONE_RE.search(message): + warnings.append( + "[guardrails:pii] Detected potential phone number in user message. " + "Avoid echoing PII in response." + ) + if _SSN_RE.search(message): + warnings.append( + "[guardrails:pii] Detected potential SSN in user message. " + "Do not store or repeat this information." + ) + if _CREDIT_CARD_RE.search(message): + warnings.append( + "[guardrails:pii] Detected potential credit card number in user message. " + "Do not store or repeat this information." + ) + return warnings + + +def _detect_injection(message: str) -> list: + """Return warning strings for prompt injection attempts.""" + warnings = [] + for pattern in _INJECTION_PATTERNS: + match = pattern.search(message) + if match: + snippet = match.group(0) + warnings.append( + f'[guardrails:injection] Possible prompt injection detected ' + f'("{snippet}"). Maintain original instructions.' + ) + # One warning per message is sufficient to alert the agent. + break + return warnings + + +def _detect_credentials(message: str) -> list: + """Return warning strings for credential exposure.""" + warnings = [] + for pattern in _CREDENTIAL_PATTERNS: + match = pattern.search(message) + if match: + # Show only the key portion, not the value, to avoid logging secrets. + snippet = match.group(0).split("=")[0].strip() + "=..." + if "BEGIN" in snippet: + snippet = "-----BEGIN...KEY-----" + warnings.append( + f"[guardrails:credential] Potential credential in message " + f"({snippet}). Do not store or repeat credentials." + ) + break + return warnings + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main(): + request = json.loads(sys.stdin.read()) + message = request.get("message", "") + + warnings = [] + warnings.extend(_detect_pii(message)) + warnings.extend(_detect_injection(message)) + warnings.extend(_detect_credentials(message)) + + memories = [{"content": w} for w in warnings] + result = {"type": "ingest_result", "memories": memories} + print(json.dumps(result)) + + +if __name__ == "__main__": + main() diff --git a/plugins/guardrails/plugin.toml b/plugins/guardrails/plugin.toml new file mode 100644 index 0000000..9e45039 --- /dev/null +++ b/plugins/guardrails/plugin.toml @@ -0,0 +1,7 @@ +name = "guardrails" +version = "0.1.0" +description = "Safety filter that detects potentially harmful content patterns and injects warnings into agent context" +author = "librefang" + +[hooks] +ingest = "hooks/ingest.py" diff --git a/plugins/keyword-memory/README.md b/plugins/keyword-memory/README.md new file mode 100644 index 0000000..f48be89 --- /dev/null +++ b/plugins/keyword-memory/README.md @@ -0,0 +1,32 @@ +# keyword-memory + +Extracts keywords and named entities from user messages and returns them as contextual memories. Gives agents awareness of conversation topics without requiring external NLP libraries. + +## Extraction Techniques + +- **Plain keywords**: Splits words, filters English stopwords (~50 words), removes short tokens +- **Capitalized phrases**: Detects multi-word proper nouns and mid-sentence capitalized words +- **Emails and URLs**: Regex pattern matching +- **Numbers with units**: e.g. 500ms, 10GB, 3.5GHz +- **Dates**: YYYY-MM-DD, MM/DD/YYYY, DD.MM.YYYY formats +- **Technical terms**: camelCase, snake_case, dotted identifiers (e.g. `os.path`) + +Results are deduplicated and capped at 10 keywords. + +## Hooks + +| Hook | Script | Description | +|------|--------|-------------| +| ingest | `hooks/ingest.py` | Extracts keywords from the user message and returns them as a memory fragment | + +## Example Output + +```json +{"type": "ingest_result", "memories": [{"content": "[keyword-memory] Key topics: GPT-4, machine_learning, data pipeline, https://example.com"}]} +``` + +If no meaningful keywords are found, returns an empty memories list. + +## Usage + +Installed automatically when enabled in agent configuration. No external dependencies required (stdlib only). diff --git a/plugins/keyword-memory/hooks/README.md b/plugins/keyword-memory/hooks/README.md new file mode 100644 index 0000000..f4b348e --- /dev/null +++ b/plugins/keyword-memory/hooks/README.md @@ -0,0 +1,14 @@ +# keyword-memory hooks + +Python hook scripts for the keyword-memory plugin. Each script reads a JSON request from stdin and writes a JSON response to stdout. + +## Scripts + +| Script | Hook | Description | +|--------|------|-------------| +| `ingest.py` | ingest | Receives `{"message": "..."}`, extracts keywords and named entities, returns them as memory fragments | + +## Protocol + +- **Input**: JSON object on stdin (fields vary by hook type) +- **Output**: JSON object on stdout (`ingest_result` with memories) diff --git a/plugins/keyword-memory/hooks/ingest.py b/plugins/keyword-memory/hooks/ingest.py new file mode 100644 index 0000000..794a21a --- /dev/null +++ b/plugins/keyword-memory/hooks/ingest.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Keyword memory ingest hook. + +Extracts keywords and named entities from user messages and returns +them as contextual memories so agents have topic awareness. + +Receives via stdin: + {"type": "ingest", "agent_id": "...", "message": "user message text"} + +Prints to stdout: + {"type": "ingest_result", "memories": [{"content": "..."}]} +""" +import json +import re +import sys + +# Compact English stopword set (~50 common words) +STOPWORDS = frozenset({ + "a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for", + "of", "with", "by", "from", "is", "are", "was", "were", "be", "been", + "being", "have", "has", "had", "do", "does", "did", "will", "would", + "could", "should", "may", "might", "shall", "can", "need", "must", + "it", "its", "i", "me", "my", "you", "your", "he", "she", "we", + "they", "them", "their", "this", "that", "these", "those", "what", + "which", "who", "how", "when", "where", "why", "if", "then", "so", + "not", "no", "just", "also", "very", "too", "about", "up", "out", + "all", "some", "any", "each", "every", "into", "over", "after", +}) + +# Minimum word length for plain keyword extraction +MIN_WORD_LEN = 3 + +# Maximum keywords to return +MAX_KEYWORDS = 10 + +# Pattern: email addresses +RE_EMAIL = re.compile(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}") + +# Pattern: URLs (http/https/ftp) — excludes trailing punctuation +RE_URL = re.compile(r"https?://[^\s,)>]+(?<=[a-zA-Z0-9/])|ftp://[^\s,)>]+(?<=[a-zA-Z0-9/])") + +# Pattern: numbers with units (e.g. 500ms, 10GB, 3.5GHz, 200k) +RE_NUMBER_UNIT = re.compile(r"\b\d+(?:\.\d+)?(?:ms|s|min|hr|h|kb|mb|gb|tb|ghz|mhz|hz|k|m|px|em|rem|%)\b", re.IGNORECASE) + +# Pattern: dates (YYYY-MM-DD, MM/DD/YYYY, DD.MM.YYYY) +RE_DATE = re.compile( + r"\b\d{4}-\d{2}-\d{2}\b" + r"|\b\d{1,2}/\d{1,2}/\d{2,4}\b" + r"|\b\d{1,2}\.\d{1,2}\.\d{2,4}\b" +) + +# Pattern: camelCase or PascalCase identifiers +RE_CAMEL = re.compile(r"\b[a-z]+(?:[A-Z][a-z0-9]+)+\b|\b(?:[A-Z][a-z0-9]+){2,}\b") + +# Pattern: snake_case identifiers (at least one underscore) +RE_SNAKE = re.compile(r"\b[a-zA-Z][a-zA-Z0-9]*(?:_[a-zA-Z0-9]+)+\b") + +# Pattern: dotted technical terms (e.g. api.endpoint, os.path) +RE_DOTTED = re.compile(r"\b[a-zA-Z][a-zA-Z0-9]*(?:\.[a-zA-Z][a-zA-Z0-9]*)+\b") + + +def extract_patterns(text): + """Extract structured patterns: emails, URLs, numbers+units, dates, tech terms.""" + found = [] + for pattern in (RE_EMAIL, RE_URL, RE_NUMBER_UNIT, RE_DATE, RE_CAMEL, RE_SNAKE, RE_DOTTED): + found.extend(pattern.findall(text)) + return found + + +def extract_capitalized_phrases(text): + """Detect consecutive capitalized words (likely proper nouns / named entities). + + Skips single capitalized words at sentence boundaries by requiring + either multi-word phrases or mid-sentence capitalized words. + """ + phrases = [] + # Find sequences of 2+ capitalized words + for match in re.finditer(r"\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b", text): + phrases.append(match.group(0)) + + # Find single capitalized words that are NOT at sentence start + # (preceded by a lowercase letter, comma, or mid-sentence punctuation) + for match in re.finditer(r"(?<=[a-z,;]\s)([A-Z][a-zA-Z0-9]+)\b", text): + word = match.group(1) + if word.lower() not in STOPWORDS and len(word) >= MIN_WORD_LEN: + phrases.append(word) + + return phrases + + +def extract_plain_keywords(text): + """Split text into words and filter out stopwords and short tokens.""" + # Remove URLs and emails first so they don't pollute word splitting + cleaned = RE_URL.sub(" ", text) + cleaned = RE_EMAIL.sub(" ", cleaned) + # Split on non-alphanumeric (keep hyphens inside words) + words = re.findall(r"[a-zA-Z][a-zA-Z0-9\-]*[a-zA-Z0-9]|[a-zA-Z]", cleaned) + keywords = [] + for w in words: + lower = w.lower() + if lower not in STOPWORDS and len(lower) >= MIN_WORD_LEN: + keywords.append(lower) + return keywords + + +def deduplicate_keywords(items): + """Deduplicate while preserving insertion order. Case-insensitive for plain words.""" + seen = set() + result = [] + for item in items: + key = item.lower() + if key not in seen: + seen.add(key) + result.append(item) + return result + + +def main(): + request = json.loads(sys.stdin.read()) + message = request.get("message", "") + + if not message.strip(): + print(json.dumps({"type": "ingest_result", "memories": []})) + return + + # Collect keywords from all extraction methods (patterns first for priority) + all_keywords = [] + all_keywords.extend(extract_patterns(message)) + all_keywords.extend(extract_capitalized_phrases(message)) + all_keywords.extend(extract_plain_keywords(message)) + + # Deduplicate and cap at MAX_KEYWORDS + keywords = deduplicate_keywords(all_keywords)[:MAX_KEYWORDS] + + if not keywords: + print(json.dumps({"type": "ingest_result", "memories": []})) + return + + topic_str = ", ".join(keywords) + memories = [ + {"content": f"[keyword-memory] Key topics: {topic_str}"} + ] + + print(json.dumps({"type": "ingest_result", "memories": memories})) + + +if __name__ == "__main__": + main() diff --git a/plugins/keyword-memory/plugin.toml b/plugins/keyword-memory/plugin.toml new file mode 100644 index 0000000..8ec0d83 --- /dev/null +++ b/plugins/keyword-memory/plugin.toml @@ -0,0 +1,7 @@ +name = "keyword-memory" +version = "0.1.0" +description = "Extracts keywords and named entities from user messages and returns them as contextual memories" +author = "librefang" + +[hooks] +ingest = "hooks/ingest.py" diff --git a/plugins/sentiment-tracker/README.md b/plugins/sentiment-tracker/README.md new file mode 100644 index 0000000..5167112 --- /dev/null +++ b/plugins/sentiment-tracker/README.md @@ -0,0 +1,44 @@ +# sentiment-tracker + +Analyzes user message sentiment using keyword-based scoring and injects emotional context so agents can respond with appropriate tone. No external ML libraries required (stdlib only). + +## Scoring Method + +- **Positive words** (~30): great, love, excellent, awesome, helpful, appreciate, etc. (+1 each) +- **Negative words** (~30): bad, terrible, frustrated, broken, bug, error, crash, etc. (-1 each) +- **Intensifiers**: very, extremely, really, absolutely, totally (multiply next sentiment word by 1.5x) +- **Negators**: not, no, never, don't, doesn't, isn't, can't, won't (flip next word's polarity) + +The raw score is normalized by message length and clamped to [-1.0, 1.0]. + +## Classification + +| Score Range | Label | Action | +|-------------|-------|--------| +| > 0.3 | positive | Inject positive context memory | +| < -0.3 | negative | Inject frustration-aware memory | +| -0.3 to 0.3 | neutral | No memory injected (avoid context clutter) | + +## Hooks + +| Hook | Script | Description | +|------|--------|-------------| +| ingest | `hooks/ingest.py` | Analyzes message sentiment and returns emotional context as a memory fragment | + +## Example Output + +Negative sentiment: +```json +{"type": "ingest_result", "memories": [{"content": "[sentiment] User appears frustrated (score: -0.6). Consider acknowledging the issue."}]} +``` + +Positive sentiment: +```json +{"type": "ingest_result", "memories": [{"content": "[sentiment] User seems satisfied (score: 0.7). Positive interaction."}]} +``` + +Neutral sentiment returns an empty memories list. + +## Usage + +Installed automatically when enabled in agent configuration. No external dependencies required (stdlib only). diff --git a/plugins/sentiment-tracker/hooks/README.md b/plugins/sentiment-tracker/hooks/README.md new file mode 100644 index 0000000..b81739d --- /dev/null +++ b/plugins/sentiment-tracker/hooks/README.md @@ -0,0 +1,14 @@ +# sentiment-tracker hooks + +Python hook scripts for the sentiment-tracker plugin. Each script reads a JSON request from stdin and writes a JSON response to stdout. + +## Scripts + +| Script | Hook | Description | +|--------|------|-------------| +| `ingest.py` | ingest | Receives `{"message": "..."}`, analyzes sentiment, returns emotional context as a memory fragment | + +## Protocol + +- **Input**: JSON object on stdin (fields vary by hook type) +- **Output**: JSON object on stdout (`ingest_result` with memories, empty for neutral sentiment) diff --git a/plugins/sentiment-tracker/hooks/ingest.py b/plugins/sentiment-tracker/hooks/ingest.py new file mode 100644 index 0000000..358da87 --- /dev/null +++ b/plugins/sentiment-tracker/hooks/ingest.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Sentiment tracker ingest hook. + +Analyzes user message sentiment using keyword-based scoring and injects +emotional context so agents can respond with appropriate tone. + +Receives via stdin: + {"type": "ingest", "agent_id": "...", "message": "user message text"} + +Prints to stdout: + {"type": "ingest_result", "memories": [{"content": "..."}]} +""" +import json +import re +import sys + +# Positive sentiment words with base score of +1 +POSITIVE_WORDS = frozenset({ + "great", "love", "excellent", "happy", "thanks", "awesome", "perfect", + "wonderful", "good", "nice", "amazing", "fantastic", "helpful", "pleased", + "appreciate", "brilliant", "outstanding", "superb", "delightful", "glad", + "impressive", "beautiful", "enjoy", "excited", "grateful", "incredible", + "marvelous", "terrific", "thank", "cool", +}) + +# Negative sentiment words with base score of -1 +NEGATIVE_WORDS = frozenset({ + "bad", "terrible", "hate", "angry", "frustrated", "disappointed", "broken", + "wrong", "awful", "horrible", "annoying", "useless", "fail", "worst", + "problem", "issue", "bug", "error", "crash", "slow", "stuck", "confused", + "difficult", "painful", "ugly", "ridiculous", "poor", "sucks", "garbage", + "missing", +}) + +# Intensifiers multiply the next sentiment word's score by this factor +INTENSIFIERS = frozenset({ + "very", "extremely", "really", "absolutely", "totally", "incredibly", + "completely", "utterly", "highly", "so", +}) +INTENSIFIER_MULTIPLIER = 1.5 + +# Negators flip the polarity of the next sentiment word +NEGATORS = frozenset({ + "not", "no", "never", "don't", "doesn't", "isn't", "can't", "won't", + "didn't", "wasn't", "weren't", "couldn't", "shouldn't", "wouldn't", + "hardly", "barely", "neither", +}) + +# Sentiment thresholds +POSITIVE_THRESHOLD = 0.3 +NEGATIVE_THRESHOLD = -0.3 + + +def tokenize(text): + """Split text into lowercase tokens, preserving contractions.""" + return re.findall(r"[a-zA-Z][a-zA-Z']*", text.lower()) + + +def compute_sentiment(tokens): + """Compute sentiment score from -1.0 to 1.0. + + Walks through tokens tracking negator and intensifier state, + then applies them to sentiment-bearing words. + """ + if not tokens: + return 0.0 + + raw_score = 0.0 + negate_next = False + intensify_next = False + + for token in tokens: + if token in NEGATORS: + negate_next = True + continue + + if token in INTENSIFIERS: + intensify_next = True + continue + + score = 0.0 + if token in POSITIVE_WORDS: + score = 1.0 + elif token in NEGATIVE_WORDS: + score = -1.0 + + if score != 0.0: + if intensify_next: + score *= INTENSIFIER_MULTIPLIER + intensify_next = False + + if negate_next: + score *= -1.0 + negate_next = False + + raw_score += score + else: + # Reset modifiers if the next word is not a sentiment word + # (modifiers only apply to the immediately following sentiment word) + negate_next = False + intensify_next = False + + # Normalize to -1.0 .. 1.0 using tanh-like scaling + # This keeps small scores proportional while bounding large ones + word_count = len(tokens) + if word_count == 0: + return 0.0 + + # Scale by number of tokens to normalize for message length + normalized = raw_score / max(word_count ** 0.5, 1.0) + + # Clamp to [-1.0, 1.0] + return max(-1.0, min(1.0, normalized)) + + +def classify(score): + """Classify sentiment score into a label.""" + if score > POSITIVE_THRESHOLD: + return "positive" + elif score < NEGATIVE_THRESHOLD: + return "negative" + else: + return "neutral" + + +def main(): + request = json.loads(sys.stdin.read()) + message = request.get("message", "") + + if not message.strip(): + print(json.dumps({"type": "ingest_result", "memories": []})) + return + + tokens = tokenize(message) + score = compute_sentiment(tokens) + label = classify(score) + + # Only inject memory for clearly non-neutral sentiment + if label == "neutral": + print(json.dumps({"type": "ingest_result", "memories": []})) + return + + score_str = f"{score:.1f}" + + if label == "negative": + content = f"[sentiment] User appears frustrated (score: {score_str}). Consider acknowledging the issue." + else: + content = f"[sentiment] User seems satisfied (score: {score_str}). Positive interaction." + + memories = [{"content": content}] + print(json.dumps({"type": "ingest_result", "memories": memories})) + + +if __name__ == "__main__": + main() diff --git a/plugins/sentiment-tracker/plugin.toml b/plugins/sentiment-tracker/plugin.toml new file mode 100644 index 0000000..cc36ef9 --- /dev/null +++ b/plugins/sentiment-tracker/plugin.toml @@ -0,0 +1,7 @@ +name = "sentiment-tracker" +version = "0.1.0" +description = "Analyzes user message sentiment and injects emotional context so agents can respond with appropriate tone" +author = "librefang" + +[hooks] +ingest = "hooks/ingest.py" diff --git a/plugins/todo-tracker/README.md b/plugins/todo-tracker/README.md new file mode 100644 index 0000000..8ea82e2 --- /dev/null +++ b/plugins/todo-tracker/README.md @@ -0,0 +1,58 @@ +# todo-tracker + +Detects action items and tasks mentioned in conversations, persists them, and recalls them as context. Helps agents keep track of what needs to be done without the user having to repeat themselves. + +## How it works + +### Task detection + +After each conversation turn, the plugin scans **all** messages (both user and assistant) for task patterns: + +- `TODO: ...` or `todo: ...` +- `remind me to ...` +- `don't forget to ...` +- `action item: ...` +- `need to ...` +- `I should ...` or `we should ...` +- `- [ ] ...` (markdown checkbox) + +### Completion detection + +The plugin also detects when tasks are marked as done: + +- `done with ...` +- `completed ...` +- `finished ...` +- Completion markers near task text (checkmark emoji, `[x]`) + +### Deduplication + +New tasks are deduplicated against existing ones using normalized lowercase comparison and substring matching, so "Fix the login bug" and "fix the login bug" are treated as the same item. + +### Limits + +Only the latest 20 pending items are kept (FIFO). Completed items are retained for reference. + +## Hooks + +| Hook | Script | Description | +|------|--------|-------------| +| ingest | `hooks/ingest.py` | Returns pending todo items as a memory fragment | +| after_turn | `hooks/after_turn.py` | Scans messages for tasks and completions, updates the todo list | + +## Storage + +Todos are stored at `~/.librefang/plugins/todo-tracker/{agent_id}.json` in the format: + +```json +{ + "todos": [ + {"text": "Fix the login bug", "status": "pending", "added": "2026-03-21T12:00:00+00:00"}, + {"text": "Update README", "status": "done", "added": "2026-03-21T12:00:00+00:00", "completed": "2026-03-21T13:00:00+00:00"} + ] +} +``` + +## Usage + +Installed automatically when enabled in agent configuration. diff --git a/plugins/todo-tracker/hooks/after_turn.py b/plugins/todo-tracker/hooks/after_turn.py new file mode 100644 index 0000000..4662ca6 --- /dev/null +++ b/plugins/todo-tracker/hooks/after_turn.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +"""Todo tracker after_turn hook. + +Scans all conversation messages for task patterns and completion markers, +then persists the updated todo list to disk. + +Receives via stdin: + {"type": "after_turn", "agent_id": "...", "messages": [...]} + +Prints to stdout: + {"type": "ok"} +""" +import json +import os +import re +import sys +from datetime import datetime, timezone + +# Maximum number of pending todos to keep (oldest dropped first) +MAX_PENDING = 20 + +# Maximum characters to extract for a single task description +MAX_TASK_LEN = 100 + +# ── Task detection patterns ────────────────────────────────────────── + +# Each pattern captures the task text in group 1 +TASK_PATTERNS = [ + # "TODO: ..." or "todo: ..." + re.compile(r"(?i)\btodo\s*:\s*(.+)"), + # "remind me to ..." + re.compile(r"(?i)\bremind\s+me\s+to\s+(.+)"), + # "don't forget to ..." + re.compile(r"(?i)\bdon'?t\s+forget\s+to\s+(.+)"), + # "action item: ..." + re.compile(r"(?i)\baction\s+item\s*:\s*(.+)"), + # "need to ..." + re.compile(r"(?i)\bneed\s+to\s+(.+)"), + # "I should ..." or "we should ..." + re.compile(r"(?i)\b(?:i|we)\s+should\s+(.+)"), + # "- [ ] ..." markdown checkbox + re.compile(r"-\s*\[\s*\]\s*(.+)"), +] + +# ── Completion detection patterns ──────────────────────────────────── + +COMPLETION_PATTERNS = [ + # "done with ..." + re.compile(r"(?i)\bdone\s+with\s+(.+)"), + # "completed ..." + re.compile(r"(?i)\bcompleted\s+(.+)"), + # "finished ..." + re.compile(r"(?i)\bfinished\s+(.+)"), +] + +# Completion markers that apply to nearby text +COMPLETION_MARKERS = re.compile(r"(?:\u2705|\[x\])", re.IGNORECASE) + +# Pattern to extract text near a completion marker +# Looks for marker followed by text, or text followed by marker +MARKER_CONTEXT = re.compile( + r"(?:\u2705|\[x\])\s*(.+?)(?:\.|$)|(.+?)\s*(?:\u2705|\[x\])", + re.IGNORECASE, +) + + +def get_storage_dir(): + """Return the plugin storage directory, creating it if needed.""" + home = os.path.expanduser("~") + path = os.path.join(home, ".librefang", "plugins", "todo-tracker") + os.makedirs(path, exist_ok=True) + return path + + +def get_todos_path(agent_id): + """Return the path to the todos file for the given agent.""" + return os.path.join(get_storage_dir(), f"{agent_id}.json") + + +def load_todos(agent_id): + """Load existing todos from disk. Returns a list of todo dicts.""" + path = get_todos_path(agent_id) + if not os.path.isfile(path): + return [] + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + return data.get("todos", []) + except (OSError, IOError, json.JSONDecodeError): + return [] + + +def save_todos(agent_id, todos): + """Persist the todo list to disk.""" + path = get_todos_path(agent_id) + with open(path, "w", encoding="utf-8") as f: + json.dump({"todos": todos}, f, indent=2, ensure_ascii=False) + + +def clean_task_text(text): + """Clean and truncate extracted task text.""" + # Take up to end of first sentence or MAX_TASK_LEN + text = text.strip() + # Truncate at sentence boundary + for delim in (".", "!", "\n"): + idx = text.find(delim) + if 0 < idx < MAX_TASK_LEN: + text = text[:idx] + break + text = text.strip().rstrip(".,;:!?") + if len(text) > MAX_TASK_LEN: + text = text[:MAX_TASK_LEN].rstrip() + return text + + +def normalize_for_comparison(text): + """Normalize text for fuzzy deduplication: lowercase + strip non-alnum.""" + return re.sub(r"[^a-z0-9]", "", text.lower()) + + +def is_duplicate(new_text, existing_todos): + """Check if a task is a fuzzy duplicate of any existing todo.""" + normalized_new = normalize_for_comparison(new_text) + if not normalized_new: + return True # empty tasks are always "duplicates" + for todo in existing_todos: + normalized_existing = normalize_for_comparison(todo.get("text", "")) + if normalized_new == normalized_existing: + return True + # Check if one is a substring of the other (for near-duplicates) + if len(normalized_new) >= 5 and len(normalized_existing) >= 5: + if normalized_new in normalized_existing or normalized_existing in normalized_new: + return True + return False + + +def get_content(msg): + """Extract text content from a message object.""" + if isinstance(msg, dict): + return msg.get("content", "") or "" + return str(msg) + + +def extract_tasks_from_text(text): + """Extract task descriptions from a block of text.""" + tasks = [] + for pattern in TASK_PATTERNS: + for match in pattern.finditer(text): + raw = match.group(1) + cleaned = clean_task_text(raw) + if cleaned and len(cleaned) >= 3: + tasks.append(cleaned) + return tasks + + +def extract_completions_from_text(text): + """Extract completed task descriptions from a block of text.""" + completions = [] + + # Explicit completion phrases + for pattern in COMPLETION_PATTERNS: + for match in pattern.finditer(text): + raw = match.group(1) + cleaned = clean_task_text(raw) + if cleaned and len(cleaned) >= 3: + completions.append(cleaned) + + # Completion markers (checkmark emoji, [x]) + for match in MARKER_CONTEXT.finditer(text): + raw = match.group(1) or match.group(2) or "" + cleaned = clean_task_text(raw) + if cleaned and len(cleaned) >= 3: + completions.append(cleaned) + + return completions + + +def stem_word(word): + """Minimal suffix stripping to normalize verb forms (ing, ed, s, etc.). + + This is intentionally simple -- just enough to match "fixing" to "fix", + "updated" to "updat(e)", etc. without pulling in nltk. + """ + if len(word) <= 4: + return word + if word.endswith("ing") and len(word) > 5: + # running -> runn -> run (but we keep the stem for comparison) + return word[:-3] + if word.endswith("ed") and len(word) > 4: + return word[:-2] + if word.endswith("s") and not word.endswith("ss") and len(word) > 4: + return word[:-1] + return word + + +def word_overlap_score(text_a, text_b): + """Compute a word-overlap similarity score between two texts. + + Returns a float between 0.0 and 1.0 indicating what fraction of + the shorter text's stemmed words appear in the longer text. + """ + words_a = set(stem_word(w) for w in re.findall(r"[a-z]+", text_a.lower()) if len(w) >= 3) + words_b = set(stem_word(w) for w in re.findall(r"[a-z]+", text_b.lower()) if len(w) >= 3) + if not words_a or not words_b: + return 0.0 + smaller = words_a if len(words_a) <= len(words_b) else words_b + larger = words_b if len(words_a) <= len(words_b) else words_a + overlap = smaller & larger + return len(overlap) / len(smaller) + + +# Minimum word-overlap score to consider a completion matching a todo +COMPLETION_MATCH_THRESHOLD = 0.6 + + +def mark_completed(todos, completions): + """Mark todos as done if they match any completion descriptions.""" + if not completions: + return todos + + now = datetime.now(timezone.utc).isoformat() + completion_norms = [normalize_for_comparison(c) for c in completions] + + for todo in todos: + if todo.get("status") == "done": + continue + todo_text = todo.get("text", "") + todo_norm = normalize_for_comparison(todo_text) + for i, comp_norm in enumerate(completion_norms): + if not comp_norm or not todo_norm: + continue + # Exact substring match (normalized) + if comp_norm in todo_norm or todo_norm in comp_norm: + todo["status"] = "done" + todo["completed"] = now + break + # Fuzzy word-overlap match (handles verb form differences) + if word_overlap_score(completions[i], todo_text) >= COMPLETION_MATCH_THRESHOLD: + todo["status"] = "done" + todo["completed"] = now + break + + return todos + + +def enforce_pending_limit(todos): + """Keep only the latest MAX_PENDING pending items. Done items are kept.""" + pending = [t for t in todos if t.get("status") == "pending"] + done = [t for t in todos if t.get("status") != "pending"] + + if len(pending) > MAX_PENDING: + # Keep the most recent MAX_PENDING pending items (by position / added time) + pending = pending[-MAX_PENDING:] + + return pending + done + + +def main(): + request = json.loads(sys.stdin.read()) + agent_id = request.get("agent_id", "unknown") + messages = request.get("messages", []) + + todos = load_todos(agent_id) + now = datetime.now(timezone.utc).isoformat() + + all_new_tasks = [] + all_completions = [] + + # Scan ALL messages for task and completion patterns + for msg in messages: + content = get_content(msg) + if not content.strip(): + continue + + all_new_tasks.extend(extract_tasks_from_text(content)) + all_completions.extend(extract_completions_from_text(content)) + + # Add new tasks (deduplicated against existing) + for task_text in all_new_tasks: + if not is_duplicate(task_text, todos): + todos.append({ + "text": task_text, + "status": "pending", + "added": now, + }) + + # Mark completed tasks + todos = mark_completed(todos, all_completions) + + # Enforce pending limit + todos = enforce_pending_limit(todos) + + # Persist + save_todos(agent_id, todos) + + print(json.dumps({"type": "ok"}), flush=True) + + +if __name__ == "__main__": + main() diff --git a/plugins/todo-tracker/hooks/ingest.py b/plugins/todo-tracker/hooks/ingest.py new file mode 100644 index 0000000..e5cfe56 --- /dev/null +++ b/plugins/todo-tracker/hooks/ingest.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Todo tracker ingest hook. + +Returns pending todo items as a memory fragment so agents stay aware +of outstanding action items during conversations. + +Receives via stdin: + {"type": "ingest", "agent_id": "...", "message": "user message text"} + +Prints to stdout: + {"type": "ingest_result", "memories": [{"content": "..."}]} +""" +import json +import os +import sys + + +def get_todos_path(agent_id): + """Return the path to the todos file for the given agent.""" + home = os.path.expanduser("~") + return os.path.join( + home, ".librefang", "plugins", "todo-tracker", f"{agent_id}.json" + ) + + +def load_todos(agent_id): + """Load existing todos from disk. Returns a list of todo dicts.""" + path = get_todos_path(agent_id) + if not os.path.isfile(path): + return [] + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + return data.get("todos", []) + except (OSError, IOError, json.JSONDecodeError): + return [] + + +def main(): + request = json.loads(sys.stdin.read()) + agent_id = request.get("agent_id", "unknown") + + memories = [] + todos = load_todos(agent_id) + + # Filter to pending items only + pending = [t for t in todos if t.get("status") == "pending"] + + if pending: + items = [] + for i, todo in enumerate(pending, 1): + items.append(f"{i}) {todo.get('text', '?')}") + items_str = " ".join(items) + memories.append( + {"content": f"[todo] Pending items: {items_str}"} + ) + + print(json.dumps({"type": "ingest_result", "memories": memories})) + + +if __name__ == "__main__": + main() diff --git a/plugins/todo-tracker/plugin.toml b/plugins/todo-tracker/plugin.toml new file mode 100644 index 0000000..5ba2aec --- /dev/null +++ b/plugins/todo-tracker/plugin.toml @@ -0,0 +1,8 @@ +name = "todo-tracker" +version = "0.1.0" +description = "Detects action items and tasks mentioned in conversations, persists them, and recalls them as context" +author = "librefang" + +[hooks] +ingest = "hooks/ingest.py" +after_turn = "hooks/after_turn.py" diff --git a/plugins/todo-tracker/requirements.txt b/plugins/todo-tracker/requirements.txt new file mode 100644 index 0000000..023dffa --- /dev/null +++ b/plugins/todo-tracker/requirements.txt @@ -0,0 +1 @@ +# No external dependencies — uses only Python stdlib