agentirc 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentirc/__init__.py +9 -0
- agentirc/__main__.py +97 -0
- agentirc/api.py +369 -0
- agentirc/bot.py +437 -0
- agentirc/config.py +114 -0
- agentirc/history.py +202 -0
- agentirc/models.py +156 -0
- agentirc/tools.py +81 -0
- agentirc-1.0.0.dist-info/METADATA +115 -0
- agentirc-1.0.0.dist-info/RECORD +20 -0
- agentirc-1.0.0.dist-info/WHEEL +4 -0
- agentirc-1.0.0.dist-info/entry_points.txt +2 -0
- agentirc-1.0.0.dist-info/licenses/LICENSE +21 -0
- ircbot/__init__.py +15 -0
- ircbot/__main__.py +41 -0
- ircbot/bot.py +279 -0
- ircbot/commands.py +52 -0
- ircbot/config.py +64 -0
- ircbot/connection.py +133 -0
- ircbot/protocol.py +109 -0
agentirc/history.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
log = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HistoryStore:
|
|
12
|
+
"""In-memory history per room and user with system prompt support.
|
|
13
|
+
|
|
14
|
+
Optionally persists to an encrypted file when *store_path* and
|
|
15
|
+
*encryption_key* are both provided.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
prompt_prefix: str = "you are ",
|
|
21
|
+
prompt_suffix: str = ".",
|
|
22
|
+
personality: str = "",
|
|
23
|
+
*,
|
|
24
|
+
prompt_suffix_extra: str = "",
|
|
25
|
+
max_items: int = 24,
|
|
26
|
+
history_size: Optional[int] = None,
|
|
27
|
+
system_prompt: Optional[str] = None,
|
|
28
|
+
store_path: Optional[str] = None,
|
|
29
|
+
encryption_key: Optional[str] = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
if system_prompt is not None:
|
|
32
|
+
self.prompt_prefix = ""
|
|
33
|
+
self.prompt_suffix = ""
|
|
34
|
+
self.prompt_suffix_extra = ""
|
|
35
|
+
self.personality = ""
|
|
36
|
+
self._fixed_system_prompt = system_prompt
|
|
37
|
+
else:
|
|
38
|
+
self.prompt_prefix = prompt_prefix
|
|
39
|
+
self.prompt_suffix = prompt_suffix
|
|
40
|
+
self.prompt_suffix_extra = prompt_suffix_extra
|
|
41
|
+
self.personality = personality
|
|
42
|
+
self._fixed_system_prompt = None
|
|
43
|
+
self.max_items = history_size or max_items
|
|
44
|
+
self._include_extra = True
|
|
45
|
+
self._messages: Dict[str, Dict[str, List[Dict[str, str]]]] = {}
|
|
46
|
+
self._locations: Dict[str, str] = {}
|
|
47
|
+
self.user_models: Dict[str, Dict[str, str]] = {}
|
|
48
|
+
|
|
49
|
+
# Encrypted persistence setup
|
|
50
|
+
self._fernet = None
|
|
51
|
+
self._store_file: Optional[Path] = None
|
|
52
|
+
if store_path and encryption_key:
|
|
53
|
+
try:
|
|
54
|
+
from cryptography.fernet import Fernet
|
|
55
|
+
self._fernet = Fernet(encryption_key.encode() if isinstance(encryption_key, str) else encryption_key)
|
|
56
|
+
self._store_file = Path(store_path) / "history.enc"
|
|
57
|
+
self._store_file.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
self._load()
|
|
59
|
+
except Exception:
|
|
60
|
+
log.exception("Failed to initialize encrypted persistence")
|
|
61
|
+
self._fernet = None
|
|
62
|
+
self._store_file = None
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def messages(self) -> Dict[str, Dict[str, List[Dict[str, str]]]]:
|
|
66
|
+
return self._messages
|
|
67
|
+
|
|
68
|
+
def set_verbose(self, verbose: bool) -> None:
|
|
69
|
+
self._include_extra = not bool(verbose)
|
|
70
|
+
|
|
71
|
+
def _full_suffix(self) -> str:
|
|
72
|
+
extra = self.prompt_suffix_extra if self._include_extra and self.prompt_suffix_extra else ""
|
|
73
|
+
return f"{self.prompt_suffix}{extra}"
|
|
74
|
+
|
|
75
|
+
def _location_suffix(self, user: str) -> str:
|
|
76
|
+
loc = self._locations.get(user, "")
|
|
77
|
+
if loc:
|
|
78
|
+
return f" The user has indicated they are located in {loc}. Use when needed, Do not adopt this as part of your personality."
|
|
79
|
+
return ""
|
|
80
|
+
|
|
81
|
+
def _system_for(self, room: str, user: str) -> str:
|
|
82
|
+
del room
|
|
83
|
+
if self._fixed_system_prompt is not None:
|
|
84
|
+
return self._fixed_system_prompt + self._location_suffix(user)
|
|
85
|
+
return f"{self.prompt_prefix}{self.personality}{self._full_suffix()}{self._location_suffix(user)}"
|
|
86
|
+
|
|
87
|
+
def _ensure(self, room: str, user: str) -> None:
|
|
88
|
+
if room not in self._messages:
|
|
89
|
+
self._messages[room] = {}
|
|
90
|
+
if user not in self._messages[room]:
|
|
91
|
+
self._messages[room][user] = [{"role": "system", "content": self._system_for(room, user)}]
|
|
92
|
+
|
|
93
|
+
def init_prompt(
|
|
94
|
+
self,
|
|
95
|
+
room: str,
|
|
96
|
+
user: str,
|
|
97
|
+
persona: Optional[str] = None,
|
|
98
|
+
custom: Optional[str] = None,
|
|
99
|
+
) -> None:
|
|
100
|
+
self._ensure(room, user)
|
|
101
|
+
loc_suffix = self._location_suffix(user)
|
|
102
|
+
if custom:
|
|
103
|
+
self._messages[room][user] = [{"role": "system", "content": custom + loc_suffix}]
|
|
104
|
+
else:
|
|
105
|
+
p = persona if (persona is not None and persona != "") else self.personality
|
|
106
|
+
self._messages[room][user] = [
|
|
107
|
+
{"role": "system", "content": f"{self.prompt_prefix}{p}{self._full_suffix()}{loc_suffix}"}
|
|
108
|
+
]
|
|
109
|
+
self._save()
|
|
110
|
+
|
|
111
|
+
def add(self, room: str, user: str, role: str, content: str) -> None:
|
|
112
|
+
self._ensure(room, user)
|
|
113
|
+
self._messages[room][user].append({"role": role, "content": content})
|
|
114
|
+
self._trim(room, user)
|
|
115
|
+
self._save()
|
|
116
|
+
|
|
117
|
+
def get(self, room: str, user: str) -> List[Dict[str, str]]:
|
|
118
|
+
self._ensure(room, user)
|
|
119
|
+
return list(self._messages[room][user])
|
|
120
|
+
|
|
121
|
+
def reset(self, room: str, user: str, stock: bool = False) -> None:
|
|
122
|
+
if room not in self._messages:
|
|
123
|
+
self._messages[room] = {}
|
|
124
|
+
self._messages[room][user] = []
|
|
125
|
+
if not stock:
|
|
126
|
+
self.init_prompt(room, user, persona=self.personality)
|
|
127
|
+
self._save()
|
|
128
|
+
|
|
129
|
+
def clear(self, room: str, user: str) -> None:
|
|
130
|
+
self.reset(room, user, stock=True)
|
|
131
|
+
|
|
132
|
+
def clear_all(self) -> None:
|
|
133
|
+
self._messages.clear()
|
|
134
|
+
# Locations are user preferences, not conversation state — preserve them
|
|
135
|
+
self._save()
|
|
136
|
+
|
|
137
|
+
def set_location(self, user: str, location: str) -> None:
|
|
138
|
+
"""Set or clear a user's location. Updates system prompts in all existing threads."""
|
|
139
|
+
old_suffix = self._location_suffix(user)
|
|
140
|
+
if location:
|
|
141
|
+
self._locations[user] = location
|
|
142
|
+
else:
|
|
143
|
+
self._locations.pop(user, None)
|
|
144
|
+
new_suffix = self._location_suffix(user)
|
|
145
|
+
|
|
146
|
+
# Update system prompts in all existing threads for this user
|
|
147
|
+
for room in self._messages:
|
|
148
|
+
if user in self._messages[room]:
|
|
149
|
+
msgs = self._messages[room][user]
|
|
150
|
+
if msgs and msgs[0].get("role") == "system":
|
|
151
|
+
content = msgs[0]["content"]
|
|
152
|
+
if old_suffix:
|
|
153
|
+
content = content.replace(old_suffix, "")
|
|
154
|
+
content = content + new_suffix
|
|
155
|
+
msgs[0]["content"] = content
|
|
156
|
+
self._save()
|
|
157
|
+
|
|
158
|
+
def get_location(self, user: str) -> Optional[str]:
|
|
159
|
+
return self._locations.get(user) or None
|
|
160
|
+
|
|
161
|
+
def _trim(self, room: str, user: str) -> None:
|
|
162
|
+
msgs = self._messages[room][user]
|
|
163
|
+
while len(msgs) > self.max_items:
|
|
164
|
+
if msgs and msgs[0].get("role") == "system":
|
|
165
|
+
if len(msgs) > 1:
|
|
166
|
+
msgs.pop(1)
|
|
167
|
+
else:
|
|
168
|
+
break
|
|
169
|
+
else:
|
|
170
|
+
msgs.pop(0)
|
|
171
|
+
|
|
172
|
+
def _save(self) -> None:
|
|
173
|
+
if not self._fernet or not self._store_file:
|
|
174
|
+
return
|
|
175
|
+
try:
|
|
176
|
+
data = json.dumps({
|
|
177
|
+
"messages": self._messages,
|
|
178
|
+
"locations": self._locations,
|
|
179
|
+
})
|
|
180
|
+
encrypted = self._fernet.encrypt(data.encode())
|
|
181
|
+
self._store_file.write_bytes(encrypted)
|
|
182
|
+
except Exception:
|
|
183
|
+
log.exception("Failed to save encrypted history")
|
|
184
|
+
|
|
185
|
+
def _load(self) -> None:
|
|
186
|
+
if not self._fernet or not self._store_file or not self._store_file.exists():
|
|
187
|
+
return
|
|
188
|
+
try:
|
|
189
|
+
encrypted = self._store_file.read_bytes()
|
|
190
|
+
decrypted = self._fernet.decrypt(encrypted)
|
|
191
|
+
data = json.loads(decrypted.decode())
|
|
192
|
+
if isinstance(data, dict):
|
|
193
|
+
if "messages" in data:
|
|
194
|
+
self._messages = data["messages"]
|
|
195
|
+
self._locations = data.get("locations", {})
|
|
196
|
+
else:
|
|
197
|
+
# Old format: bare messages dict
|
|
198
|
+
self._messages = data
|
|
199
|
+
except Exception:
|
|
200
|
+
log.exception("Failed to load encrypted history (wrong key?), starting fresh")
|
|
201
|
+
self._messages = {}
|
|
202
|
+
self._locations = {}
|
agentirc/models.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Model discovery and provider resolution helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
from urllib.error import URLError
|
|
9
|
+
from urllib.request import Request, urlopen
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
KNOWN_PROVIDERS = ("openai", "xai", "lmstudio")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _models_url(api_base: str) -> str:
|
|
17
|
+
base = api_base.rstrip("/")
|
|
18
|
+
return f"{base}/models" if base.endswith("/v1") else f"{base}/v1/models"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _is_chat_model(provider: str, model_id: str) -> bool:
|
|
22
|
+
lowered = model_id.lower()
|
|
23
|
+
if provider == "lmstudio":
|
|
24
|
+
blocked_models = {
|
|
25
|
+
"text-embedding-nomic-embed-text-v1.5",
|
|
26
|
+
}
|
|
27
|
+
if lowered in blocked_models:
|
|
28
|
+
return False
|
|
29
|
+
return bool(model_id.strip())
|
|
30
|
+
|
|
31
|
+
if provider == "xai":
|
|
32
|
+
if not lowered.startswith("grok-"):
|
|
33
|
+
return False
|
|
34
|
+
blocked_fragments = ("imagine", "image", "video", "voice", "vision")
|
|
35
|
+
return not any(fragment in lowered for fragment in blocked_fragments)
|
|
36
|
+
|
|
37
|
+
prefixes = ("gpt-", "o1", "o3", "o4")
|
|
38
|
+
if not model_id.startswith(prefixes):
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
blocked_fragments = (
|
|
42
|
+
"preview",
|
|
43
|
+
"audio",
|
|
44
|
+
"computer-use",
|
|
45
|
+
"transcribe",
|
|
46
|
+
"tts",
|
|
47
|
+
"image",
|
|
48
|
+
)
|
|
49
|
+
if any(fragment in lowered for fragment in blocked_fragments):
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
if re.search(r"-\d{4}-\d{2}-\d{2}$", lowered):
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def provider_for_model(model: str, models: dict[str, list[str]]) -> str | None:
|
|
59
|
+
selected = str(model or "").strip()
|
|
60
|
+
if not selected:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
for provider, provider_models in models.items():
|
|
64
|
+
if selected in provider_models:
|
|
65
|
+
return provider
|
|
66
|
+
|
|
67
|
+
lowered = selected.lower()
|
|
68
|
+
if lowered.startswith("grok-"):
|
|
69
|
+
return "xai"
|
|
70
|
+
if lowered.startswith(("gpt-", "o1", "o3", "o4")):
|
|
71
|
+
return "openai"
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def fetch_models(api_base: str, api_key: str = "", provider: str = "openai") -> list[str]:
|
|
76
|
+
"""GET models and return filtered chat-capable model IDs."""
|
|
77
|
+
url = _models_url(api_base)
|
|
78
|
+
req = Request(url, method="GET")
|
|
79
|
+
if api_key:
|
|
80
|
+
req.add_header("Authorization", f"Bearer {api_key}")
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
with urlopen(req, timeout=30) as resp:
|
|
84
|
+
body = json.loads(resp.read())
|
|
85
|
+
except (URLError, OSError, json.JSONDecodeError) as exc:
|
|
86
|
+
log.error("Failed to fetch models from %s: %s", url, exc)
|
|
87
|
+
return []
|
|
88
|
+
|
|
89
|
+
models = [m["id"] for m in body.get("data", []) if isinstance(m, dict) and "id" in m]
|
|
90
|
+
models = [m for m in models if _is_chat_model(provider, m)]
|
|
91
|
+
models.sort()
|
|
92
|
+
return models
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def refresh_model_catalog(
|
|
96
|
+
configured_models: dict[str, list[str]],
|
|
97
|
+
*,
|
|
98
|
+
base_urls: dict[str, str],
|
|
99
|
+
api_keys: dict[str, str],
|
|
100
|
+
server_models: bool = True,
|
|
101
|
+
) -> dict[str, list[str]]:
|
|
102
|
+
"""Build a provider->models map from config and optional server discovery."""
|
|
103
|
+
merged: dict[str, list[str]] = {
|
|
104
|
+
provider: sorted(dict.fromkeys(configured_models.get(provider, [])))
|
|
105
|
+
for provider in KNOWN_PROVIDERS
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if not server_models:
|
|
109
|
+
return merged
|
|
110
|
+
|
|
111
|
+
for provider in KNOWN_PROVIDERS:
|
|
112
|
+
api_base = str(base_urls.get(provider, "") or "").strip()
|
|
113
|
+
if not api_base:
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
api_key = str(api_keys.get(provider, "") or "").strip()
|
|
117
|
+
|
|
118
|
+
fetched = fetch_models(api_base, api_key, provider=provider)
|
|
119
|
+
if not fetched:
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
merged[provider] = sorted(dict.fromkeys([*merged[provider], *fetched]))
|
|
123
|
+
|
|
124
|
+
return merged
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def pick_default_model(models: dict[str, list[str]], preferred: str = "") -> str:
|
|
128
|
+
"""Select a default model from preferred value or the catalog."""
|
|
129
|
+
preferred = preferred.strip()
|
|
130
|
+
if preferred:
|
|
131
|
+
return preferred
|
|
132
|
+
|
|
133
|
+
for provider in KNOWN_PROVIDERS:
|
|
134
|
+
items = models.get(provider, [])
|
|
135
|
+
if items:
|
|
136
|
+
return items[0]
|
|
137
|
+
raise RuntimeError("No models available from configured providers")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def pick_model(api_base: str, api_key: str = "", preferred: str = "", provider: str = "openai") -> str:
|
|
141
|
+
"""Legacy helper: pick a model from a single provider endpoint."""
|
|
142
|
+
available = fetch_models(api_base, api_key, provider=provider)
|
|
143
|
+
|
|
144
|
+
if available:
|
|
145
|
+
log.debug("Available models: %s", ", ".join(available))
|
|
146
|
+
if preferred and preferred in available:
|
|
147
|
+
return preferred
|
|
148
|
+
if preferred:
|
|
149
|
+
log.warning("Preferred model %r not found, using %s", preferred, available[0])
|
|
150
|
+
return available[0]
|
|
151
|
+
|
|
152
|
+
if preferred:
|
|
153
|
+
log.warning("Could not fetch models from %s, using preferred model %r", api_base, preferred)
|
|
154
|
+
return preferred
|
|
155
|
+
|
|
156
|
+
raise RuntimeError(f"No models available from {api_base}")
|
agentirc/tools.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Tool definitions for OpenAI-compatible Responses APIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
XAI_HOSTED_TOOL_TYPES = {"web_search", "x_search", "code_interpreter", "mcp"}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_tools(
|
|
11
|
+
enabled: list[str],
|
|
12
|
+
provider: str = "openai",
|
|
13
|
+
*,
|
|
14
|
+
web_search_country: str = "",
|
|
15
|
+
) -> list[dict[str, Any]]:
|
|
16
|
+
"""Build the tools list for the Responses API request.
|
|
17
|
+
|
|
18
|
+
Supported tools:
|
|
19
|
+
- web_search (openai, xai)
|
|
20
|
+
- x_search (xai)
|
|
21
|
+
- code_interpreter (openai, xai)
|
|
22
|
+
"""
|
|
23
|
+
tool_builders: dict[str, tuple[set[str], Any]] = {
|
|
24
|
+
"web_search": ({"openai", "xai"}, _web_search_tool),
|
|
25
|
+
"x_search": ({"xai"}, _x_search_tool),
|
|
26
|
+
"code_interpreter": ({"openai", "xai"}, _code_interpreter_tool),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
tools = []
|
|
30
|
+
for name in enabled:
|
|
31
|
+
spec = tool_builders.get(name)
|
|
32
|
+
if not spec:
|
|
33
|
+
continue
|
|
34
|
+
providers, builder = spec
|
|
35
|
+
if provider in providers:
|
|
36
|
+
if name == "web_search":
|
|
37
|
+
tools.append(builder(provider, country=web_search_country))
|
|
38
|
+
else:
|
|
39
|
+
tools.append(builder(provider))
|
|
40
|
+
return tools
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _web_search_tool(_provider: str, *, country: str = "") -> dict[str, Any]:
|
|
44
|
+
tool: dict[str, Any] = {"type": "web_search"}
|
|
45
|
+
if country:
|
|
46
|
+
tool["user_location"] = {"type": "approximate", "country": country}
|
|
47
|
+
return tool
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def strip_search_country(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
51
|
+
"""Remove user_location from web_search tool dicts."""
|
|
52
|
+
result = []
|
|
53
|
+
for tool in tools:
|
|
54
|
+
if isinstance(tool, dict) and tool.get("type") == "web_search" and "user_location" in tool:
|
|
55
|
+
tool = {k: v for k, v in tool.items() if k != "user_location"}
|
|
56
|
+
result.append(tool)
|
|
57
|
+
return result
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _x_search_tool(_provider: str) -> dict[str, Any]:
|
|
61
|
+
return {"type": "x_search"}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _code_interpreter_tool(provider: str) -> dict[str, Any]:
|
|
65
|
+
tool = {"type": "code_interpreter"}
|
|
66
|
+
if provider == "openai":
|
|
67
|
+
tool["container"] = {"type": "auto"}
|
|
68
|
+
return tool
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def xai_model_supports_hosted_tools(model: str) -> bool:
|
|
72
|
+
lowered = str(model or "").strip().lower()
|
|
73
|
+
return lowered.startswith("grok-4")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def tools_for_model(enabled: list[str], provider: str, model: str) -> list[str]:
|
|
77
|
+
if provider != "xai":
|
|
78
|
+
return list(enabled)
|
|
79
|
+
if xai_model_supports_hosted_tools(model):
|
|
80
|
+
return list(enabled)
|
|
81
|
+
return [name for name in enabled if name not in XAI_HOSTED_TOOL_TYPES]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentirc
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: AI-powered IRC agent with multi-provider LLM support
|
|
5
|
+
Project-URL: Homepage, https://github.com/h1ddenpr0cess20/agentirc
|
|
6
|
+
Project-URL: Repository, https://github.com/h1ddenpr0cess20/agentirc
|
|
7
|
+
Project-URL: Issues, https://github.com/h1ddenpr0cess20/agentirc/issues
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agent,ai,bot,chatbot,irc,llm,ollama,openai
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Communications :: Chat
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: httpx>=0.27
|
|
18
|
+
Provides-Extra: crypto
|
|
19
|
+
Requires-Dist: cryptography>=43; extra == 'crypto'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# agentirc
|
|
23
|
+
|
|
24
|
+
An AI-powered IRC agent built on a minimal async IRC bot framework. Supports multiple LLM providers (OpenAI, xAI, LM Studio) with per-user conversation history, tool use, and encrypted persistence.
|
|
25
|
+
|
|
26
|
+
## Table of Contents
|
|
27
|
+
|
|
28
|
+
- [Quick Start](#quick-start)
|
|
29
|
+
- [CLI Options](#cli-options)
|
|
30
|
+
- [Commands](#commands)
|
|
31
|
+
- [Documentation](#documentation)
|
|
32
|
+
- [License](#license)
|
|
33
|
+
|
|
34
|
+
## Quick Start
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
git clone <repo-url>
|
|
38
|
+
cd agentirc
|
|
39
|
+
cp .env.example .env
|
|
40
|
+
# Edit .env with your IRC server, nick, and at least one API provider
|
|
41
|
+
pip install .
|
|
42
|
+
agentirc
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Or with Docker:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
cp .env.example .env
|
|
49
|
+
# Edit .env
|
|
50
|
+
docker compose up -d
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
> **Requirements:** Python 3.10+, `httpx`, and optionally `cryptography` for encrypted history persistence.
|
|
54
|
+
|
|
55
|
+
## CLI Options
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
agentirc [options]
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
| Flag | Description |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `--env-file PATH` | Path to .env file (default: `.env`) |
|
|
64
|
+
| `--debug` | Enable debug logging |
|
|
65
|
+
| `--host HOST` | IRC server hostname (overrides `IRC_HOST`) |
|
|
66
|
+
| `--port PORT` | IRC server port (overrides `IRC_PORT`) |
|
|
67
|
+
| `--nick NICK` | Bot nickname (overrides `IRC_NICK`) |
|
|
68
|
+
| `--channels CHANS` | Comma-separated channels (overrides `IRC_CHANNELS`) |
|
|
69
|
+
| `--tls` | Connect with TLS (overrides `IRC_USE_TLS`) |
|
|
70
|
+
| `--model MODEL` | Default model (overrides `DEFAULT_MODEL`) |
|
|
71
|
+
| `--generate-key` | Generate a Fernet encryption key and exit |
|
|
72
|
+
|
|
73
|
+
CLI flags override their corresponding environment variables.
|
|
74
|
+
|
|
75
|
+
## Commands
|
|
76
|
+
|
|
77
|
+
### User Commands
|
|
78
|
+
|
|
79
|
+
| Command | Aliases | Description |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `!ai <message>` | `!chat`, `!ask` | Talk to the AI |
|
|
82
|
+
| `!persona <text>` | | Set a persona and reintroduce |
|
|
83
|
+
| `!custom <prompt>` | | Set a custom system prompt |
|
|
84
|
+
| `!reset` | | Reset conversation to defaults |
|
|
85
|
+
| `!stock` | | Reset conversation with no system prompt |
|
|
86
|
+
| `!mymodel [name]` | | Show or set your model |
|
|
87
|
+
| `!location <place>` | | Set your location for contextual answers |
|
|
88
|
+
| `!x <nick> <message>` | | Talk as another user |
|
|
89
|
+
|
|
90
|
+
### Admin Commands
|
|
91
|
+
|
|
92
|
+
| Command | Description |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `!model [name\|reset]` | Show/set global default model |
|
|
95
|
+
| `!tools [on\|off\|toggle\|status]` | Enable/disable tool use |
|
|
96
|
+
| `!verbose [on\|off\|toggle]` | Toggle verbose mode |
|
|
97
|
+
| `!clear` | Clear all conversation state |
|
|
98
|
+
| `!country [on\|off\|status]` | Toggle search country filtering |
|
|
99
|
+
| `!join <#channel>` | Join a channel |
|
|
100
|
+
| `!part [#channel] [reason]` | Leave a channel |
|
|
101
|
+
|
|
102
|
+
Built-in IRC commands (`!ping`, `!time`, `!help`) are also available.
|
|
103
|
+
|
|
104
|
+
## Documentation
|
|
105
|
+
|
|
106
|
+
- [docs/configuration.md](docs/configuration.md) -- IRC and AI provider configuration
|
|
107
|
+
- [docs/commands.md](docs/commands.md) -- command registry and decorator API
|
|
108
|
+
- [docs/extending.md](docs/extending.md) -- subclassing IRCBot, event hooks
|
|
109
|
+
- [docs/ai-agent.md](docs/ai-agent.md) -- AI agent architecture, providers, tools, and history
|
|
110
|
+
- [docs/ai-output-disclaimer.md](docs/ai-output-disclaimer.md) -- AI output disclaimer and conditions of use
|
|
111
|
+
- [docs/not-a-companion.md](docs/not-a-companion.md) -- project scope and intended use
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
MIT
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
agentirc/__init__.py,sha256=vS8FZZv7cTGYip0sDOB5R3AKQ7n9YFed-wpMhpoX2tI,184
|
|
2
|
+
agentirc/__main__.py,sha256=_55sP49N9gEe5frnCdRP8XFl69MeGPvLmqUIPvo1LYQ,2519
|
|
3
|
+
agentirc/api.py,sha256=R8vDxuNZDw57FE1F78Ffuxit6v85JfiS2zAEQO9Iaic,13353
|
|
4
|
+
agentirc/bot.py,sha256=GqRw6HmT-ldxcyJQI5I1znI7kx6ObVvE2agUuJ6N4Sg,18636
|
|
5
|
+
agentirc/config.py,sha256=h1gH2GFTk9l0XCIW36aEnoWcKBIgGDbsZIUzNDH-7UE,4853
|
|
6
|
+
agentirc/history.py,sha256=GVAU6HtINVw4plS5QCDUIWC3R4IBw1rOMS5-_T8n8yo,7712
|
|
7
|
+
agentirc/models.py,sha256=tOo-CgwxjG2hVrliEZF-c974sHuKQNw1bQSLJXvylP8,4749
|
|
8
|
+
agentirc/tools.py,sha256=tX7z0dCDJNlxOHMtKsoKZZNIPm_9s--m-mCRZYzIq0g,2513
|
|
9
|
+
ircbot/__init__.py,sha256=jOY4I9BbKkn3jxao1ntiY_0q8D60jQT4gb9mGY4r95s,317
|
|
10
|
+
ircbot/__main__.py,sha256=I_VQkjDLvL4w5lFrMUAdfL94mK86vwNjHwE2PGy9EhU,1011
|
|
11
|
+
ircbot/bot.py,sha256=KQhANH06TkSBetOthlOiT9Y0fuaOzGCgg2oI7ZKANCY,9265
|
|
12
|
+
ircbot/commands.py,sha256=nyPmFgJceRuZKNKP7bD45DcwiLRlPY7QNWGD5wJnqCI,1789
|
|
13
|
+
ircbot/config.py,sha256=4GL5EXjHJlMd9yQX-yAK2vBJwAuab-7X-R8hsrn9cx8,2078
|
|
14
|
+
ircbot/connection.py,sha256=yMvpALcTxAySWiSEMOGB8jOdHxq3O5EN9_2rOZFBtRA,4066
|
|
15
|
+
ircbot/protocol.py,sha256=_ytYwsLS56FHVbXxn1HsO5-I9yFtsykbSSo7bztJxqw,2725
|
|
16
|
+
agentirc-1.0.0.dist-info/METADATA,sha256=WxIezdIRn87Fg2h_cCwvoVm64O9jUdRsLjF7VXslfnQ,3862
|
|
17
|
+
agentirc-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
18
|
+
agentirc-1.0.0.dist-info/entry_points.txt,sha256=4NGgML7IAdbX-kAWU-sbDnIGr6GBZmABuFQAcJXW2cA,52
|
|
19
|
+
agentirc-1.0.0.dist-info/licenses/LICENSE,sha256=Z7RUccYKYBtqgZ6na1vsF5vZO7NYP9EoD9Km9pGE_Xo,1069
|
|
20
|
+
agentirc-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dustin Whyte
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
ircbot/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""ircbot -- async IRC bot framework, stdlib only."""
|
|
2
|
+
|
|
3
|
+
from .bot import IRCBot
|
|
4
|
+
from .config import BotConfig, load_env
|
|
5
|
+
from .protocol import IRCMessage, parse
|
|
6
|
+
from .commands import register_builtins
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"IRCBot",
|
|
10
|
+
"BotConfig",
|
|
11
|
+
"IRCMessage",
|
|
12
|
+
"load_env",
|
|
13
|
+
"parse",
|
|
14
|
+
"register_builtins",
|
|
15
|
+
]
|
ircbot/__main__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Entry point: python -m ircbot"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from .config import load_env, BotConfig
|
|
10
|
+
from .bot import IRCBot
|
|
11
|
+
from .commands import register_builtins
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def setup_logging() -> None:
|
|
15
|
+
"""Configure stdlib logging with a clean format."""
|
|
16
|
+
logging.basicConfig(
|
|
17
|
+
level=logging.INFO,
|
|
18
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
19
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
20
|
+
stream=sys.stderr,
|
|
21
|
+
)
|
|
22
|
+
# Quiet down the debug-level line logging unless DEBUG is set
|
|
23
|
+
logging.getLogger("ircbot.connection").setLevel(
|
|
24
|
+
logging.DEBUG if "--debug" in sys.argv else logging.INFO
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def main() -> None:
|
|
29
|
+
load_env()
|
|
30
|
+
config = BotConfig.from_env()
|
|
31
|
+
bot = IRCBot(config)
|
|
32
|
+
register_builtins(bot)
|
|
33
|
+
await bot.run()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if __name__ == "__main__":
|
|
37
|
+
setup_logging()
|
|
38
|
+
try:
|
|
39
|
+
asyncio.run(main())
|
|
40
|
+
except KeyboardInterrupt:
|
|
41
|
+
logging.getLogger(__name__).info("Shutting down.")
|