Skip to content

Commit ea172da

Browse files
committed
feat: Release v1.7.0 - Cerebras Acceleration, Memory Bank, and Agent Delegation
- Providers: Added native Cerebras Inference support with auto-configured base URL and Llama 3.1 pricing. - Memory: Implemented persistent semantic Memory Bank using ChromaDB and Sentence Transformers. - Tools: Added SaveMemoryTool and SearchMemoryTool for long-term context retention. - Agents: Formalized delegation workflow with DelegateToAgentTool and sub-agent context management. - Fixed: Resolved erroneous await on action_quit in TUI. - Infrastructure: Updated ROADMAP.md and bumped version to 1.7.0. - Dependencies: Added chromadb and sentence-transformers.
1 parent 1d63c50 commit ea172da

File tree

14 files changed

+398
-69
lines changed

14 files changed

+398
-69
lines changed

ROADMAP.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,30 @@ We have successfully implemented the core foundation and advanced agentic capabi
8787

8888
---
8989

90+
## ✅ Completed Milestones (v1.6)
91+
92+
### 🚀 Core Architecture
93+
- [x] **Provider Abstraction**: Refactored `LLMProvider` to support `gemini`, `openai`, `groq`, and `ollama` seamlessly.
94+
- [x] **Thinking Blocks**: Added support for `<think>` tags to visualize reasoning chains in the TUI.
95+
- [x] **Config Manager**: Centralized configuration with secure secret handling (keyring support).
96+
97+
### 🛠️ Developer Experience
98+
- [x] **Sandbox Integration**: Docker-based sandboxing for safe code execution.
99+
- [x] **MCP Support**: Full integration with Model Context Protocol for extensible tools.
100+
101+
---
102+
103+
## ✅ Completed Milestones (v1.7)
104+
105+
### ⚡ Inference Acceleration
106+
- [x] **Cerebras Support**: Native integration for Cerebras Inference API (Llama 3.1 8B/70B) for ultra-fast generation.
107+
108+
### 🧠 Advanced Capabilities
109+
- [x] **Multi-Agent Delegation**: Formalized the `delegate_to_agent` workflow with dedicated sub-agent contexts.
110+
- [x] **Memory Bank**: Persistent long-term memory using vector stores (Chroma/Qdrant).
111+
112+
---
113+
90114
## 🔮 Long Term Vision (v2.0)
91115

92116
### 1. True "IDE-Like" UI

plexir/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "1.6.0"
1+
__version__ = "1.7.0"

plexir/core/commands.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ async def process(self, text: str) -> Optional[str]:
7474
await self.app.action_reload_providers()
7575
return "Providers reloaded from config."
7676
elif cmd in ("/quit", "/exit"):
77-
await self.app.action_quit()
77+
self.app.action_quit()
7878
return "Exiting..."
7979
else:
8080
return f"Unknown command: {cmd}. Type /help for list."

plexir/core/config_manager.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ def store_secret(username: str, secret: str):
5252
class ProviderConfig(BaseModel):
5353
"""Configuration for an individual LLM provider."""
5454
name: str = Field(..., description="Unique name for the provider.")
55-
type: str = Field(..., description="Type: gemini, openai, groq, ollama, mcp.")
55+
type: str = Field(..., description="Type: gemini, openai, groq, ollama, cerebras, mcp.")
5656
api_key: Optional[str] = None
5757
model_name: str
5858
base_url: Optional[str] = None
@@ -114,6 +114,10 @@ class AppConfig(BaseModel):
114114
"deepseek-v3": (0.27, 1.10),
115115
"deepseek-reasoner": (0.55, 2.19),
116116
"llama-3.3-70b-versatile": (0.59, 0.79),
117+
118+
# --- Cerebras Inference ---
119+
"llama3.1-8b": (0.10, 0.10),
120+
"llama3.1-70b": (0.60, 0.60),
117121
},
118122
description="Pricing map: model -> (prompt_price, completion_price) per 1M tokens."
119123
)

