Files
code-memory/main.py
jze9 9974ce8dd9 Add memory management: chat commands + REST API
Chat commands (intercept before LLM):
  "запомни что X" / "remember X" — saves with importance 0.9
  "забудь про X" / "forget X"    — deletes by vector similarity

REST API:
  POST   /v1/memories             — add memory manually
  PATCH  /v1/memories/{id}        — update content or importance
  DELETE /v1/memories/{id}        — delete by id (existing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 20:41:53 +05:00

295 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
code-memory: OpenAI-compatible proxy with GraphRAG + persistent memory.
Endpoints:
POST /v1/chat/completions — drop-in Ollama replacement with memory
POST /v1/index — index a code repository
GET /v1/memories — list memories
DELETE /v1/memories/{id} — delete a memory
GET /health — health check
"""
import json, os, asyncio
from contextlib import asynccontextmanager
from typing import AsyncIterator
import asyncpg
from fastapi import FastAPI, HTTPException, BackgroundTasks
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from dotenv import load_dotenv
load_dotenv()
import db as _db
import memory as mem
import graph as gph
import ollama_client as oll
from apscheduler.schedulers.asyncio import AsyncIOScheduler
# ── lifespan ──────────────────────────────────────────────────────────────────
scheduler = AsyncIOScheduler()
@asynccontextmanager
async def lifespan(app: FastAPI):
await _db.init_db()
# CMA consolidation runs every 6 hours
scheduler.add_job(mem.consolidate_memories, "interval", hours=6,
kwargs={"user_id": "default"})
scheduler.start()
yield
scheduler.shutdown()
app = FastAPI(title="code-memory", lifespan=lifespan)
# ── models ────────────────────────────────────────────────────────────────────
class Message(BaseModel):
role: str
content: str
class ChatRequest(BaseModel):
model: str = Field(default=None)
messages: list[Message]
stream: bool = False
session_id: str = "default"
user_id: str = "default"
project_id: str | None = None
class IndexRequest(BaseModel):
project_id: str
root_dir: str
extensions: list[str] = [".py"]
# ── models list endpoint ──────────────────────────────────────────────────────
@app.get("/v1/models")
async def list_models():
return {
"object": "list",
"data": [{
"id": "code-memory",
"object": "model",
"owned_by": "code-memory",
"created": 1700000000,
}]
}
# ── context assembly ──────────────────────────────────────────────────────────
async def build_context(request: ChatRequest) -> list[dict]:
user_msg = next((m.content for m in reversed(request.messages)
if m.role == "user"), "")
# 1. Relevant archival memories
memories = await mem.search_memories(user_msg, user_id=request.user_id, limit=6)
mem_block = ""
if memories:
mem_block = "## Relevant memories\n" + "\n".join(
f"- [{m['memory_type']}] {m['content']}" for m in memories
if m.get("score", 0) > 0.4
)
# 2. GraphRAG code context
code_block = ""
if request.project_id:
ctx = await gph.graphrag_context(user_msg, request.project_id)
if ctx:
code_block = f"## Code architecture context\n{ctx}"
# 3. Conversation summary (if exists)
summary = await mem.get_summary(request.session_id)
summary_block = f"## Earlier conversation summary\n{summary}" if summary else ""
# 4. Assemble system prompt
base_system = (
"You are an expert coding assistant with deep knowledge of the project architecture. "
"Use the provided memory and code context to give precise, informed answers. "
"Always reason about the full architecture, not just isolated snippets."
)
context_parts = [p for p in [base_system, summary_block, mem_block, code_block] if p]
system_content = "\n\n".join(context_parts)
# 5. Build messages: system + recent history + current
recent = await mem.get_recent_turns(request.session_id, limit=20)
messages = [{"role": "system", "content": system_content}]
messages += [{"role": r["role"], "content": r["content"]} for r in recent]
# add current user messages (skip if already in history)
for m in request.messages:
if not any(h["role"] == m.role and h["content"] == m.content for h in recent[-2:]):
messages.append({"role": m.role, "content": m.content})
return messages
# ── chat endpoint ─────────────────────────────────────────────────────────────
FORGET_PREFIXES = ("забудь про ", "забудь ", "forget ")
REMEMBER_PREFIXES = ("запомни что ", "запомни ", "remember ")
async def handle_memory_command(user_content: str, user_id: str) -> str | None:
low = user_content.lower().strip()
for prefix in FORGET_PREFIXES:
if low.startswith(prefix):
topic = user_content[len(prefix):].strip()
pool = await _db.get_pool()
vec = await oll.embed(topic)
from memory import _vec_str
async with pool.acquire() as conn:
deleted = await conn.fetchval("""
DELETE FROM memories
WHERE user_id=$1
AND 1-(embedding<=>$2::vector) > 0.60
RETURNING content
""", user_id, _vec_str(vec))
if deleted:
return f"Забыл: «{deleted}»"
return f"Не нашёл ничего похожего на «{topic}»"
for prefix in REMEMBER_PREFIXES:
if low.startswith(prefix):
fact = user_content[len(prefix):].strip()
await mem.store_memory(fact, user_id=user_id, memory_type="manual", importance=0.9)
return f"Запомнил: «{fact}»"
return None
@app.post("/v1/chat/completions")
async def chat_completions(req: ChatRequest, bg: BackgroundTasks):
user_content = next((m.content for m in reversed(req.messages)
if m.role == "user"), "")
# intercept memory commands before building context
cmd_response = await handle_memory_command(user_content, req.user_id)
if cmd_response:
return {
"id": "cmpl-cmd",
"object": "chat.completion",
"model": req.model or os.getenv("CHAT_MODEL"),
"choices": [{"index": 0,
"message": {"role": "assistant", "content": cmd_response},
"finish_reason": "stop"}]
}
messages = await build_context(req)
# use real Ollama model, not our proxy name
ollama_model = None if req.model == "code-memory" else req.model
if req.stream:
collected: list[str] = []
async def gen() -> AsyncIterator[bytes]:
try:
async for chunk in oll.chat_stream(messages, model=ollama_model):
collected.append(chunk)
data = json.dumps({"choices": [{"delta": {"content": chunk},
"finish_reason": None}]})
yield f"data: {data}\n\n".encode()
yield b"data: [DONE]\n\n"
except Exception as e:
yield f"data: {json.dumps({'error': str(e)})}\n\n".encode()
async def _save_stream():
response_text = "".join(collected)
await mem.save_turn(req.session_id, "user", user_content)
await mem.save_turn(req.session_id, "assistant", response_text)
await mem.extract_and_store(user_content, user_id=req.user_id)
await mem.extract_and_store(response_text, user_id=req.user_id)
await mem.summarize_old_turns(req.session_id)
bg.add_task(_save_stream)
return StreamingResponse(gen(), media_type="text/event-stream")
response_text = await oll.chat(messages, model=ollama_model)
# background: save turns + extract facts
async def _save():
await mem.save_turn(req.session_id, "user", user_content)
await mem.save_turn(req.session_id, "assistant", response_text)
await mem.extract_and_store(user_content, user_id=req.user_id)
await mem.extract_and_store(response_text, user_id=req.user_id)
await mem.summarize_old_turns(req.session_id)
bg.add_task(_save)
return {
"id": "cmpl-1",
"object": "chat.completion",
"model": req.model or os.getenv("CHAT_MODEL"),
"choices": [{"index": 0,
"message": {"role": "assistant", "content": response_text},
"finish_reason": "stop"}]
}
# ── index endpoint ────────────────────────────────────────────────────────────
@app.post("/v1/index")
async def index_code(req: IndexRequest, bg: BackgroundTasks):
if not os.path.isdir(req.root_dir):
raise HTTPException(404, f"Directory not found: {req.root_dir}")
bg.add_task(gph.index_project, req.project_id, req.root_dir, req.extensions)
return {"status": "indexing started", "project_id": req.project_id}
# ── memory endpoints ──────────────────────────────────────────────────────────
@app.get("/v1/memories")
async def list_memories(user_id: str = "default", limit: int = 50):
pool = await _db.get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch("""
SELECT id, content, importance, memory_type, access_count,
last_accessed, created_at
FROM memories WHERE user_id=$1
AND (metadata->>'archived') IS NULL
ORDER BY importance DESC, last_accessed DESC LIMIT $2
""", user_id, limit)
return [dict(r) for r in rows]
@app.post("/v1/memories")
async def create_memory(body: dict, user_id: str = "default"):
content = body.get("content", "").strip()
if not content:
raise HTTPException(400, "content required")
importance = float(body.get("importance", 0.9))
memory_type = body.get("memory_type", "manual")
await mem.store_memory(content, user_id=user_id,
memory_type=memory_type, importance=importance)
return {"status": "stored", "content": content}
@app.patch("/v1/memories/{memory_id}")
async def update_memory(memory_id: str, body: dict):
import uuid as _uuid
pool = await _db.get_pool()
uid = _uuid.UUID(memory_id)
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT * FROM memories WHERE id=$1", uid)
if not row:
raise HTTPException(404, "memory not found")
new_content = body.get("content", row["content"])
new_importance = float(body.get("importance", row["importance"]))
if new_content != row["content"]:
vec = await oll.embed(new_content)
from memory import _vec_str
await conn.execute("""
UPDATE memories SET content=$1, embedding=$2::vector,
importance=$3, last_accessed=NOW()
WHERE id=$4
""", new_content, _vec_str(vec), new_importance, uid)
else:
await conn.execute("""
UPDATE memories SET importance=$1, last_accessed=NOW()
WHERE id=$2
""", new_importance, uid)
return {"updated": memory_id}
@app.delete("/v1/memories/{memory_id}")
async def delete_memory(memory_id: str):
pool = await _db.get_pool()
async with pool.acquire() as conn:
await conn.execute("DELETE FROM memories WHERE id=$1",
__import__("uuid").UUID(memory_id))
return {"deleted": memory_id}
@app.get("/health")
async def health():
pool = await _db.get_pool()
async with pool.acquire() as conn:
await conn.fetchval("SELECT 1")
return {"status": "ok", "db": "connected"}