Initial Arka plugin registry: Official plugins mirror + Arka-signed index

11 plugins from github.com/librefang/librefang-registry plugins/.
index.json / index.json.sig are signed with Arka's Ed25519 key
(not upstream stats.librefang.ai). Private key is not in this repo.
This commit is contained in:
ixoblakp committed 2026-09-01 10:22:07 +03:00
commit 8dec8f6038
62 files changed
+5189

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
# user-profile
Persistent user profiling from conversation patterns. Builds a profile of user expertise areas, communication style, and technical level, then injects it as context so agents can personalize responses.
## How it works
**After each turn**, the plugin analyzes user messages to update the profile:
- **Expertise areas** -- extracts technical keywords and tracks frequency (top 20 retained)
- **Communication style** -- running average of message lengths (brief / moderate / detailed)
- **Technical level** -- scored from signals like code blocks, version numbers, tech abbreviations, and question patterns (beginner / intermediate / advanced)
- **Question ratio** -- fraction of user messages containing questions
**On ingest**, once the profile has at least 5 interactions, the plugin returns a compact profile summary as a memory fragment: `expertise=python,devops; style=detailed; level=advanced`.
## Hooks
| Hook | Script | Description |
|------|--------|-------------|
| ingest | `hooks/ingest.py` | Returns the profile summary as a memory fragment (after 5+ interactions) |
| after_turn | `hooks/after_turn.py` | Analyzes user messages to update the profile |
## Storage
Profiles are stored at `~/.librefang/plugins/user-profile/{agent_id}.json`.
## Usage
Installed automatically when enabled in agent configuration.
+282
View File
@@ -0,0 +1,282 @@
#!/usr/bin/env python3
"""User-profile after_turn hook.
Analyses user messages from the completed turn and updates the persisted
user profile with extracted signals: expertise areas, message length
statistics, question ratio, and inferred technical level.
This hook is WRITE-ONLY -- it updates the profile store but never returns
memories.
Receives via stdin:
{"type": "after_turn", "agent_id": "...", "messages": [...]}
Each message: {"role": "user"|"assistant", "content": "..."}
Prints to stdout:
{"type": "ok"}
"""
import json
import os
import re
import sys
from datetime import datetime, timezone
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
STORE_DIR = os.path.join(
os.path.expanduser("~"), ".librefang", "plugins", "user-profile"
)
MAX_EXPERTISE_ENTRIES = 20
MIN_KEYWORD_LEN = 3
# Technical abbreviations that signal intermediate+ level
TECH_ABBREVIATIONS = frozenset({
"api", "cli", "sdk", "orm", "sql", "css", "html", "http", "https",
"jwt", "oauth", "ssr", "csr", "dom", "cdn", "dns", "tcp", "udp",
"grpc", "wasm", "yaml", "toml", "json", "xml", "cicd", "gpu",
"cpu", "ram", "ssd", "tls", "ssh", "llm", "rag", "mlops", "etl",
"crud", "rest", "graphql", "ide", "vcs", "iot", "saas", "paas",
})
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",
"been", "before", "between", "both", "down", "during", "few", "get",
"got", "here", "him", "his", "her", "like", "make", "many", "more",
"most", "much", "new", "now", "old", "one", "only", "other", "our",
"own", "same", "say", "see", "still", "such", "take", "than",
"there", "thing", "think", "time", "use", "used", "using", "want",
"way", "well", "work", "know", "really", "right", "going", "back",
})
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def ok_result():
"""Return an ok result."""
return json.dumps({"type": "ok"})
def load_profile(agent_id):
"""Load the profile JSON for an agent. Returns default structure on any error."""
path = os.path.join(STORE_DIR, f"{agent_id}.json")
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict) and isinstance(data.get("interaction_count"), int):
return data
except (OSError, json.JSONDecodeError, ValueError):
pass
return {
"interaction_count": 0,
"expertise_areas": {},
"avg_message_length": 0.0,
"question_ratio": 0.0,
"technical_level": "beginner",
"last_updated": "",
}
def save_profile(agent_id, profile):
"""Persist profile to disk."""
os.makedirs(STORE_DIR, exist_ok=True)
path = os.path.join(STORE_DIR, f"{agent_id}.json")
try:
with open(path, "w", encoding="utf-8") as f:
json.dump(profile, f, indent=2, ensure_ascii=False)
except OSError:
pass
def extract_keywords(text):
"""Extract meaningful keywords from text, filtering stopwords."""
# Handle hyphenated compound terms and regular words
words = re.findall(r"[a-zA-Z][a-zA-Z0-9\-]*[a-zA-Z0-9]|[a-zA-Z]", text)
keywords = []
seen = set()
for w in words:
lower = w.lower()
if lower not in STOPWORDS and len(lower) >= MIN_KEYWORD_LEN and lower not in seen:
seen.add(lower)
keywords.append(lower)
return keywords
def has_code_blocks(text):
"""Check if text contains code blocks or backtick references."""
return bool(re.search(r"```|`[^`]+`", text))
def has_version_numbers(text):
"""Check if text contains version references like v3.2, Python 3.12, etc."""
return bool(re.search(r"v\d+\.\d+|(?<!\w)\d+\.\d+\.\d+", text))
def has_tech_abbreviations(text):
"""Check if text contains known technical abbreviations."""
words = set(re.findall(r"\b[a-zA-Z]{2,6}\b", text))
lower_words = {w.lower() for w in words}
return bool(lower_words & TECH_ABBREVIATIONS)
def has_basic_questions(text):
"""Check if text contains beginner-style 'what is' / 'explain' patterns."""
lower = text.lower()
return bool(re.search(r"\bwhat\s+is\b|\bexplain\b|\bwhat\s+are\b", lower))
def average_word_length(text):
"""Compute average word length in the text."""
words = re.findall(r"[a-zA-Z]+", text)
if not words:
return 0.0
return sum(len(w) for w in words) / len(words)
def infer_technical_level(text):
"""Infer technical level from a single message. Returns a score.
Score >= 3 -> "advanced"
Score 1-2 -> "intermediate"
Score <= 0 -> "beginner"
"""
score = 0
if has_code_blocks(text):
score += 2
if has_version_numbers(text):
score += 1
if has_tech_abbreviations(text):
score += 1
if has_basic_questions(text):
score -= 1
if average_word_length(text) > 5.5:
score += 1
return score
def tech_level_from_score(score):
"""Map a numeric score to a technical level label."""
if score >= 3:
return "advanced"
elif score >= 1:
return "intermediate"
else:
return "beginner"
def prune_expertise(expertise, max_entries):
"""Keep only the top max_entries expertise areas by count."""
if len(expertise) <= max_entries:
return expertise
sorted_items = sorted(expertise.items(), key=lambda x: x[1], reverse=True)
return dict(sorted_items[:max_entries])
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
try:
request = json.loads(sys.stdin.read())
except (json.JSONDecodeError, ValueError):
print(ok_result())
return
agent_id = request.get("agent_id", "")
messages = request.get("messages", [])
if not agent_id or not isinstance(messages, list):
print(ok_result())
return
# Filter to user messages only
user_messages = []
for msg in messages:
if isinstance(msg, dict) and msg.get("role") == "user":
content = msg.get("content", "")
if isinstance(content, str) and content.strip():
user_messages.append(content)
if not user_messages:
print(ok_result())
return
profile = load_profile(agent_id)
old_count = profile["interaction_count"]
new_count = old_count + len(user_messages)
# --- Expertise areas ---
expertise = profile.get("expertise_areas", {})
for msg in user_messages:
keywords = extract_keywords(msg)
for kw in keywords:
expertise[kw] = expertise.get(kw, 0) + 1
expertise = prune_expertise(expertise, MAX_EXPERTISE_ENTRIES)
profile["expertise_areas"] = expertise
# --- Average message length (running average) ---
old_avg = profile.get("avg_message_length", 0.0)
total_new_len = sum(len(msg) for msg in user_messages)
if old_count == 0:
new_avg = total_new_len / len(user_messages)
else:
# Weighted running average: combine old aggregate with new messages
old_total = old_avg * old_count
new_avg = (old_total + total_new_len) / new_count
profile["avg_message_length"] = round(new_avg, 1)
# --- Question ratio (running ratio) ---
old_ratio = profile.get("question_ratio", 0.0)
questions_in_batch = sum(1 for msg in user_messages if "?" in msg)
if old_count == 0:
new_ratio = questions_in_batch / len(user_messages)
else:
old_question_count = round(old_ratio * old_count)
new_ratio = (old_question_count + questions_in_batch) / new_count
profile["question_ratio"] = round(new_ratio, 3)
# --- Technical level (weighted towards recent) ---
total_score = 0
for msg in user_messages:
total_score += infer_technical_level(msg)
avg_score = total_score / len(user_messages)
# Blend with historical level: map old level to a score, then average
level_to_score = {"beginner": 0, "intermediate": 1.5, "advanced": 3}
old_level_score = level_to_score.get(profile.get("technical_level", "beginner"), 0)
if old_count == 0:
blended_score = avg_score
else:
# Give 70% weight to history, 30% to this batch
blended_score = 0.7 * old_level_score + 0.3 * avg_score
profile["technical_level"] = tech_level_from_score(blended_score)
# --- Bookkeeping ---
profile["interaction_count"] = new_count
profile["last_updated"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
save_profile(agent_id, profile)
print(ok_result())
if __name__ == "__main__":
main()
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""User-profile ingest hook.
Reads the persisted user profile for the given agent and, when enough
interaction data has been collected (>= 5 interactions), returns a
compact profile summary as injected memory so the agent can personalise
its responses.
This hook is READ-ONLY -- it never modifies the profile store.
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
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
STORE_DIR = os.path.join(
os.path.expanduser("~"), ".librefang", "plugins", "user-profile"
)
MIN_INTERACTIONS = 5
MAX_SUMMARY_LEN = 200
TOP_EXPERTISE = 5
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def empty_result():
"""Return an empty ingest result."""
return json.dumps({"type": "ingest_result", "memories": []})
def load_profile(agent_id):
"""Load the profile JSON for an agent. Returns None on any error."""
path = os.path.join(STORE_DIR, f"{agent_id}.json")
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict) and isinstance(data.get("interaction_count"), int):
return data
except (OSError, json.JSONDecodeError, ValueError):
pass
return None
def message_length_bucket(avg_len):
"""Classify average message length into a human-readable bucket."""
if avg_len < 50:
return "brief"
elif avg_len <= 200:
return "moderate"
else:
return "detailed"
def build_summary(profile):
"""Build a compact profile summary string (max MAX_SUMMARY_LEN chars)."""
parts = []
# Top expertise areas
expertise = profile.get("expertise_areas", {})
if expertise:
sorted_areas = sorted(expertise.items(), key=lambda x: x[1], reverse=True)
top = [area for area, _count in sorted_areas[:TOP_EXPERTISE]]
parts.append("expertise=" + ",".join(top))
# Communication style
avg_len = profile.get("avg_message_length", 0)
parts.append("style=" + message_length_bucket(avg_len))
# Technical level
tech_level = profile.get("technical_level", "")
if tech_level:
parts.append("level=" + tech_level)
# Question ratio
q_ratio = profile.get("question_ratio", 0.0)
if q_ratio > 0.5:
parts.append("asks-many-questions")
summary = "; ".join(parts)
if len(summary) > MAX_SUMMARY_LEN:
summary = summary[:MAX_SUMMARY_LEN - 3] + "..."
return summary
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
try:
request = json.loads(sys.stdin.read())
except (json.JSONDecodeError, ValueError):
print(empty_result())
return
agent_id = request.get("agent_id", "")
if not agent_id:
print(empty_result())
return
profile = load_profile(agent_id)
if profile is None:
print(empty_result())
return
interaction_count = profile.get("interaction_count", 0)
if interaction_count < MIN_INTERACTIONS:
print(empty_result())
return
summary = build_summary(profile)
if not summary:
print(empty_result())
return
memory = {"content": f"[user-profile] User context: {summary}"}
print(json.dumps({"type": "ingest_result", "memories": [memory]}))
if __name__ == "__main__":
main()
+41
View File
@@ -0,0 +1,41 @@
name = "user-profile"
version = "0.1.0"
description = "Persistent user profiling from conversation patterns for personalized agent responses"
author = "librefang"
[hooks]
ingest = "hooks/ingest.py"
after_turn = "hooks/after_turn.py"
[i18n.zh]
name = "用户画像"
description = "从对话模式中持续构建用户画像,用于个性化的 Agent 回复。"
[i18n.zh-TW]
name = "使用者畫像"
description = "從對話模式中持續建立使用者畫像,用於個人化的 Agent 回覆。"
[i18n.ja]
name = "ユーザープロファイル"
description = "会話パターンから継続的にユーザープロファイルを構築し、パーソナライズ応答に活用。"
[i18n.ko]
name = "사용자 프로필"
description = "대화 패턴에서 지속적으로 사용자 프로필을 구축하여 개인화된 응답에 활용."
[i18n.de]
name = "User-Profil"
description = "Persistente Nutzerprofilerstellung aus Konversationsmustern für personalisierte Agent-Antworten."
[i18n.es]
name = "Perfil de usuario"
description = "Perfilado persistente del usuario a partir de patrones de conversación para respuestas personalizadas."
[i18n.fr]
name = "Profil utilisateur"
description = "Profilage persistant basé sur les motifs de conversation pour des réponses personnalisées."
[integrity]
"hooks/after_turn.py" = "e1e12c2e6a3e32e2c26b4184836229f7f6cb32b0e14fcca82496354ff3b36f73"
"hooks/ingest.py" = "28ae3b2435b6e46ef7bfd71b913f2e8c96d1a89120b48eb4cd54ceaefc18501b"
Whitespace-only changes.