mforege 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.
app/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """MForege — a personal AI agent CLI with tools, memory, and an IDE-style terminal UI."""
2
+
3
+ __version__ = "0.1.0"
app/agent/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """
2
+ AI Agent Package
3
+ """
4
+ from ..llm import LLMClient
5
+ from .agent import Agent, AgentConfig
6
+ from .memory import ConversationMemory, Message
7
+ from .long_term_memory import LongTermMemory
8
+ from .tools import Tool, ToolRegistry, CalculatorTool, TimeTool
9
+
10
+ __all__ = ["Agent", "AgentConfig", "LLMClient", "ConversationMemory", "Message", "LongTermMemory", "Tool", "ToolRegistry", "CalculatorTool", "TimeTool"]
app/agent/agent.py ADDED
@@ -0,0 +1,431 @@
1
+ """
2
+ AI Agent Implementation
3
+ ========================
4
+ A conversational AI agent with support for multiple backends:
5
+ - OpenAI API (paid)
6
+ - Ollama (free, local)
7
+ - Other OpenAI-compatible APIs
8
+
9
+ Features:
10
+ - Full tool-use loop (tool results are sent back to the model)
11
+ - Streaming responses (including tool calls)
12
+ - Conversation memory management
13
+ """
14
+
15
+ import asyncio
16
+ import json
17
+ import time
18
+ from typing import Optional, AsyncGenerator, Any, Callable, List, Dict, Tuple, Union
19
+ from pydantic import BaseModel
20
+
21
+ from ..llm import LLMClient
22
+ from .memory import ConversationMemory, Message
23
+ from .tools import Tool, ToolRegistry
24
+ from .long_term_memory import LongTermMemory, extract_facts
25
+
26
+ # How many model<->tool round trips a single chat() call may make by default
27
+ # (configurable per-agent via AgentConfig.max_tool_rounds or MAX_TOOL_ROUNDS env)
28
+ MAX_TOOL_ROUNDS = 10
29
+
30
+
31
+ class AgentConfig(BaseModel):
32
+ """Configuration for the AI Agent"""
33
+ model: str = "gpt-4o-mini"
34
+ temperature: float = 0.7
35
+ max_tokens: int = 4096
36
+ max_tool_rounds: int = MAX_TOOL_ROUNDS
37
+ system_prompt: str = """You are MForege, a personal AI assistant with a warm, playful personality.
38
+
39
+ # Identity
40
+ You are NOT ChatGPT, Claude, or any other assistant — never claim another identity, even if asked.
41
+ If someone asks who you are, say you are MForege, the user's personal AI assistant.
42
+
43
+ # Personality
44
+ - Friendly and upbeat: chat like a fun, smart friend, not a corporate robot.
45
+ - Witty: enjoy a clever one-liner or light joke when it fits — but the answer always comes first.
46
+ Never sacrifice clarity for comedy.
47
+ - Emojis: use them like a touch of sparkle ✨ — a few where they fit the mood (usually 1-3 per reply),
48
+ not on every line. Skip them in code blocks, error messages, and serious or sensitive topics.
49
+ - Honest and humble: if you don't know something, say so with charm and offer to figure it out
50
+ (you have tools for that!).
51
+
52
+ # Planning for multi-step tasks
53
+ For tasks with 3+ distinct steps (scaffolding, big refactors, "then fix, then test" chains),
54
+ create a visible plan first with the todo_plan tool (action "set"), keep it updated as you go
55
+ (mark steps done immediately after finishing them), and call "clear" when the mission ends.
56
+ Don't use it for simple one-shot questions.
57
+
58
+ # Response control (very important)
59
+ - Match the reply to the question. Casual chat ("give a biscuit?", "sup?") gets a short, playful
60
+ answer — one or two sentences, not an essay.
61
+ - Simple factual question → direct answer first; extra detail only if it clearly helps.
62
+ - Big requests ("build a project", "teach me X") → give a brief overview (a few bullets) and ask
63
+ which part to dive into. NEVER dump a giant tutorial unprompted.
64
+ - Ambiguous question (e.g., "what is apple?" — fruit 🍎 or company 💻?) → ask ONE short clarifying
65
+ question, or answer the most likely meaning and offer the other.
66
+ - No padding or filler conclusions ("I hope this helps!"). Answer, then stop.
67
+
68
+ # Acting on the user's machine (agentic tools)
69
+ You have tools to inspect and modify the user's project: list_files, read_file, run_command,
70
+ create_file, edit_file, search_code, glob_files.
71
+ - Search-first workflow: before touching anything, understand the project. Use glob_files to
72
+ discover files (e.g., '**/*.py'), search_code to find where things are defined or where an
73
+ error string originates, and read_file (with offset/limit for big files) to study the code.
74
+ - You operate inside ONE workspace folder, chosen by the user at startup (shown in the header).
75
+ All tool paths are relative to it. If the user names a folder outside this workspace, DO NOT
76
+ say you "can't" or call it a sandbox limitation — instead say: "restart me with
77
+ --workspace <that folder> and I'll work there", then offer to proceed in the current
78
+ workspace meanwhile.
79
+ - Relative paths in conversation (e.g., "in the town folder") are fine — tools resolve them
80
+ inside the workspace. Absolute paths outside it are refused by design.
81
+ - Before creating anything the user didn't fully specify (project name, location, stack), ask ONE
82
+ short clarifying question or propose sensible defaults and ask "shall I proceed?". NEVER invent
83
+ details and act on them silently.
84
+ - Prefer list_files/read_file to ground answers in the actual project before guessing.
85
+ - Use create_file for new files (small plan first for big scaffolds); use edit_file for existing
86
+ ones — read the file first, then replace an exact unique snippet.
87
+ - Every write/edit asks the user for confirmation — that's by design, don't try to bypass it
88
+ (e.g., don't echo file content through run_command to write files).
89
+ - When running commands, explain briefly what you're about to run and why. If the user cancels,
90
+ accept it gracefully and ask what they'd prefer.
91
+ - Long build/install commands may take a while — warn the user before starting them.
92
+
93
+ # Tools
94
+ - Prefer tools when they make the answer accurate: web_search for anything current or uncertain,
95
+ calculator for math, get_current_time for dates/times.
96
+ - When using a tool, briefly say what you're doing (e.g., "Crunching the numbers... 🧮").
97
+ - If a needed tool is unavailable (e.g., search not configured), say so honestly instead of guessing.
98
+ - Maintain context across the conversation and offer helpful follow-ups — briefly."""
99
+ streaming: bool = True
100
+
101
+
102
+ class Agent:
103
+ """
104
+ AI Agent with tool use capabilities.
105
+
106
+ Supports multiple LLM backends:
107
+ - openai: OpenAI API (requires API key)
108
+ - ollama: Local Ollama (free, no API key needed)
109
+ - custom: Any OpenAI-compatible API
110
+
111
+ Features:
112
+ - Full tool loop: when the model requests a tool, the tool runs and its
113
+ result is sent back so the model can incorporate it into the answer
114
+ - Supports streaming responses (tool calls included)
115
+ - Maintains conversation memory
116
+ """
117
+
118
+ def __init__(self, config: Optional[AgentConfig] = None, api_key: Optional[str] = None,
119
+ backend: str = "openai", base_url: Optional[str] = None,
120
+ memory_path: Optional[str] = None):
121
+ self.config = config or AgentConfig()
122
+ self.llm = LLMClient(
123
+ backend=backend,
124
+ api_key=api_key,
125
+ base_url=base_url,
126
+ model=self.config.model
127
+ )
128
+ self.memory = ConversationMemory()
129
+ self.tools = ToolRegistry()
130
+ self.long_term_memory = LongTermMemory(path=memory_path)
131
+ self._pending_exchange: Optional[Tuple[str, str]] = None # (user msg, reply) awaiting memory extraction
132
+ self.on_activity: Optional[Callable[[str, str], None]] = None # (event, detail) -> UI hook
133
+
134
+ def _emit(self, event: str, detail: str = "") -> None:
135
+ """Notify the UI of agent activity (tool calls, rounds). Never raises."""
136
+ if self.on_activity is None:
137
+ return
138
+ try:
139
+ self.on_activity(event, detail)
140
+ except Exception:
141
+ pass
142
+
143
+ def add_tool(self, tool: Tool) -> None:
144
+ """Register a tool for the agent to use"""
145
+ self.tools.register(tool)
146
+
147
+ def register_tools(self, *tools: Tool) -> None:
148
+ """Register multiple tools at once"""
149
+ for tool in tools:
150
+ self.add_tool(tool)
151
+
152
+ async def chat(self, message: str, stream: Optional[bool] = None) -> Union[str, AsyncGenerator[str, None]]:
153
+ """
154
+ Send a message to the agent and get a response.
155
+
156
+ Args:
157
+ message: User's message
158
+ stream: Whether to stream the response (default: use config setting)
159
+
160
+ Returns:
161
+ If streaming: async generator yielding text chunks
162
+ If not streaming: complete response string
163
+ """
164
+ # Add user message to memory
165
+ # First, remember anything durable from the previous exchange
166
+ await self._process_pending_memory()
167
+
168
+ self.memory.add(Message(role="user", content=message))
169
+
170
+ stream_mode = stream if stream is not None else self.config.streaming
171
+
172
+ if stream_mode:
173
+ return self._stream_response(user_message=message)
174
+
175
+ reply = await self._get_response()
176
+ self._pending_exchange = (message, reply)
177
+ await self._process_pending_memory() # remember this exchange immediately
178
+ return reply
179
+
180
+ async def flush_memory(self) -> None:
181
+ """Force extraction of any pending exchange (call before shutdown)"""
182
+ await self._process_pending_memory()
183
+
184
+ async def _process_pending_memory(self) -> None:
185
+ """Extract durable facts from the pending exchange (best-effort)"""
186
+ if not self._pending_exchange:
187
+ return
188
+ user_msg, reply = self._pending_exchange
189
+ self._pending_exchange = None
190
+ try:
191
+ new_facts = await extract_facts(
192
+ self.llm, self.config.model, user_msg, reply,
193
+ self.long_term_memory.all(),
194
+ )
195
+ except Exception:
196
+ return # memory is best-effort; never disrupt chatting
197
+ for fact in new_facts:
198
+ self.long_term_memory.add(fact)
199
+
200
+ def _build_api_messages(self) -> List[Dict]:
201
+ """Build the message list sent to the API (system prompt + plan + memories + history)"""
202
+ system_prompt = self.long_term_memory.inject_into(self.config.system_prompt)
203
+ plan = getattr(self, "plan_state", None)
204
+ if plan is not None and plan.steps:
205
+ system_prompt += (
206
+ "\n\n# Current mission plan (use todo_plan to update it as you progress)\n"
207
+ + plan.render()
208
+ )
209
+ return [
210
+ {"role": "system", "content": system_prompt},
211
+ *self.memory.to_openai_format(),
212
+ ]
213
+
214
+ def _tools_schema(self) -> Optional[List[Dict]]:
215
+ return self.tools.to_openai_schema() if self.tools.tools else None
216
+
217
+ # ── Non-streaming path ─────────────────────────────
218
+ async def _get_response(self) -> str:
219
+ """Run the tool loop until the model produces a final text answer"""
220
+ messages = self._build_api_messages()
221
+ tools_schema = self._tools_schema()
222
+
223
+ for round_num in range(1, self.config.max_tool_rounds + 1):
224
+ if round_num > 1:
225
+ self._emit("round", f"{round_num}/{self.config.max_tool_rounds}")
226
+ response = await self.llm.create_chat_completion(
227
+ model=self.config.model,
228
+ messages=messages,
229
+ temperature=self.config.temperature,
230
+ max_tokens=self.config.max_tokens,
231
+ tools=tools_schema,
232
+ tool_choice="auto" if tools_schema else None
233
+ )
234
+
235
+ msg = response.choices[0].message
236
+
237
+ if not msg.tool_calls:
238
+ content = msg.content or ""
239
+ self.memory.add(Message(role="assistant", content=content))
240
+ return content
241
+
242
+ # Record the assistant's tool-call request, run the tools,
243
+ # then feed the results back to the model.
244
+ messages.append({
245
+ "role": "assistant",
246
+ "content": msg.content or "",
247
+ "tool_calls": [
248
+ {
249
+ "id": tc.id,
250
+ "type": "function",
251
+ "function": {
252
+ "name": tc.function.name,
253
+ "arguments": tc.function.arguments,
254
+ },
255
+ }
256
+ for tc in msg.tool_calls
257
+ ],
258
+ })
259
+ tool_results = await self._run_tool_calls(msg.tool_calls)
260
+ messages.extend(tool_results)
261
+
262
+ # Model kept requesting tools past the limit; force a final answer
263
+ response = await self.llm.create_chat_completion(
264
+ model=self.config.model,
265
+ messages=messages,
266
+ temperature=self.config.temperature,
267
+ max_tokens=self.config.max_tokens,
268
+ )
269
+ content = response.choices[0].message.content or ""
270
+ self.memory.add(Message(role="assistant", content=content))
271
+ return content
272
+
273
+ # ── Streaming path ─────────────────────────────────
274
+
275
+ async def _stream_response(self, user_message: str = "") -> AsyncGenerator[str, None]:
276
+ """
277
+ Stream a response, handling tool calls if they occur.
278
+
279
+ If the model requests tools mid-stream, the tools run and a new
280
+ streamed completion is started; the generator yields the final
281
+ text answer (streamed chunk by chunk).
282
+ """
283
+ messages = self._build_api_messages()
284
+ tools_schema = self._tools_schema()
285
+ full_content = ""
286
+ saved = False
287
+
288
+ try:
289
+ for round_num in range(1, self.config.max_tool_rounds + 1):
290
+ if round_num > 1:
291
+ self._emit("round", f"{round_num}/{self.config.max_tool_rounds}")
292
+ response = await self.llm.create_chat_completion(
293
+ model=self.config.model,
294
+ messages=messages,
295
+ temperature=self.config.temperature,
296
+ max_tokens=self.config.max_tokens,
297
+ stream=True,
298
+ tools=tools_schema,
299
+ tool_choice="auto" if tools_schema else None
300
+ )
301
+
302
+ round_content = ""
303
+ tool_calls: Dict[int, Dict[str, Any]] = {} # index -> {id, name, arguments}
304
+
305
+ async for chunk in response:
306
+ if not chunk.choices:
307
+ continue
308
+ delta = chunk.choices[0].delta
309
+
310
+ if delta.content:
311
+ round_content += delta.content
312
+ full_content += delta.content
313
+ yield delta.content
314
+
315
+ # Accumulate streamed tool-call fragments
316
+ if delta.tool_calls:
317
+ for tc_delta in delta.tool_calls:
318
+ entry = tool_calls.setdefault(
319
+ tc_delta.index, {"id": "", "name": "", "arguments": ""}
320
+ )
321
+ if tc_delta.id:
322
+ entry["id"] = tc_delta.id
323
+ if tc_delta.function:
324
+ if tc_delta.function.name:
325
+ entry["name"] += tc_delta.function.name
326
+ if tc_delta.function.arguments:
327
+ entry["arguments"] += tc_delta.function.arguments
328
+
329
+ # No tools requested -> this round is the final answer
330
+ if not tool_calls:
331
+ self.memory.add(Message(role="assistant", content=full_content))
332
+ saved = True
333
+ if user_message and full_content:
334
+ self._pending_exchange = (user_message, full_content)
335
+ return
336
+
337
+ # Feed the tool-call request back and run the tools
338
+ messages.append({
339
+ "role": "assistant",
340
+ "content": round_content,
341
+ "tool_calls": [
342
+ {
343
+ "id": tc["id"] or f"call_{i}",
344
+ "type": "function",
345
+ "function": {"name": tc["name"], "arguments": tc["arguments"]},
346
+ }
347
+ for i, tc in sorted(tool_calls.items())
348
+ ],
349
+ })
350
+ tool_results = await self._run_tool_calls_from_stream(tool_calls)
351
+ messages.extend(tool_results)
352
+ # Tool progress is surfaced below the stream, not in the text
353
+ finally:
354
+ # Persist whatever was produced, even if the consumer stops early
355
+ if full_content and not saved:
356
+ self.memory.add(Message(role="assistant", content=full_content))
357
+
358
+ # ── Tool execution ─────────────────────────────────
359
+
360
+ async def _run_tool_calls(self, tool_calls: list) -> List[Dict]:
361
+ """Execute tool calls and return them as OpenAI 'tool' role messages"""
362
+ results = []
363
+ for tc in tool_calls:
364
+ result = await self._execute_one(tc.function.name, tc.function.arguments)
365
+ results.append({
366
+ "role": "tool",
367
+ "tool_call_id": tc.id,
368
+ "content": result,
369
+ })
370
+ return results
371
+
372
+ async def _run_tool_calls_from_stream(self, tool_calls: Dict[int, Dict[str, Any]]) -> List[Dict]:
373
+ """Same as _run_tool_calls but for tool calls accumulated from a stream"""
374
+ results = []
375
+ for i, tc in sorted(tool_calls.items()):
376
+ result = await self._execute_one(tc["name"], tc["arguments"])
377
+ results.append({
378
+ "role": "tool",
379
+ "tool_call_id": tc["id"] or f"call_{i}",
380
+ "content": result,
381
+ })
382
+ return results
383
+
384
+ async def _execute_one(self, function_name: str, raw_arguments: str) -> str:
385
+ """Execute a single tool and return its result as a string (never raises)"""
386
+ tool = self.tools.get(function_name)
387
+ if not tool:
388
+ return f"Error: unknown tool '{function_name}'"
389
+
390
+ try:
391
+ function_args = json.loads(raw_arguments) if raw_arguments else {}
392
+ except json.JSONDecodeError:
393
+ return f"Error: could not parse arguments for {function_name}: {raw_arguments!r}"
394
+
395
+ if not isinstance(function_args, dict):
396
+ return f"Error: arguments for {function_name} must be an object"
397
+
398
+ # Show the user what the model is doing right now
399
+ brief = ", ".join(f"{k}={str(v)[:40]}" for k, v in list(function_args.items())[:2])
400
+ self._emit("tool_start", f"{function_name}({brief})")
401
+ start = time.monotonic()
402
+ try:
403
+ result = str(await tool.execute(**function_args))
404
+ except Exception as e:
405
+ result = f"Error in {function_name}: {e}"
406
+ elapsed = time.monotonic() - start
407
+ ok = not result.startswith("Error")
408
+ self._emit("tool_end", f"{function_name} {'✓' if ok else '✗'} ({elapsed:.1f}s)")
409
+ return result
410
+
411
+ # ── Utilities ──────────────────────────────────────
412
+
413
+ def clear_memory(self) -> None:
414
+ """Clear conversation history (session memory, not long-term facts)"""
415
+ self.memory.clear()
416
+
417
+ def clear_long_term_memory(self) -> bool:
418
+ """Forget all long-term facts across sessions"""
419
+ return self.long_term_memory.clear()
420
+
421
+ def remember_fact(self, fact: str) -> bool:
422
+ """Manually store a durable fact about the user"""
423
+ return self.long_term_memory.add(fact)
424
+
425
+ def get_history(self) -> List[Message]:
426
+ """Get conversation history"""
427
+ return self.memory.messages
428
+
429
+ def set_system_prompt(self, prompt: str) -> None:
430
+ """Update the system prompt (applies to the next chat call)"""
431
+ self.config.system_prompt = prompt