Skip to content

Commit 339b378

Browse files
committed
Refactor CLI to use Python bridge for LLM operations
Architecture change: - Go CLI is now a thin wrapper that spawns Python subprocess - New /bridge/ module handles LLM provider calls and Memex tools - Removed Go agent/client packages (moved logic to Python) Bridge components: - providers.py: Modular LLM provider abstraction (swappable backends) - tools.py: Memex tools that call Go server endpoints - query.py: Main entry point for subprocess invocation This separates concerns: Go for CLI UX, Python for LLM ecosystem.
1 parent 0a7983b commit 339b378

13 files changed

Lines changed: 624 additions & 1674 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,4 +63,5 @@ __pycache__/
6363

6464
# Python venvs
6565
*_venv/
66+
venv/
6667
memex-cli

bridge/providers.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"""
2+
LLM Provider abstraction.
3+
4+
Swap between OpenAI, Anthropic, or others without changing the rest of the code.
5+
"""
6+
7+
import os
8+
import json
9+
from abc import ABC, abstractmethod
10+
from typing import Dict, Any, List, Generator, Callable
11+
from dataclasses import dataclass
12+
13+
14+
@dataclass
15+
class Tool:
16+
"""Tool definition for function calling"""
17+
name: str
18+
description: str
19+
parameters: Dict[str, Any]
20+
21+
22+
@dataclass
23+
class ToolCall:
24+
"""A tool call request from the LLM"""
25+
id: str
26+
name: str
27+
arguments: Dict[str, Any]
28+
29+
30+
@dataclass
31+
class Chunk:
32+
"""Stream chunk from LLM"""
33+
type: str # "text" | "tool_call" | "done" | "error"
34+
text: str = ""
35+
tool_call: ToolCall = None
36+
error: str = ""
37+
38+
39+
class Provider(ABC):
40+
"""Abstract LLM provider"""
41+
42+
@abstractmethod
43+
def stream(
44+
self,
45+
system: str,
46+
messages: List[Dict],
47+
tools: List[Tool]
48+
) -> Generator[Chunk, None, None]:
49+
"""Stream response with tool support"""
50+
pass
51+
52+
53+
class OpenAIProvider(Provider):
54+
"""OpenAI provider (GPT-4, etc.)"""
55+
56+
def __init__(self, model: str = None):
57+
from openai import OpenAI
58+
self.client = OpenAI()
59+
self.model = model or os.getenv("OPENAI_MODEL", "gpt-4o")
60+
61+
def stream(
62+
self,
63+
system: str,
64+
messages: List[Dict],
65+
tools: List[Tool]
66+
) -> Generator[Chunk, None, None]:
67+
68+
# Build messages
69+
api_messages = [{"role": "system", "content": system}] if system else []
70+
api_messages.extend(messages)
71+
72+
# Build tools
73+
api_tools = [{
74+
"type": "function",
75+
"function": {
76+
"name": t.name,
77+
"description": t.description,
78+
"parameters": t.parameters
79+
}
80+
} for t in tools] if tools else None
81+
82+
try:
83+
stream = self.client.chat.completions.create(
84+
model=self.model,
85+
messages=api_messages,
86+
tools=api_tools,
87+
stream=True
88+
)
89+
90+
# Accumulate tool calls
91+
tool_calls: Dict[int, Dict] = {}
92+
93+
for chunk in stream:
94+
delta = chunk.choices[0].delta
95+
96+
# Text
97+
if delta.content:
98+
yield Chunk(type="text", text=delta.content)
99+
100+
# Tool calls (accumulate across chunks)
101+
if delta.tool_calls:
102+
for tc in delta.tool_calls:
103+
idx = tc.index
104+
if idx not in tool_calls:
105+
tool_calls[idx] = {"id": "", "name": "", "args": ""}
106+
if tc.id:
107+
tool_calls[idx]["id"] = tc.id
108+
if tc.function:
109+
if tc.function.name:
110+
tool_calls[idx]["name"] = tc.function.name
111+
if tc.function.arguments:
112+
tool_calls[idx]["args"] += tc.function.arguments
113+
114+
# Finish
115+
if chunk.choices[0].finish_reason == "tool_calls":
116+
for idx in sorted(tool_calls.keys()):
117+
tc = tool_calls[idx]
118+
try:
119+
args = json.loads(tc["args"]) if tc["args"] else {}
120+
yield Chunk(
121+
type="tool_call",
122+
tool_call=ToolCall(tc["id"], tc["name"], args)
123+
)
124+
except json.JSONDecodeError:
125+
pass
126+
tool_calls.clear()
127+
128+
elif chunk.choices[0].finish_reason == "stop":
129+
yield Chunk(type="done")
130+
131+
except Exception as e:
132+
yield Chunk(type="error", error=str(e))
133+
134+
135+
def get_provider(name: str = None) -> Provider:
136+
"""Get provider by name or auto-detect from env"""
137+
name = name or os.getenv("LLM_PROVIDER", "openai")
138+
139+
if name == "openai":
140+
return OpenAIProvider()
141+
# Add more providers here:
142+
# elif name == "anthropic":
143+
# return AnthropicProvider()
144+
else:
145+
raise ValueError(f"Unknown provider: {name}")

