agent-mini 0.1.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.
- agent_mini/__init__.py +3 -0
- agent_mini/__main__.py +6 -0
- agent_mini/agent/__init__.py +7 -0
- agent_mini/agent/context.py +48 -0
- agent_mini/agent/loop.py +372 -0
- agent_mini/agent/memory.py +152 -0
- agent_mini/agent/token_estimator.py +105 -0
- agent_mini/agent/tools.py +582 -0
- agent_mini/agent/vision.py +80 -0
- agent_mini/bus.py +45 -0
- agent_mini/channels/__init__.py +11 -0
- agent_mini/channels/base.py +36 -0
- agent_mini/channels/telegram.py +164 -0
- agent_mini/cli.py +621 -0
- agent_mini/config.py +161 -0
- agent_mini/providers/__init__.py +57 -0
- agent_mini/providers/base.py +147 -0
- agent_mini/providers/gemini.py +257 -0
- agent_mini/providers/github_copilot.py +253 -0
- agent_mini/providers/local.py +158 -0
- agent_mini/providers/ollama.py +171 -0
- agent_mini/sessions.py +95 -0
- agent_mini-0.1.0.dist-info/METADATA +530 -0
- agent_mini-0.1.0.dist-info/RECORD +27 -0
- agent_mini-0.1.0.dist-info/WHEEL +4 -0
- agent_mini-0.1.0.dist-info/entry_points.txt +2 -0
- agent_mini-0.1.0.dist-info/licenses/LICENSE +21 -0
agent_mini/__init__.py
ADDED
agent_mini/__main__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""System prompt builder."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .memory import Memory
|
|
9
|
+
|
|
10
|
+
_SYSTEM_PROMPT_TEMPLATE = """\
|
|
11
|
+
You are Agent Mini, a personal AI assistant with tools for shell commands, \
|
|
12
|
+
file I/O, web search/fetch, and persistent memory.
|
|
13
|
+
|
|
14
|
+
Date: {date}
|
|
15
|
+
Workspace: {workspace}
|
|
16
|
+
|
|
17
|
+
<rules>
|
|
18
|
+
- ALWAYS read a file before editing it.
|
|
19
|
+
- Use tools to act directly — never say "I would run…" when you can just run it.
|
|
20
|
+
- If a tool fails, read the error and try a different approach. Retry at least twice.
|
|
21
|
+
- Verify changes (read back files, run tests, check output).
|
|
22
|
+
- Be concise. Skip preamble.
|
|
23
|
+
- Use memory_store/memory_recall for user preferences and project context.
|
|
24
|
+
</rules>
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_system_prompt(config: dict, memory: Memory) -> str:
|
|
29
|
+
"""Render the full system prompt with live context."""
|
|
30
|
+
workspace = Path(config.get("workspace", "~/.agent-mini/workspace")).expanduser()
|
|
31
|
+
|
|
32
|
+
# Use safe substitution to avoid format string injection from user config
|
|
33
|
+
prompt = _SYSTEM_PROMPT_TEMPLATE.format(
|
|
34
|
+
date=datetime.now().strftime("%Y-%m-%d %H:%M"),
|
|
35
|
+
workspace=str(workspace),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
custom = config.get("agent", {}).get("systemPrompt", "")
|
|
39
|
+
if custom:
|
|
40
|
+
# Append directly — no .format() call on user-controlled content
|
|
41
|
+
prompt += f"\n## User instructions\n{custom}\n"
|
|
42
|
+
|
|
43
|
+
recent = memory.get_recent(5)
|
|
44
|
+
if recent:
|
|
45
|
+
items = "\n".join(f"- {m['key']}: {m['value']}" for m in recent)
|
|
46
|
+
prompt += f"\n## Recent memories\n{items}\n"
|
|
47
|
+
|
|
48
|
+
return prompt.strip()
|
agent_mini/agent/loop.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Core agent loop — think → act → observe → repeat."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import random
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Awaitable, Callable
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from ..providers.base import BaseProvider
|
|
16
|
+
from .context import build_system_prompt
|
|
17
|
+
from .memory import Memory
|
|
18
|
+
from .token_estimator import (
|
|
19
|
+
classify_model_tier,
|
|
20
|
+
estimate_messages_tokens,
|
|
21
|
+
get_effective_context,
|
|
22
|
+
get_output_limit,
|
|
23
|
+
get_tier_max_iterations,
|
|
24
|
+
)
|
|
25
|
+
from .tools import ToolExecutor
|
|
26
|
+
from .vision import build_image_content_parts
|
|
27
|
+
|
|
28
|
+
log = logging.getLogger("agent-mini")
|
|
29
|
+
StreamEmitter = Callable[[str], Awaitable[None]]
|
|
30
|
+
|
|
31
|
+
# HTTP status codes considered transient (worth retrying)
|
|
32
|
+
_TRANSIENT_CODES = {429, 500, 502, 503, 504}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ToolEvent:
|
|
37
|
+
"""Emitted when a tool is called or produces a result."""
|
|
38
|
+
name: str
|
|
39
|
+
arguments: dict | None = None
|
|
40
|
+
result_preview: str | None = None
|
|
41
|
+
is_error: bool = False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
ToolEventCallback = Callable[[ToolEvent], Awaitable[None]]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AgentLoop:
|
|
48
|
+
"""Runs the agentic ReAct loop.
|
|
49
|
+
|
|
50
|
+
1. Build messages (system + history + user message).
|
|
51
|
+
2. Call the LLM.
|
|
52
|
+
3. If the LLM returns tool calls → execute them, append results, goto 2.
|
|
53
|
+
4. If the LLM returns text → return it to the caller.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
provider: BaseProvider,
|
|
59
|
+
config: dict,
|
|
60
|
+
memory: Memory,
|
|
61
|
+
):
|
|
62
|
+
self.provider = provider
|
|
63
|
+
self.config = config
|
|
64
|
+
self.memory = memory
|
|
65
|
+
self.tools = ToolExecutor(config, memory)
|
|
66
|
+
self._model_name: str = provider.model_name
|
|
67
|
+
self._model_tier: str = classify_model_tier(self._model_name)
|
|
68
|
+
self._effective_ctx: int = get_effective_context(self._model_name)
|
|
69
|
+
self._output_limit: int = get_output_limit(self._model_name)
|
|
70
|
+
# Max iterations: respect explicit user config, otherwise use tier default
|
|
71
|
+
user_iters = config.get("agent", {}).get("maxIterations")
|
|
72
|
+
self.max_iterations: int = (
|
|
73
|
+
user_iters if user_iters is not None
|
|
74
|
+
else get_tier_max_iterations(self._model_name)
|
|
75
|
+
)
|
|
76
|
+
self.temperature: float = config.get("agent", {}).get("temperature", 0.7)
|
|
77
|
+
# Token/cost tracking per session
|
|
78
|
+
self.session_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
|
79
|
+
self.turn_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
|
80
|
+
# Loop detection state
|
|
81
|
+
self._recent_calls: list[str] = []
|
|
82
|
+
|
|
83
|
+
async def close(self) -> None:
|
|
84
|
+
"""Clean up provider and tool resources."""
|
|
85
|
+
await self.provider.close()
|
|
86
|
+
await self.tools.close()
|
|
87
|
+
|
|
88
|
+
async def run(
|
|
89
|
+
self,
|
|
90
|
+
user_message: str,
|
|
91
|
+
conversation: list[dict],
|
|
92
|
+
on_stream: StreamEmitter | None = None,
|
|
93
|
+
on_tool_event: ToolEventCallback | None = None,
|
|
94
|
+
) -> str:
|
|
95
|
+
"""Process *user_message* and return the assistant's final text reply.
|
|
96
|
+
|
|
97
|
+
*conversation* is mutated in-place (appended with the new user/assistant
|
|
98
|
+
turns) so the caller can maintain session state.
|
|
99
|
+
When *on_stream* is provided, partial text deltas are emitted in real time.
|
|
100
|
+
"""
|
|
101
|
+
system_prompt = build_system_prompt(self.config, self.memory)
|
|
102
|
+
tool_defs = self.tools.get_tool_defs()
|
|
103
|
+
|
|
104
|
+
# Reset per-turn usage tracking
|
|
105
|
+
self.turn_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
|
106
|
+
|
|
107
|
+
# Build the full message list for this request
|
|
108
|
+
messages: list[dict] = [{"role": "system", "content": system_prompt}]
|
|
109
|
+
messages.extend(conversation)
|
|
110
|
+
|
|
111
|
+
# Build user message — detect image references for vision support
|
|
112
|
+
image_parts = build_image_content_parts(user_message)
|
|
113
|
+
if image_parts:
|
|
114
|
+
messages.append({"role": "user", "content": image_parts})
|
|
115
|
+
else:
|
|
116
|
+
messages.append({"role": "user", "content": user_message})
|
|
117
|
+
|
|
118
|
+
for iteration in range(self.max_iterations):
|
|
119
|
+
log.debug("iteration %d / %d", iteration + 1, self.max_iterations)
|
|
120
|
+
|
|
121
|
+
# Prune old tool results in-memory before sending to provider
|
|
122
|
+
pruned = self._prune_tool_results(messages)
|
|
123
|
+
|
|
124
|
+
response = await self._call_provider_with_retry(
|
|
125
|
+
pruned, tool_defs, on_stream
|
|
126
|
+
)
|
|
127
|
+
if isinstance(response, str):
|
|
128
|
+
# Error string from retry exhaustion
|
|
129
|
+
return response
|
|
130
|
+
|
|
131
|
+
# Accumulate token usage if available
|
|
132
|
+
if hasattr(response, "usage") and response.usage:
|
|
133
|
+
for key in ("prompt_tokens", "completion_tokens", "total_tokens"):
|
|
134
|
+
val = response.usage.get(key, 0)
|
|
135
|
+
self.turn_usage[key] += val
|
|
136
|
+
self.session_usage[key] += val
|
|
137
|
+
|
|
138
|
+
# ----- text-only response → done -----
|
|
139
|
+
if not response.tool_calls:
|
|
140
|
+
final = response.content or "(empty response)"
|
|
141
|
+
# Persist to conversation history
|
|
142
|
+
conversation.append({"role": "user", "content": user_message})
|
|
143
|
+
conversation.append({"role": "assistant", "content": final})
|
|
144
|
+
# Token-aware compaction: summarize when conversation
|
|
145
|
+
# exceeds 75% of the model's effective context budget.
|
|
146
|
+
current_tokens = estimate_messages_tokens(conversation)
|
|
147
|
+
if current_tokens > int(self._effective_ctx * 0.75):
|
|
148
|
+
await self._summarize_history(conversation)
|
|
149
|
+
return final
|
|
150
|
+
|
|
151
|
+
# ----- tool calls → execute in parallel and continue -----
|
|
152
|
+
assistant_msg: dict = {
|
|
153
|
+
"role": "assistant",
|
|
154
|
+
"content": response.content or "",
|
|
155
|
+
"tool_calls": [
|
|
156
|
+
{
|
|
157
|
+
"id": tc.id,
|
|
158
|
+
"type": "function",
|
|
159
|
+
"function": {
|
|
160
|
+
"name": tc.name,
|
|
161
|
+
"arguments": (
|
|
162
|
+
json.dumps(tc.arguments)
|
|
163
|
+
if isinstance(tc.arguments, dict)
|
|
164
|
+
else tc.arguments
|
|
165
|
+
),
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
for tc in response.tool_calls
|
|
169
|
+
],
|
|
170
|
+
}
|
|
171
|
+
messages.append(assistant_msg)
|
|
172
|
+
|
|
173
|
+
# Execute all tool calls concurrently
|
|
174
|
+
async def _exec(tc):
|
|
175
|
+
log.info(
|
|
176
|
+
"🔧 %s(%s)",
|
|
177
|
+
tc.name,
|
|
178
|
+
json.dumps(tc.arguments, ensure_ascii=False)[:200],
|
|
179
|
+
)
|
|
180
|
+
if on_tool_event:
|
|
181
|
+
await on_tool_event(ToolEvent(name=tc.name, arguments=tc.arguments))
|
|
182
|
+
result = await self.tools.execute(tc.name, tc.arguments)
|
|
183
|
+
log.debug(" → %s", result[:300])
|
|
184
|
+
if on_tool_event:
|
|
185
|
+
preview = result[:200]
|
|
186
|
+
await on_tool_event(ToolEvent(
|
|
187
|
+
name=tc.name,
|
|
188
|
+
result_preview=preview,
|
|
189
|
+
is_error=result.startswith("Error:"),
|
|
190
|
+
))
|
|
191
|
+
return tc, result
|
|
192
|
+
|
|
193
|
+
results = await asyncio.gather(
|
|
194
|
+
*[_exec(tc) for tc in response.tool_calls]
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
for tc, result in results:
|
|
198
|
+
# Compress tool output based on model tier
|
|
199
|
+
if len(result) > self._output_limit:
|
|
200
|
+
head = self._output_limit * 2 // 3
|
|
201
|
+
tail = self._output_limit // 3
|
|
202
|
+
result = (
|
|
203
|
+
result[:head]
|
|
204
|
+
+ f"\n\n[... truncated {len(result) - head - tail} chars ...]\n\n"
|
|
205
|
+
+ result[-tail:]
|
|
206
|
+
)
|
|
207
|
+
# Self-reflection on errors: nudge the LLM to reason about failures
|
|
208
|
+
if result.startswith("Error:"):
|
|
209
|
+
result = (
|
|
210
|
+
f"{result}\n\n"
|
|
211
|
+
"[The tool call failed. Analyze what went wrong "
|
|
212
|
+
"and try a different approach.]"
|
|
213
|
+
)
|
|
214
|
+
messages.append(
|
|
215
|
+
{
|
|
216
|
+
"role": "tool",
|
|
217
|
+
"tool_call_id": tc.id,
|
|
218
|
+
"name": tc.name,
|
|
219
|
+
"content": result,
|
|
220
|
+
}
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# Loop detection: identical consecutive tool calls → nudge
|
|
224
|
+
for tc, _result in results:
|
|
225
|
+
sig = hashlib.md5(
|
|
226
|
+
json.dumps({"n": tc.name, "a": tc.arguments}, sort_keys=True).encode()
|
|
227
|
+
).hexdigest()
|
|
228
|
+
self._recent_calls.append(sig)
|
|
229
|
+
# Keep a sliding window of last few signatures
|
|
230
|
+
self._recent_calls = self._recent_calls[-6:]
|
|
231
|
+
if len(self._recent_calls) >= 4:
|
|
232
|
+
last4 = self._recent_calls[-4:]
|
|
233
|
+
if last4[0] == last4[1] == last4[2] == last4[3]:
|
|
234
|
+
messages.append({
|
|
235
|
+
"role": "user",
|
|
236
|
+
"content": (
|
|
237
|
+
"You have repeated the same tool call multiple times "
|
|
238
|
+
"with the same result. Try a completely different approach."
|
|
239
|
+
),
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
return "Reached maximum iterations. Please try a simpler request."
|
|
243
|
+
|
|
244
|
+
def _prune_tool_results(self, messages: list[dict]) -> list[dict]:
|
|
245
|
+
"""Return a copy of *messages* with old tool results trimmed.
|
|
246
|
+
|
|
247
|
+
Protects the last 3 assistant turns and their tool results.
|
|
248
|
+
Older tool messages get soft-trimmed (head+tail) or replaced
|
|
249
|
+
with a placeholder. The original list is never mutated.
|
|
250
|
+
"""
|
|
251
|
+
# Find positions of the last 3 assistant messages
|
|
252
|
+
asst_indices = [i for i, m in enumerate(messages) if m.get("role") == "assistant"]
|
|
253
|
+
cutoff = asst_indices[-3] if len(asst_indices) >= 3 else 0
|
|
254
|
+
|
|
255
|
+
pruned: list[dict] = []
|
|
256
|
+
for i, msg in enumerate(messages):
|
|
257
|
+
if i >= cutoff or msg.get("role") != "tool":
|
|
258
|
+
pruned.append(msg)
|
|
259
|
+
continue
|
|
260
|
+
content = msg.get("content", "")
|
|
261
|
+
if len(content) <= 4000:
|
|
262
|
+
pruned.append(msg)
|
|
263
|
+
elif i >= cutoff - 6:
|
|
264
|
+
# Soft-trim: keep head + tail
|
|
265
|
+
trimmed = content[:1500] + "\n...\n" + content[-1500:]
|
|
266
|
+
pruned.append({**msg, "content": trimmed})
|
|
267
|
+
else:
|
|
268
|
+
# Hard-clear: replace with placeholder
|
|
269
|
+
pruned.append({**msg, "content": "[Old tool result cleared to save context]"})
|
|
270
|
+
return pruned
|
|
271
|
+
|
|
272
|
+
async def _summarize_history(self, conversation: list[dict]) -> None:
|
|
273
|
+
"""Summarize oldest messages in-place to keep context bounded."""
|
|
274
|
+
# Determine how many messages to summarize: enough to drop below
|
|
275
|
+
# 50% of effective context, but at least 6 messages.
|
|
276
|
+
target = int(self._effective_ctx * 0.5)
|
|
277
|
+
tokens_so_far = 0
|
|
278
|
+
cut = 0
|
|
279
|
+
for i, msg in enumerate(conversation):
|
|
280
|
+
content = msg.get("content", "")
|
|
281
|
+
tokens_so_far += len(content) // 4 + 4 if isinstance(content, str) else 50
|
|
282
|
+
if tokens_so_far > (estimate_messages_tokens(conversation) - target):
|
|
283
|
+
cut = max(i, 6)
|
|
284
|
+
break
|
|
285
|
+
if cut < 6:
|
|
286
|
+
cut = min(20, len(conversation) - 4) # fallback: oldest 20, keep last 4
|
|
287
|
+
|
|
288
|
+
to_summarize = conversation[:cut]
|
|
289
|
+
keep = conversation[cut:]
|
|
290
|
+
|
|
291
|
+
# Build a tight summarization request
|
|
292
|
+
summary_messages = [
|
|
293
|
+
{
|
|
294
|
+
"role": "system",
|
|
295
|
+
"content": (
|
|
296
|
+
"Summarize in under 200 words. Keep file paths, error "
|
|
297
|
+
"messages, code snippets, and key decisions verbatim. "
|
|
298
|
+
"State what was done and what remains."
|
|
299
|
+
),
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
"role": "user",
|
|
303
|
+
"content": "\n".join(
|
|
304
|
+
f"[{m['role']}]: {m.get('content', '')[:500]}"
|
|
305
|
+
for m in to_summarize
|
|
306
|
+
if m.get("content")
|
|
307
|
+
),
|
|
308
|
+
},
|
|
309
|
+
]
|
|
310
|
+
|
|
311
|
+
try:
|
|
312
|
+
response = await self.provider.chat(
|
|
313
|
+
summary_messages, tools=None, temperature=0.3
|
|
314
|
+
)
|
|
315
|
+
summary_text = response.content or "Previous conversation context."
|
|
316
|
+
except Exception as e:
|
|
317
|
+
log.warning("Failed to summarize history: %s", e)
|
|
318
|
+
return
|
|
319
|
+
|
|
320
|
+
new_conversation = [
|
|
321
|
+
{
|
|
322
|
+
"role": "system",
|
|
323
|
+
"content": f"[Previous context summary]\n{summary_text}",
|
|
324
|
+
},
|
|
325
|
+
]
|
|
326
|
+
new_conversation.extend(keep)
|
|
327
|
+
conversation.clear()
|
|
328
|
+
conversation.extend(new_conversation)
|
|
329
|
+
|
|
330
|
+
async def _call_provider_with_retry(self, messages, tool_defs, on_stream):
|
|
331
|
+
"""Call the provider with retry + exponential backoff (with jitter) for transient errors."""
|
|
332
|
+
max_retries = 3
|
|
333
|
+
for attempt in range(max_retries):
|
|
334
|
+
try:
|
|
335
|
+
if on_stream:
|
|
336
|
+
return await self.provider.chat_stream(
|
|
337
|
+
messages,
|
|
338
|
+
on_delta=on_stream,
|
|
339
|
+
tools=tool_defs or None,
|
|
340
|
+
temperature=self.temperature,
|
|
341
|
+
)
|
|
342
|
+
else:
|
|
343
|
+
return await self.provider.chat(
|
|
344
|
+
messages,
|
|
345
|
+
tools=tool_defs or None,
|
|
346
|
+
temperature=self.temperature,
|
|
347
|
+
)
|
|
348
|
+
except httpx.HTTPStatusError as e:
|
|
349
|
+
if e.response.status_code in _TRANSIENT_CODES and attempt < max_retries - 1:
|
|
350
|
+
wait = (2 ** attempt) + random.uniform(0, 1) # jitter
|
|
351
|
+
log.warning(
|
|
352
|
+
"Transient HTTP %d, retrying in %.1fs (attempt %d/%d)",
|
|
353
|
+
e.response.status_code, wait, attempt + 1, max_retries,
|
|
354
|
+
)
|
|
355
|
+
await asyncio.sleep(wait)
|
|
356
|
+
continue
|
|
357
|
+
log.error("Provider error: %s", e)
|
|
358
|
+
return f"Error communicating with LLM: {e}"
|
|
359
|
+
except (httpx.ConnectError, httpx.ReadTimeout, OSError) as e:
|
|
360
|
+
if attempt < max_retries - 1:
|
|
361
|
+
wait = (2 ** attempt) + random.uniform(0, 1) # jitter
|
|
362
|
+
log.warning(
|
|
363
|
+
"Connection error, retrying in %.1fs (attempt %d/%d): %s",
|
|
364
|
+
wait, attempt + 1, max_retries, e,
|
|
365
|
+
)
|
|
366
|
+
await asyncio.sleep(wait)
|
|
367
|
+
continue
|
|
368
|
+
log.error("Provider error after %d attempts: %s", max_retries, e)
|
|
369
|
+
return f"Error communicating with LLM: {e}"
|
|
370
|
+
except Exception as e:
|
|
371
|
+
log.error("Provider error: %s", e)
|
|
372
|
+
return f"Error communicating with LLM: {e}"
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Persistent memory — simple JSON-backed key/value store with fuzzy search."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import math
|
|
8
|
+
import re
|
|
9
|
+
from collections import Counter
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger("agent-mini")
|
|
14
|
+
|
|
15
|
+
# ---------- Minimal Porter-style stemmer (covers common suffixes) ----------
|
|
16
|
+
|
|
17
|
+
_SUFFIX_RULES = [
|
|
18
|
+
("ational", "ate"), ("tional", "tion"), ("enci", "ence"),
|
|
19
|
+
("anci", "ance"), ("izer", "ize"), ("isation", "ize"),
|
|
20
|
+
("ization", "ize"), ("ation", "ate"), ("ator", "ate"),
|
|
21
|
+
("alism", "al"), ("iveness", "ive"), ("fulness", "ful"),
|
|
22
|
+
("ousness", "ous"), ("aliti", "al"), ("iviti", "ive"),
|
|
23
|
+
("biliti", "ble"), ("ling", "l"),
|
|
24
|
+
("ing", ""), ("ment", ""), ("ness", ""), ("ity", ""),
|
|
25
|
+
("ies", "y"), ("es", "e"), ("ed", ""), ("ly", ""), ("s", ""),
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _stem(word: str) -> str:
|
|
30
|
+
"""Very lightweight suffix stripping — handles 80 %+ of common English inflections."""
|
|
31
|
+
if len(word) <= 3:
|
|
32
|
+
return word
|
|
33
|
+
for suffix, replacement in _SUFFIX_RULES:
|
|
34
|
+
if word.endswith(suffix) and len(word) - len(suffix) + len(replacement) >= 3:
|
|
35
|
+
return word[: -len(suffix)] + replacement
|
|
36
|
+
return word
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _tokenize(text: str) -> list[str]:
|
|
40
|
+
"""Lowercase, tokenize, and stem."""
|
|
41
|
+
words = re.findall(r"[a-z0-9]+", text.lower())
|
|
42
|
+
return [_stem(w) for w in words if len(w) > 1]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Memory:
|
|
46
|
+
"""Lightweight persistent memory.
|
|
47
|
+
|
|
48
|
+
Stores entries as ``{key, value, timestamp}`` dicts in a JSON file.
|
|
49
|
+
Recall uses simple keyword matching — no embedding model required.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, filepath: Path, max_entries: int = 1000):
|
|
53
|
+
self.filepath = filepath
|
|
54
|
+
self.max_entries = max_entries
|
|
55
|
+
self._data: list[dict] = []
|
|
56
|
+
self._load()
|
|
57
|
+
|
|
58
|
+
# ------------------------------------------------------------------
|
|
59
|
+
# Persistence
|
|
60
|
+
# ------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def _load(self) -> None:
|
|
63
|
+
if self.filepath.exists():
|
|
64
|
+
try:
|
|
65
|
+
self._data = json.loads(self.filepath.read_text())
|
|
66
|
+
except json.JSONDecodeError:
|
|
67
|
+
log.warning("Corrupted memory file %s, starting fresh", self.filepath)
|
|
68
|
+
self._data = []
|
|
69
|
+
except OSError as e:
|
|
70
|
+
log.error("Cannot read memory file %s: %s", self.filepath, e)
|
|
71
|
+
self._data = []
|
|
72
|
+
|
|
73
|
+
def _save(self) -> None:
|
|
74
|
+
self.filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
self.filepath.write_text(json.dumps(self._data, indent=2, ensure_ascii=False))
|
|
76
|
+
|
|
77
|
+
# ------------------------------------------------------------------
|
|
78
|
+
# Public API (called by tools)
|
|
79
|
+
# ------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def store(self, key: str, value: str) -> str:
|
|
82
|
+
"""Store a key/value pair. Returns confirmation string."""
|
|
83
|
+
entry = {
|
|
84
|
+
"key": key,
|
|
85
|
+
"value": value,
|
|
86
|
+
"timestamp": datetime.now().isoformat(),
|
|
87
|
+
}
|
|
88
|
+
self._data.append(entry)
|
|
89
|
+
if len(self._data) > self.max_entries:
|
|
90
|
+
self._data = self._data[-self.max_entries :]
|
|
91
|
+
self._save()
|
|
92
|
+
return f"Stored memory: {key}"
|
|
93
|
+
|
|
94
|
+
def recall(self, query: str) -> str:
|
|
95
|
+
"""TF-IDF fuzzy search across stored memories."""
|
|
96
|
+
if not self._data:
|
|
97
|
+
return "No matching memories found."
|
|
98
|
+
|
|
99
|
+
query_tokens = _tokenize(query)
|
|
100
|
+
if not query_tokens:
|
|
101
|
+
return "No matching memories found."
|
|
102
|
+
|
|
103
|
+
# Build document token lists
|
|
104
|
+
docs: list[tuple[dict, list[str]]] = []
|
|
105
|
+
for entry in self._data:
|
|
106
|
+
text = f"{entry['key']} {entry['value']}"
|
|
107
|
+
tokens = _tokenize(text)
|
|
108
|
+
if tokens:
|
|
109
|
+
docs.append((entry, tokens))
|
|
110
|
+
|
|
111
|
+
if not docs:
|
|
112
|
+
return "No matching memories found."
|
|
113
|
+
|
|
114
|
+
# IDF: log(N / df) for each term in the query
|
|
115
|
+
n = len(docs)
|
|
116
|
+
df: Counter = Counter()
|
|
117
|
+
for _, tokens in docs:
|
|
118
|
+
unique = set(tokens)
|
|
119
|
+
for t in unique:
|
|
120
|
+
df[t] += 1
|
|
121
|
+
|
|
122
|
+
idf = {t: math.log((n + 1) / (df.get(t, 0) + 1)) + 1 for t in query_tokens}
|
|
123
|
+
|
|
124
|
+
# Score each document
|
|
125
|
+
scored: list[tuple[float, dict]] = []
|
|
126
|
+
for entry, tokens in docs:
|
|
127
|
+
tf = Counter(tokens)
|
|
128
|
+
doc_len = len(tokens)
|
|
129
|
+
score = 0.0
|
|
130
|
+
for qt in query_tokens:
|
|
131
|
+
# TF-IDF with length normalization
|
|
132
|
+
score += (tf.get(qt, 0) / doc_len) * idf.get(qt, 0)
|
|
133
|
+
# Substring fallback: partial match bonus
|
|
134
|
+
for token in set(tokens):
|
|
135
|
+
if qt in token or token in qt:
|
|
136
|
+
score += 0.3 * idf.get(qt, 0) / doc_len
|
|
137
|
+
if score > 0:
|
|
138
|
+
scored.append((score, entry))
|
|
139
|
+
|
|
140
|
+
scored.sort(key=lambda x: x[0], reverse=True)
|
|
141
|
+
|
|
142
|
+
if not scored:
|
|
143
|
+
return "No matching memories found."
|
|
144
|
+
|
|
145
|
+
lines = []
|
|
146
|
+
for _, entry in scored[:10]:
|
|
147
|
+
lines.append(f"[{entry['timestamp']}] {entry['key']}: {entry['value']}")
|
|
148
|
+
return "\n".join(lines)
|
|
149
|
+
|
|
150
|
+
def get_recent(self, n: int = 5) -> list[dict]:
|
|
151
|
+
"""Return the *n* most recent entries (for system prompt context)."""
|
|
152
|
+
return self._data[-n:]
|