plexir/core/memory.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""
2+
Persistent Memory Bank for Plexir using ChromaDB.
3+
"""
4+
5+
import os
6+
import logging
7+
import uuid
8+
from typing import List, Dict, Any, Optional
9+
10+
try:
11+
import chromadb
12+
from chromadb.config import Settings
13+
from sentence_transformers import SentenceTransformer
14+
HAS_MEMORY_DEPS = True
15+
except ImportError:
16+
HAS_MEMORY_DEPS = False
17+
18+
logger = logging.getLogger(__name__)
19+
20+
MEMORY_DIR = os.path.expanduser("~/.plexir/memory")
21+
22+
class MemoryBank:
23+
_instance = None
24+
25+
def __new__(cls):
26+
if cls._instance is None:
27+
cls._instance = super(MemoryBank, cls).__new__(cls)
28+
cls._instance.initialized = False
29+
return cls._instance
30+
31+
def __init__(self):
32+
if self.initialized:
33+
return
34+
35+
if not HAS_MEMORY_DEPS:
36+
logger.warning("MemoryBank dependencies (chromadb, sentence-transformers) not found. Memory features disabled.")
37+
self.initialized = False
38+
return
39+
40+
os.makedirs(MEMORY_DIR, exist_ok=True)
41+
42+
try:
43+
self.client = chromadb.PersistentClient(path=MEMORY_DIR)
44+
45+
# Use a lightweight model for local embeddings
46+
self.embedder = SentenceTransformer('all-MiniLM-L6-v2')
47+
48+
self.collection = self.client.get_or_create_collection(
49+
name="plexir_memory",
50+
metadata={"hnsw:space": "cosine"}
51+
)
52+
self.initialized = True
53+
logger.info("MemoryBank initialized with ChromaDB.")
54+
except Exception as e:
55+
logger.error(f"Failed to initialize MemoryBank: {e}")
56+
self.initialized = False
57+
58+
def add(self, text: str, metadata: Dict[str, Any] = None) -> str:
59+
if not self.initialized:
60+
return "MemoryBank not initialized."
61+
62+
try:
63+
doc_id = str(uuid.uuid4())
64+
embedding = self.embedder.encode(text).tolist()
65+
66+
self.collection.add(
67+
documents=[text],
68+
embeddings=[embedding],
69+
metadatas=[metadata or {}],
70+
ids=[doc_id]
71+
)
72+
return f"Memory saved (ID: {doc_id})"
73+
except Exception as e:
74+
logger.error(f"Failed to add memory: {e}")
75+
return f"Error saving memory: {e}"
76+
77+
def search(self, query: str, n_results: int = 5) -> List[Dict[str, Any]]:
78+
if not self.initialized:
79+
return []
80+
81+
try:
82+
query_embedding = self.embedder.encode(query).tolist()
83+
84+
results = self.collection.query(
85+
query_embeddings=[query_embedding],
86+
n_results=n_results
87+
)
88+
89+
# Flatten results structure
90+
documents = results['documents'][0]
91+
metadatas = results['metadatas'][0]
92+
ids = results['ids'][0]
93+
distances = results['distances'][0]
94+
95+
formatted_results = []
96+
for i in range(len(documents)):
97+
formatted_results.append({
98+
"id": ids[i],
99+
"content": documents[i],
100+
"metadata": metadatas[i],
101+
"score": 1 - distances[i] # Convert distance to similarity score
102+
})
103+
104+
return formatted_results
105+
except Exception as e:
106+
logger.error(f"Memory search failed: {e}")
107+
return []
108+
109+
def delete(self, doc_id: str) -> str:
110+
if not self.initialized:
111+
return "MemoryBank not initialized."
112+
try:
113+
self.collection.delete(ids=[doc_id])
114+
return f"Memory {doc_id} deleted."
115+
except Exception as e:
116+
return f"Error deleting memory: {e}"

plexir/core/providers.py

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -254,13 +254,17 @@ def __init__(self, config: ProviderConfig, tools: ToolRegistry):
254254
self.model_name = config.model_name
255255
api_key = config.get_api_key() or "MISSING_KEY"
256256

257-
if config.type == "groq" and not config.base_url:
257+
base_url = config.base_url
258+
if config.type == "groq" and not base_url:
258259
self.client = AsyncGroq(api_key=api_key)
259-
else:
260-
self.client = AsyncOpenAI(
261-
api_key=api_key,
262-
base_url=config.base_url or "https://api.openai.com/v1"
263-
)
260+
return
261+
elif config.type == "cerebras" and not base_url:
262+
base_url = "https://api.cerebras.ai/v1"
263+
264+
self.client = AsyncOpenAI(
265+
api_key=api_key,
266+
base_url=base_url or "https://api.openai.com/v1"
267+
)
264268

265269
async def generate(
266270
self,
@@ -330,14 +334,19 @@ async def generate(
330334
openai_tools = self.tools.to_openai_toolbox()
331335

332336
try:
333-
stream = await self.client.chat.completions.create(
334-
messages=messages,
335-
model=self.model_name,
336-
tools=openai_tools if openai_tools else None,
337-
tool_choice="auto" if openai_tools else None,
338-
stream=True,
339-
stream_options={"include_usage": True}
340-
)
337+
create_params = {
338+
"messages": messages,
339+
"model": self.model_name,
340+
"tools": openai_tools if openai_tools else None,
341+
"tool_choice": "auto" if openai_tools else None,
342+
"stream": True,
343+
}
344+
345+
# Only OpenAI (and possibly others) support stream_options for usage
346+
if self.config.type == "openai":
347+
create_params["stream_options"] = {"include_usage": True}
348+
349+
stream = await self.client.chat.completions.create(**create_params)
341350

342351
tool_call_accumulator = {}
343352

@@ -452,14 +461,19 @@ async def generate(
452461
openai_tools = self.tools.to_openai_toolbox()
453462

454463
try:
455-
stream = await self.client.chat.completions.create(
456-
messages=messages,
457-
model=self.model_name,
458-
tools=openai_tools if openai_tools else None,
459-
tool_choice="auto" if openai_tools else None,
460-
stream=True,
461-
stream_options={"include_usage": True}
462-
)
464+
create_params = {
465+
"messages": messages,
466+
"model": self.model_name,
467+
"tools": openai_tools if openai_tools else None,
468+
"tool_choice": "auto" if openai_tools else None,
469+
"stream": True,
470+
}
471+
472+
# Only OpenAI (and possibly others) support stream_options for usage
473+
if self.config.type == "openai":
474+
create_params["stream_options"] = {"include_usage": True}
475+
476+
stream = await self.client.chat.completions.create(**create_params)
463477

464478
tool_call_accumulator = {}
465479

plexir/core/router.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
GitPushTool, GitPullTool,
1717
GitHubCreateIssueTool, GitHubCreatePRTool,
1818
WebSearchTool, BrowseURLTool, CodebaseSearchTool, GetDefinitionsTool, GetRepoMapTool, ScratchpadTool,
19-
ExportSandboxTool
19+
ExportSandboxTool, DelegateToAgentTool, SaveMemoryTool, SearchMemoryTool
2020
)
2121
from plexir.tools.sandbox import PythonSandboxTool, PersistentSandbox
2222
from plexir.core import context
@@ -105,7 +105,7 @@ def load_base_tools(self):
105105
GitPushTool(), GitPullTool(),
106106
GitHubCreateIssueTool(), GitHubCreatePRTool(),
107107
WebSearchTool(), BrowseURLTool(), CodebaseSearchTool(), GetDefinitionsTool(), GetRepoMapTool(), ScratchpadTool(),
108-
ExportSandboxTool()
108+
ExportSandboxTool(), DelegateToAgentTool(), SaveMemoryTool(), SearchMemoryTool()
109109
]
110110
for tool in tools:
111111
if self.sandbox:

plexir/mcp/client.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,12 @@ async def refresh_resources(self):
165165
res_list = await self.send_request("resources/list")
166166
self.resources = res_list.get("resources", [])
167167

168-
tmpl_list = await self.send_request("resources/templates/list")
169-
self.resource_templates = tmpl_list.get("resourceTemplates", [])
168+
try:
169+
tmpl_list = await self.send_request("resources/templates/list")
170+
self.resource_templates = tmpl_list.get("resourceTemplates", [])
171+
except Exception:
172+
# Templates might not be supported by all servers
173+
self.resource_templates = []
170174

171175
if self.resources or self.resource_templates:
172176
self._register_resource_tool()

plexir/tools/definitions.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from plexir.core.rag import CodebaseRetriever
1717
from plexir.core.config_manager import config_manager
1818
from plexir.core.github import GitHubClient
19+
from plexir.core.memory import MemoryBank
1920

2021
logger = logging.getLogger(__name__)
2122

@@ -803,3 +804,54 @@ async def run(self, target_path: str) -> str:
803804
return f"Successfully exported workspace to {target_path}."
804805
except Exception as e:
805806
return f"Export failed: {e}"
807+
808+
class DelegateToAgentSchema(BaseModel):
809+
agent_name: str = Field(..., description="A descriptive name for the sub-agent (e.g., 'codebase_investigator').")
810+
objective: str = Field(..., description="The comprehensive and detailed goal for the sub-agent.")
811+
812+
class DelegateToAgentTool(Tool):
813+
"""Formalizes the delegation of a complex sub-task to a specialized sub-agent."""
814+
name = "delegate_to_agent"
815+
description = "Delegates a complex sub-task to a specialized sub-agent. The sub-agent will work on the objective and return a structured report."
816+
args_schema = DelegateToAgentSchema
817+
818+
async def run(self, agent_name: str, objective: str) -> str:
819+
# In this version, we simulate the delegation by logging it and returning a prompt for the user
820+
# In a future version, this could spawn a separate Router instance.
821+
logger.info(f"Delegating task to agent '{agent_name}': {objective}")
822+
return f"TASK DELEGATED TO {agent_name.upper()}\nObjective: {objective}\n\nPlease proceed with this sub-task and report back when finished."
823+
824+
class SaveMemorySchema(BaseModel):
825+
content: str = Field(..., description="The fact or information to remember.")
826+
category: str = Field("general", description="Optional category (e.g., 'preference', 'fact', 'code_pattern').")
827+
828+
class SaveMemoryTool(Tool):
829+
"""Saves a piece of information to the long-term vector memory."""
830+
name = "save_memory"
831+
description = "Saves a fact, preference, or piece of information to long-term memory."
832+
args_schema = SaveMemorySchema
833+
834+
async def run(self, content: str, category: str = "general") -> str:
835+
# MemoryBank is singleton
836+
bank = MemoryBank()
837+
return await asyncio.to_thread(bank.add, content, {"category": category})
838+
839+
class SearchMemorySchema(BaseModel):
840+
query: str = Field(..., description="The query to search for in memory.")
841+
842+
class SearchMemoryTool(Tool):
843+
"""Searches the long-term vector memory."""
844+
name = "search_memory"
845+
description = "Semantic search over long-term memory to retrieve relevant facts or context."
846+
args_schema = SearchMemorySchema
847+
848+
async def run(self, query: str) -> str:
849+
bank = MemoryBank()
850+
results = await asyncio.to_thread(bank.search, query)
851+
if not results:
852+
return "No relevant memories found."
853+
854+
output = ["Found memories:"]
855+
for res in results:
856+
output.append(f"- [{res['score']:.2f}] {res['content']} (ID: {res['id']})")
857+
return "\n".join(output)

pyproject.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "plexir"
7-
version = "1.6.0"
7+
version = "1.7.0"
88
authors = [
99
{ name = "Pomilon", email = "pomilon@proton.me" },
1010
]
@@ -33,7 +33,9 @@ dependencies = [
3333
"keyring>=24.0.0",
3434
"google-auth>=2.27.0",
3535
"google-auth-oauthlib>=1.2.0",
36-
"httpx>=0.27.0"
36+
"httpx>=0.27.0",
37+
"chromadb>=0.4.0",
38+
"sentence-transformers>=2.2.0"
3739
]
3840

3941
[project.scripts]

0 commit comments

Comments
 (0)