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
This commit is contained in:
Evan Hu committed 2026-03-21 02:51:58 +09:00
1 parent 994b60c0d9
commit afcb260554
25 files changed
+1396 -4

No files matched your search

+300
View File
@@ -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()
+62
View File
@@ -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()