bridge/query.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Bridge query processor.
4+
5+
Usage:
6+
python query.py "your question here"
7+
echo "your question" | python query.py
8+
9+
Streams response to stdout. Tool calls are shown as [tool_name: args].
10+
"""
11+
12+
import sys
13+
import os
14+
import json
15+
16+
# Add bridge dir to path for imports
17+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
18+
19+
from dotenv import load_dotenv
20+
load_dotenv()
21+
22+
from providers import get_provider, Chunk, ToolCall
23+
from tools import TOOLS, execute
24+
25+
SYSTEM_PROMPT = """You are a knowledge graph assistant for Memex.
26+
27+
Help users find information by querying the graph. You have tools to search, traverse, and retrieve nodes.
28+
29+
When answering:
30+
1. Use search to find relevant nodes
31+
2. Use traverse or get_links to explore relationships
32+
3. Synthesize findings into a clear response
33+
4. Cite node IDs when relevant
34+
35+
Node types: Person, Company, Document, Task, Project, Message, Event
36+
37+
Be concise. If you can't find something, say so."""
38+
39+
40+
def run(query: str, history: list = None):
41+
"""Run a query and stream output to stdout"""
42+
provider = get_provider()
43+
44+
# Build messages
45+
messages = history or []
46+
messages.append({"role": "user", "content": query})
47+
48+
# Agent loop
49+
max_turns = 10
50+
for _ in range(max_turns):
51+
text_buffer = ""
52+
tool_calls = []
53+
54+
for chunk in provider.stream(SYSTEM_PROMPT, messages, TOOLS):
55+
if chunk.type == "text":
56+
print(chunk.text, end="", flush=True)
57+
text_buffer += chunk.text
58+
59+
elif chunk.type == "tool_call":
60+
tool_calls.append(chunk.tool_call)
61+
# Show tool execution
62+
print(f"\n[{chunk.tool_call.name}]", flush=True)
63+
64+
elif chunk.type == "error":
65+
print(f"\nError: {chunk.error}", file=sys.stderr)
66+
return
67+
68+
elif chunk.type == "done":
69+
pass
70+
71+
# If tool calls, execute and continue
72+
if tool_calls:
73+
# Add assistant message
74+
messages.append({
75+
"role": "assistant",
76+
"content": text_buffer or None,
77+
"tool_calls": [{
78+
"id": tc.id,
79+
"type": "function",
80+
"function": {"name": tc.name, "arguments": json.dumps(tc.arguments)}
81+
} for tc in tool_calls]
82+
})
83+
84+
# Execute tools
85+
for tc in tool_calls:
86+
result = execute(tc.name, tc.arguments)
87+
messages.append({
88+
"role": "tool",
89+
"tool_call_id": tc.id,
90+
"content": result
91+
})
92+
93+
continue
94+
95+
# No tool calls - done
96+
print() # Final newline
97+
return
98+
99+
print("\nMax turns reached", file=sys.stderr)
100+
101+
102+
def main():
103+
# Get query from args or stdin
104+
if len(sys.argv) > 1:
105+
query = " ".join(sys.argv[1:])
106+
else:
107+
query = sys.stdin.read().strip()
108+
109+
if not query:
110+
print("Usage: python query.py \"your question\"", file=sys.stderr)
111+
sys.exit(1)
112+
113+
run(query)
114+
115+
116+
if __name__ == "__main__":
117+
main()

bridge/requirements.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Bridge - LLM middleware for Memex CLI
2+
openai>=1.0.0
3+
httpx>=0.24.0
4+
python-dotenv>=1.0.0

0 commit comments

Comments
 (0)