Skip to content

Commit e87ed5d

Browse files
Artificiumclaude
andcommitted
feat: framework examples, Docker standalone, ChatGPT plugin (#70, #71, #74)
#70 — Five Python integration examples: - simple_chatbot.py — complete chatbot with ConversationMemory - openai_tools.py — OpenAI function calling with MemForge tools - claude_tools.py — Anthropic tool use with MemForge tools - langchain_memory.py — LangChain conversation memory backend - quickstart.py — Python equivalent of the TypeScript quickstart #71 — Docker standalone single-container: - Dockerfile.standalone with embedded PostgreSQL - docker run -p 3333:3333 salishforge/memforge:standalone - No Docker Compose needed for evaluation/development #74 — ChatGPT plugin manifest: - public/ai-plugin.json pointing to /api/spec.json - Enables MemForge as a ChatGPT plugin Closes #70, closes #71, closes #74 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 30cfb40 commit e87ed5d

8 files changed

Lines changed: 604 additions & 0 deletions

Dockerfile.standalone

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
FROM postgres:16-alpine
2+
3+
# Install Node.js 22 and build tools
4+
RUN apk add --no-cache nodejs npm curl
5+
6+
# pgvector — build from source (not in Alpine package repos for pg 16)
7+
RUN apk add --no-cache postgresql16-dev git build-base \
8+
&& git clone --branch v0.8.0 --depth 1 https://github.com/pgvector/pgvector.git /tmp/pgvector \
9+
&& cd /tmp/pgvector && make && make install \
10+
&& rm -rf /tmp/pgvector \
11+
&& apk del git build-base
12+
13+
# pg_trgm is bundled with PostgreSQL — just enable it via schema
14+
15+
# Copy MemForge application
16+
WORKDIR /app
17+
COPY package*.json ./
18+
RUN npm ci --production
19+
COPY dist/ ./dist/
20+
COPY schema/ ./schema/
21+
22+
# Standalone entrypoint
23+
COPY docker-entrypoint-standalone.sh /docker-entrypoint-standalone.sh
24+
RUN chmod +x /docker-entrypoint-standalone.sh
25+
26+
EXPOSE 3333
27+
28+
# Defaults tuned for single-container use with local embeddings
29+
ENV EMBEDDING_PROVIDER=local
30+
ENV CONSOLIDATION_INNER_BATCH_SIZE=1
31+
ENV DATABASE_URL=postgresql://postgres@localhost/memforge
32+
ENV PORT=3333
33+
ENV LOG_LEVEL=info
34+
35+
ENTRYPOINT ["/docker-entrypoint-standalone.sh"]

docker-entrypoint-standalone.sh

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
#!/bin/sh
2+
set -e
3+
4+
# ── 1. Initialise PostgreSQL data directory if needed ────────────────────────
5+
PGDATA="${PGDATA:-/var/lib/postgresql/data}"
6+
7+
if [ ! -f "$PGDATA/PG_VERSION" ]; then
8+
echo "[standalone] Initialising PostgreSQL data directory..."
9+
su-exec postgres initdb -D "$PGDATA" --encoding=UTF8 --auth=trust
10+
fi
11+
12+
# ── 2. Start PostgreSQL in the background ────────────────────────────────────
13+
echo "[standalone] Starting PostgreSQL..."
14+
su-exec postgres pg_ctl -D "$PGDATA" -l "$PGDATA/pg.log" start -w \
15+
-o "-c listen_addresses=localhost -c max_connections=50"
16+
17+
# ── 3. Create database and apply schema ──────────────────────────────────────
18+
echo "[standalone] Creating database..."
19+
su-exec postgres createdb memforge 2>/dev/null || true
20+
21+
echo "[standalone] Applying schema..."
22+
su-exec postgres psql -d memforge -f /app/schema/schema.sql 2>/dev/null || true
23+
24+
# Apply any available migrations in version order
25+
for f in $(ls /app/schema/migration-*.sql 2>/dev/null | sort); do
26+
echo "[standalone] Applying migration: $f"
27+
su-exec postgres psql -d memforge -f "$f" 2>/dev/null || true
28+
done
29+
30+
# ── 4. Verify extensions ─────────────────────────────────────────────────────
31+
echo "[standalone] Verifying extensions..."
32+
su-exec postgres psql -d memforge -c "CREATE EXTENSION IF NOT EXISTS vector;" 2>/dev/null || true
33+
su-exec postgres psql -d memforge -c "CREATE EXTENSION IF NOT EXISTS pg_trgm;" 2>/dev/null || true
34+
35+
# ── 5. Start MemForge ────────────────────────────────────────────────────────
36+
echo "[standalone] Starting MemForge on port ${PORT:-3333}..."
37+
exec node /app/dist/server.js

examples/claude_tools.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""Anthropic Claude tool use with MemForge memory tools.
2+
3+
Demonstrates tool definitions, handling tool_use blocks, and routing to MemForge.
4+
5+
Run:
6+
pip install memforge anthropic
7+
ANTHROPIC_API_KEY=... python examples/claude_tools.py
8+
"""
9+
10+
import asyncio
11+
import json
12+
import os
13+
import sys
14+
from typing import Any
15+
16+
from memforge import MemForgeClient
17+
from memforge.tools import anthropic_tools
18+
19+
AGENT_ID = "claude-tools-demo"
20+
SYSTEM_PROMPT = (
21+
"You are a helpful assistant with access to a persistent memory store. "
22+
"Use memforge_query to recall knowledge before answering. "
23+
"Use memforge_add to store important new facts."
24+
)
25+
26+
27+
async def dispatch_tool(client: MemForgeClient, name: str, args: dict[str, Any]) -> str:
28+
"""Route an Anthropic tool_use block to the MemForge client."""
29+
try:
30+
if name == "memforge_add":
31+
r = await client.add(args["agent_id"], args["content"],
32+
outcome_type=args.get("outcome_type", "neutral"))
33+
return json.dumps({"id": r.id})
34+
if name == "memforge_query":
35+
results = await client.query(args["agent_id"], q=args["q"],
36+
limit=args.get("limit", 10), mode=args.get("mode"))
37+
return json.dumps([{"content": r.summary or r.content, "rank": r.rank} for r in results])
38+
if name == "memforge_consolidate":
39+
r = await client.consolidate(args["agent_id"], args.get("mode"))
40+
return json.dumps({"warm_rows_created": r.warm_rows_created})
41+
if name == "memforge_active_recall":
42+
r = await client.active_recall(args["agent_id"], args["context"], args.get("limit", 5))
43+
return json.dumps(r)
44+
if name == "memforge_stats":
45+
s = await client.stats(args["agent_id"])
46+
return json.dumps({"hot": s.hot_count, "warm": s.warm_count})
47+
return json.dumps({"error": f"Unknown tool: {name}"})
48+
except Exception as e:
49+
return json.dumps({"error": str(e)})
50+
51+
52+
async def run_agent_turn(client: MemForgeClient, ac: Any, user_message: str) -> str:
53+
"""Run one agentic turn, looping over tool calls until Claude returns text."""
54+
tools = anthropic_tools()
55+
messages: list[dict[str, Any]] = [{"role": "user", "content": user_message}]
56+
while True:
57+
resp = await ac.messages.create(model="claude-haiku-4-5", max_tokens=1024,
58+
system=SYSTEM_PROMPT, tools=tools, messages=messages)
59+
messages.append({"role": "assistant", "content": resp.content})
60+
tool_uses = [b for b in resp.content if b.type == "tool_use"]
61+
if not tool_uses:
62+
texts = [b for b in resp.content if b.type == "text"]
63+
return texts[0].text if texts else ""
64+
results = []
65+
for b in tool_uses:
66+
results.append({"type": "tool_result", "tool_use_id": b.id,
67+
"content": await dispatch_tool(client, b.name, b.input)})
68+
messages.append({"role": "user", "content": results})
69+
70+
71+
async def main() -> None:
72+
try:
73+
import anthropic
74+
except ImportError:
75+
print("Install anthropic: pip install anthropic", file=sys.stderr); sys.exit(1)
76+
if not os.environ.get("ANTHROPIC_API_KEY"):
77+
print("Set ANTHROPIC_API_KEY", file=sys.stderr); sys.exit(1)
78+
79+
ac = anthropic.AsyncAnthropic()
80+
async with MemForgeClient() as client:
81+
await client.add(AGENT_ID, "Our API rate limit is 1000 requests per minute")
82+
await client.add(AGENT_ID, "Database backups run daily at 03:00 UTC")
83+
await client.consolidate(AGENT_ID)
84+
query = "What infrastructure constraints should I know about?"
85+
print(f"User: {query}\n")
86+
print(f"Claude: {await run_agent_turn(client, ac, query)}")
87+
88+
89+
if __name__ == "__main__":
90+
asyncio.run(main())

examples/langchain_memory.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""LangChain integration using MemForge as conversation memory backend.
2+
3+
Demonstrates:
4+
- Wrapping ConversationMemory as a LangChain BaseChatMemory subclass
5+
- Injecting MemForge context into the LangChain chain via load_memory_variables
6+
- Storing LangChain exchanges back to MemForge via save_context
7+
- Drop-in replacement for ConversationBufferMemory
8+
9+
Run:
10+
pip install memforge langchain langchain-openai
11+
OPENAI_API_KEY=... python examples/langchain_memory.py
12+
"""
13+
14+
import asyncio
15+
import os
16+
import sys
17+
from typing import Any
18+
19+
AGENT_ID = "langchain-demo"
20+
21+
22+
def build_memforge_memory(agent_id: str) -> "MemForgeMemory":
23+
"""Create a LangChain-compatible memory backed by MemForge."""
24+
try:
25+
from langchain.memory.chat_memory import BaseChatMemory
26+
from langchain.schema import BaseMessage, HumanMessage, AIMessage
27+
except ImportError:
28+
print("Install langchain: pip install langchain", file=sys.stderr)
29+
sys.exit(1)
30+
31+
from memforge import ConversationMemory
32+
33+
class MemForgeMemory(BaseChatMemory):
34+
"""LangChain memory adapter backed by MemForge persistent storage.
35+
36+
Implements the minimal BaseChatMemory interface so it can be passed
37+
directly to any LangChain chain that accepts a ``memory`` parameter.
38+
"""
39+
40+
memory_key: str = "history"
41+
agent_id: str = "langchain-agent"
42+
_memforge: Any = None # ConversationMemory, set via model_post_init
43+
44+
class Config:
45+
arbitrary_types_allowed = True
46+
47+
def __init__(self, agent_id: str = "langchain-agent", **kwargs: Any) -> None:
48+
super().__init__(**kwargs)
49+
object.__setattr__(self, "agent_id", agent_id)
50+
object.__setattr__(self, "_memforge", ConversationMemory(agent_id=agent_id))
51+
52+
@property
53+
def memory_variables(self) -> list[str]:
54+
return [self.memory_key]
55+
56+
def load_memory_variables(self, inputs: dict[str, Any]) -> dict[str, str]:
57+
"""Retrieve relevant MemForge context synchronously for LangChain."""
58+
human_input = inputs.get("input", inputs.get("human_input", ""))
59+
context = asyncio.get_event_loop().run_until_complete(
60+
self._memforge.get_context(human_input, max_tokens=1500)
61+
)
62+
return {self.memory_key: context}
63+
64+
def save_context(self, inputs: dict[str, Any], outputs: dict[str, str]) -> None:
65+
"""Persist the latest exchange to MemForge."""
66+
human = inputs.get("input", inputs.get("human_input", ""))
67+
ai = outputs.get("output", outputs.get("response", ""))
68+
loop = asyncio.get_event_loop()
69+
if human:
70+
loop.run_until_complete(self._memforge.add_turn("user", human))
71+
if ai:
72+
loop.run_until_complete(self._memforge.add_turn("assistant", ai))
73+
74+
def clear(self) -> None:
75+
"""No-op: MemForge persists across sessions by design."""
76+
77+
return MemForgeMemory(agent_id=agent_id) # type: ignore[return-value]
78+
79+
80+
async def main() -> None:
81+
try:
82+
from langchain_openai import ChatOpenAI
83+
from langchain.chains import ConversationChain
84+
from langchain.prompts import PromptTemplate
85+
except ImportError:
86+
print("Install: pip install langchain langchain-openai", file=sys.stderr)
87+
sys.exit(1)
88+
89+
if not os.environ.get("OPENAI_API_KEY"):
90+
print("Set OPENAI_API_KEY environment variable", file=sys.stderr)
91+
sys.exit(1)
92+
93+
memory = build_memforge_memory(AGENT_ID)
94+
95+
chain = ConversationChain(
96+
llm=ChatOpenAI(model="gpt-4o-mini"),
97+
memory=memory,
98+
prompt=PromptTemplate(
99+
input_variables=["history", "input"],
100+
template=(
101+
"You are a helpful assistant. Past context:\n{history}\n\n"
102+
"Human: {input}\nAssistant:"
103+
),
104+
),
105+
verbose=False,
106+
)
107+
108+
exchanges = [
109+
"My name is Alex and I prefer concise answers.",
110+
"What's a good way to structure a Python project?",
111+
"Do you remember my preference from earlier?",
112+
]
113+
114+
for user_msg in exchanges:
115+
print(f"Human: {user_msg}")
116+
response = chain.predict(input=user_msg)
117+
print(f"Assistant: {response}\n")
118+
119+
print("[Memory] Consolidating session...")
120+
await memory._memforge.end_session()
121+
await memory._memforge.close()
122+
print("[Memory] Done.")
123+
124+
125+
if __name__ == "__main__":
126+
asyncio.run(main())

examples/openai_tools.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""OpenAI function calling with MemForge memory tools.
2+
3+
Demonstrates tool definitions, handling tool_calls, and routing to MemForge.
4+
5+
Run:
6+
pip install memforge openai
7+
OPENAI_API_KEY=... python examples/openai_tools.py
8+
"""
9+
10+
import asyncio
11+
import json
12+
import os
13+
import sys
14+
from typing import Any
15+
16+
from memforge import MemForgeClient
17+
from memforge.tools import openai_tools
18+
19+
AGENT_ID = "openai-tools-demo"
20+
SYSTEM_PROMPT = (
21+
"You are a helpful assistant with access to a persistent memory store. "
22+
"Use memforge_query to recall knowledge before answering. "
23+
"Use memforge_add to store important new information."
24+
)
25+
26+
27+
async def dispatch_tool(client: MemForgeClient, name: str, args: dict[str, Any]) -> str:
28+
"""Route an OpenAI tool_call to the MemForge client."""
29+
try:
30+
if name == "memforge_add":
31+
r = await client.add(args["agent_id"], args["content"],
32+
outcome_type=args.get("outcome_type", "neutral"))
33+
return json.dumps({"id": r.id})
34+
if name == "memforge_query":
35+
results = await client.query(args["agent_id"], q=args["q"],
36+
limit=args.get("limit", 10), mode=args.get("mode"))
37+
return json.dumps([{"content": r.summary or r.content, "rank": r.rank} for r in results])
38+
if name == "memforge_consolidate":
39+
r = await client.consolidate(args["agent_id"], args.get("mode"))
40+
return json.dumps({"warm_rows_created": r.warm_rows_created})
41+
if name == "memforge_active_recall":
42+
r = await client.active_recall(args["agent_id"], args["context"], args.get("limit", 5))
43+
return json.dumps(r)
44+
if name == "memforge_stats":
45+
s = await client.stats(args["agent_id"])
46+
return json.dumps({"hot": s.hot_count, "warm": s.warm_count})
47+
return json.dumps({"error": f"Unknown tool: {name}"})
48+
except Exception as e:
49+
return json.dumps({"error": str(e)})
50+
51+
52+
async def run_agent_turn(client: MemForgeClient, oc: Any, user_message: str) -> str:
53+
"""Run one agentic turn, looping over tool calls until the model returns text."""
54+
tools = openai_tools()
55+
messages: list[dict[str, Any]] = [
56+
{"role": "system", "content": SYSTEM_PROMPT},
57+
{"role": "user", "content": user_message},
58+
]
59+
while True:
60+
resp = await oc.chat.completions.create(model="gpt-4o-mini", messages=messages,
61+
tools=tools, tool_choice="auto")
62+
msg = resp.choices[0].message
63+
messages.append(msg.model_dump(exclude_none=True))
64+
if not msg.tool_calls:
65+
return msg.content or ""
66+
for tc in msg.tool_calls:
67+
result = await dispatch_tool(client, tc.function.name, json.loads(tc.function.arguments))
68+
messages.append({"role": "tool", "tool_call_id": tc.id, "content": result})
69+
70+
71+
async def main() -> None:
72+
try:
73+
import openai
74+
except ImportError:
75+
print("Install openai: pip install openai", file=sys.stderr); sys.exit(1)
76+
if not os.environ.get("OPENAI_API_KEY"):
77+
print("Set OPENAI_API_KEY", file=sys.stderr); sys.exit(1)
78+
79+
oc = openai.AsyncOpenAI()
80+
async with MemForgeClient() as client:
81+
await client.add(AGENT_ID, "The team deploys every Tuesday at 14:00 UTC")
82+
await client.add(AGENT_ID, "Redis cache TTL is 5 minutes for hot paths")
83+
await client.consolidate(AGENT_ID)
84+
query = "When do we deploy and what's our cache TTL?"
85+
print(f"User: {query}\n")
86+
print(f"Assistant: {await run_agent_turn(client, oc, query)}")
87+
88+
89+
if __name__ == "__main__":
90+
asyncio.run(main())

0 commit comments

Comments
 (0)