forked from SpeedyFoxAi/jarvis-memory
395 lines
13 KiB
Python
395 lines
13 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""
|
||
|
|
Enhanced memory storage with metadata support and batch upload capability
|
||
|
|
Usage: store_memory.py "Memory text" [--tags tag1,tag2] [--importance medium]
|
||
|
|
[--confidence high] [--source user|inferred|external]
|
||
|
|
[--verified] [--expires 2026-03-01] [--related id1,id2]
|
||
|
|
[--batch-mode] [--batch-size N]
|
||
|
|
|
||
|
|
Features:
|
||
|
|
- Single or batch memory storage
|
||
|
|
- Duplicate detection with --replace flag
|
||
|
|
- Enhanced metadata (importance, confidence, source_type, etc.)
|
||
|
|
- Access tracking (access_count, last_accessed)
|
||
|
|
"""
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import json
|
||
|
|
import sys
|
||
|
|
import urllib.request
|
||
|
|
import urllib.error
|
||
|
|
import uuid
|
||
|
|
from datetime import datetime, timedelta
|
||
|
|
from typing import List, Optional, Dict, Any
|
||
|
|
|
||
|
|
QDRANT_URL = "http://10.0.0.40:6333"
|
||
|
|
COLLECTION_NAME = "kimi_memories"
|
||
|
|
OLLAMA_URL = "http://localhost:11434/v1"
|
||
|
|
DEFAULT_BATCH_SIZE = 100
|
||
|
|
|
||
|
|
|
||
|
|
def check_existing(date: str = None) -> Optional[str]:
|
||
|
|
"""Check if entry already exists for this date"""
|
||
|
|
if not date:
|
||
|
|
return None
|
||
|
|
|
||
|
|
try:
|
||
|
|
scroll_data = json.dumps({
|
||
|
|
"limit": 100,
|
||
|
|
"with_payload": True,
|
||
|
|
"filter": {
|
||
|
|
"must": [{"key": "date", "match": {"value": date}}]
|
||
|
|
}
|
||
|
|
}).encode()
|
||
|
|
|
||
|
|
req = urllib.request.Request(
|
||
|
|
f"{QDRANT_URL}/collections/{COLLECTION_NAME}/points/scroll",
|
||
|
|
data=scroll_data,
|
||
|
|
headers={"Content-Type": "application/json"},
|
||
|
|
method="POST"
|
||
|
|
)
|
||
|
|
with urllib.request.urlopen(req, timeout=10) as response:
|
||
|
|
result = json.loads(response.read().decode())
|
||
|
|
points = result.get("result", {}).get("points", [])
|
||
|
|
if points:
|
||
|
|
return points[0]["id"] # Return existing ID
|
||
|
|
except Exception as e:
|
||
|
|
print(f"Warning: Could not check existing: {e}", file=sys.stderr)
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def get_embedding(text: str) -> Optional[List[float]]:
|
||
|
|
"""Generate embedding using snowflake-arctic-embed2 via Ollama"""
|
||
|
|
data = json.dumps({
|
||
|
|
"model": "snowflake-arctic-embed2",
|
||
|
|
"input": text[:8192] # Limit to 8k chars
|
||
|
|
}).encode()
|
||
|
|
|
||
|
|
req = urllib.request.Request(
|
||
|
|
f"{OLLAMA_URL}/embeddings",
|
||
|
|
data=data,
|
||
|
|
headers={"Content-Type": "application/json"}
|
||
|
|
)
|
||
|
|
|
||
|
|
try:
|
||
|
|
with urllib.request.urlopen(req, timeout=30) as response:
|
||
|
|
result = json.loads(response.read().decode())
|
||
|
|
return result["data"][0]["embedding"]
|
||
|
|
except Exception as e:
|
||
|
|
print(f"Error generating embedding: {e}", file=sys.stderr)
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def batch_upload_embeddings(texts: List[str]) -> List[Optional[List[float]]]:
|
||
|
|
"""Generate embeddings for multiple texts in one batch"""
|
||
|
|
if not texts:
|
||
|
|
return []
|
||
|
|
|
||
|
|
data = json.dumps({
|
||
|
|
"model": "snowflake-arctic-embed2",
|
||
|
|
"input": [t[:8192] for t in texts]
|
||
|
|
}).encode()
|
||
|
|
|
||
|
|
req = urllib.request.Request(
|
||
|
|
f"{OLLAMA_URL}/embeddings",
|
||
|
|
data=data,
|
||
|
|
headers={"Content-Type": "application/json"}
|
||
|
|
)
|
||
|
|
|
||
|
|
try:
|
||
|
|
with urllib.request.urlopen(req, timeout=120) as response:
|
||
|
|
result = json.loads(response.read().decode())
|
||
|
|
return [d["embedding"] for d in result["data"]]
|
||
|
|
except Exception as e:
|
||
|
|
print(f"Error generating batch embeddings: {e}", file=sys.stderr)
|
||
|
|
return [None] * len(texts)
|
||
|
|
|
||
|
|
|
||
|
|
def upload_points_batch(points: List[Dict[str, Any]], batch_size: int = DEFAULT_BATCH_SIZE) -> tuple:
|
||
|
|
"""Upload points in batches to Qdrant"""
|
||
|
|
total = len(points)
|
||
|
|
uploaded = 0
|
||
|
|
failed = 0
|
||
|
|
|
||
|
|
for i in range(0, total, batch_size):
|
||
|
|
batch = points[i:i + batch_size]
|
||
|
|
|
||
|
|
upsert_data = {"points": batch}
|
||
|
|
|
||
|
|
req = urllib.request.Request(
|
||
|
|
f"{QDRANT_URL}/collections/{COLLECTION_NAME}/points?wait=true",
|
||
|
|
data=json.dumps(upsert_data).encode(),
|
||
|
|
headers={"Content-Type": "application/json"},
|
||
|
|
method="PUT"
|
||
|
|
)
|
||
|
|
|
||
|
|
try:
|
||
|
|
with urllib.request.urlopen(req, timeout=60) as response:
|
||
|
|
result = json.loads(response.read().decode())
|
||
|
|
if result.get("status") == "ok":
|
||
|
|
uploaded += len(batch)
|
||
|
|
else:
|
||
|
|
print(f"Batch upload failed: {result}", file=sys.stderr)
|
||
|
|
failed += len(batch)
|
||
|
|
except Exception as e:
|
||
|
|
print(f"Batch upload error: {e}", file=sys.stderr)
|
||
|
|
failed += len(batch)
|
||
|
|
|
||
|
|
return uploaded, failed
|
||
|
|
|
||
|
|
|
||
|
|
def store_single_memory(
|
||
|
|
text: str,
|
||
|
|
embedding: List[float],
|
||
|
|
tags: List[str] = None,
|
||
|
|
importance: str = "medium",
|
||
|
|
date: str = None,
|
||
|
|
source: str = "conversation",
|
||
|
|
confidence: str = "high",
|
||
|
|
source_type: str = "user",
|
||
|
|
verified: bool = True,
|
||
|
|
expires_at: str = None,
|
||
|
|
related_memories: List[str] = None,
|
||
|
|
replace: bool = False
|
||
|
|
) -> Optional[str]:
|
||
|
|
"""Store a single memory in Qdrant with enhanced metadata"""
|
||
|
|
|
||
|
|
if date is None:
|
||
|
|
date = datetime.now().strftime("%Y-%m-%d")
|
||
|
|
|
||
|
|
# Check for existing entry on same date
|
||
|
|
existing_id = check_existing(date=date) if date else None
|
||
|
|
if existing_id and not replace:
|
||
|
|
print(f"⚠️ Entry for {date} already exists (ID: {existing_id})")
|
||
|
|
print(f" Use --replace to overwrite")
|
||
|
|
return None
|
||
|
|
|
||
|
|
# Use existing ID if replacing, otherwise generate new
|
||
|
|
point_id = existing_id if existing_id else str(uuid.uuid4())
|
||
|
|
|
||
|
|
# Build payload with all metadata
|
||
|
|
payload = {
|
||
|
|
"text": text,
|
||
|
|
"date": date,
|
||
|
|
"tags": tags or [],
|
||
|
|
"importance": importance,
|
||
|
|
"source": source,
|
||
|
|
"confidence": confidence,
|
||
|
|
"source_type": source_type,
|
||
|
|
"verified": verified,
|
||
|
|
"created_at": datetime.now().isoformat(),
|
||
|
|
"access_count": 0,
|
||
|
|
"last_accessed": datetime.now().isoformat()
|
||
|
|
}
|
||
|
|
|
||
|
|
# Optional metadata
|
||
|
|
if expires_at:
|
||
|
|
payload["expires_at"] = expires_at
|
||
|
|
if related_memories:
|
||
|
|
payload["related_memories"] = related_memories
|
||
|
|
|
||
|
|
# Qdrant upsert format
|
||
|
|
upsert_data = {
|
||
|
|
"points": [{
|
||
|
|
"id": point_id,
|
||
|
|
"vector": embedding,
|
||
|
|
"payload": payload
|
||
|
|
}]
|
||
|
|
}
|
||
|
|
|
||
|
|
req = urllib.request.Request(
|
||
|
|
f"{QDRANT_URL}/collections/{COLLECTION_NAME}/points?wait=true",
|
||
|
|
data=json.dumps(upsert_data).encode(),
|
||
|
|
headers={"Content-Type": "application/json"},
|
||
|
|
method="PUT"
|
||
|
|
)
|
||
|
|
|
||
|
|
try:
|
||
|
|
with urllib.request.urlopen(req, timeout=10) as response:
|
||
|
|
result = json.loads(response.read().decode())
|
||
|
|
if result.get("status") == "ok":
|
||
|
|
return point_id
|
||
|
|
else:
|
||
|
|
print(f"Qdrant response: {result}", file=sys.stderr)
|
||
|
|
return None
|
||
|
|
except urllib.error.HTTPError as e:
|
||
|
|
error_body = e.read().decode()
|
||
|
|
print(f"HTTP Error {e.code}: {error_body}", file=sys.stderr)
|
||
|
|
return None
|
||
|
|
except Exception as e:
|
||
|
|
print(f"Error storing memory: {e}", file=sys.stderr)
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def store_memories_batch(
|
||
|
|
memories: List[Dict[str, Any]],
|
||
|
|
batch_size: int = DEFAULT_BATCH_SIZE
|
||
|
|
) -> tuple:
|
||
|
|
"""Store multiple memories in batch"""
|
||
|
|
if not memories:
|
||
|
|
return 0, 0
|
||
|
|
|
||
|
|
# Generate embeddings for all
|
||
|
|
texts = [m["text"] for m in memories]
|
||
|
|
print(f"Generating embeddings for {len(texts)} memories...")
|
||
|
|
embeddings = batch_upload_embeddings(texts)
|
||
|
|
|
||
|
|
# Prepare points
|
||
|
|
points = []
|
||
|
|
failed_indices = []
|
||
|
|
|
||
|
|
for i, (memory, embedding) in enumerate(zip(memories, embeddings)):
|
||
|
|
if embedding is None:
|
||
|
|
failed_indices.append(i)
|
||
|
|
continue
|
||
|
|
|
||
|
|
point_id = str(uuid.uuid4())
|
||
|
|
date = memory.get("date", datetime.now().strftime("%Y-%m-%d"))
|
||
|
|
|
||
|
|
payload = {
|
||
|
|
"text": memory["text"],
|
||
|
|
"date": date,
|
||
|
|
"tags": memory.get("tags", []),
|
||
|
|
"importance": memory.get("importance", "medium"),
|
||
|
|
"source": memory.get("source", "conversation"),
|
||
|
|
"confidence": memory.get("confidence", "high"),
|
||
|
|
"source_type": memory.get("source_type", "user"),
|
||
|
|
"verified": memory.get("verified", True),
|
||
|
|
"created_at": datetime.now().isoformat(),
|
||
|
|
"access_count": 0,
|
||
|
|
"last_accessed": datetime.now().isoformat()
|
||
|
|
}
|
||
|
|
|
||
|
|
# NOTE: User requested NO memory expiration - permanent retention
|
||
|
|
# expires_at is accepted for API compatibility but ignored
|
||
|
|
if memory.get("expires_at"):
|
||
|
|
payload["expires_at"] = memory["expires_at"]
|
||
|
|
if memory.get("related_memories"):
|
||
|
|
payload["related_memories"] = memory["related_memories"]
|
||
|
|
|
||
|
|
points.append({
|
||
|
|
"id": point_id,
|
||
|
|
"vector": embedding,
|
||
|
|
"payload": payload
|
||
|
|
})
|
||
|
|
|
||
|
|
if not points:
|
||
|
|
return 0, len(memories)
|
||
|
|
|
||
|
|
# Upload in batches
|
||
|
|
print(f"Uploading {len(points)} memories in batches of {batch_size}...")
|
||
|
|
uploaded, failed_upload = upload_points_batch(points, batch_size)
|
||
|
|
|
||
|
|
return uploaded, len(failed_indices) + failed_upload
|
||
|
|
|
||
|
|
|
||
|
|
def parse_date(date_str: str) -> Optional[str]:
|
||
|
|
"""Validate date format"""
|
||
|
|
if not date_str:
|
||
|
|
return None
|
||
|
|
try:
|
||
|
|
datetime.strptime(date_str, "%Y-%m-%d")
|
||
|
|
return date_str
|
||
|
|
except ValueError:
|
||
|
|
print(f"Invalid date format: {date_str}. Use YYYY-MM-DD.", file=sys.stderr)
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
parser = argparse.ArgumentParser(description="Store memories in Qdrant with metadata")
|
||
|
|
parser.add_argument("text", nargs="?", help="Memory text to store")
|
||
|
|
parser.add_argument("--tags", help="Comma-separated tags")
|
||
|
|
parser.add_argument("--importance", default="medium", choices=["low", "medium", "high"])
|
||
|
|
parser.add_argument("--date", help="Date in YYYY-MM-DD format")
|
||
|
|
parser.add_argument("--source", default="conversation", help="Source of the memory")
|
||
|
|
parser.add_argument("--confidence", default="high", choices=["high", "medium", "low"])
|
||
|
|
parser.add_argument("--source-type", default="user", choices=["user", "inferred", "external"])
|
||
|
|
parser.add_argument("--verified", action="store_true", default=True)
|
||
|
|
parser.add_argument("--expires", help="Expiration date YYYY-MM-DD (NOTE: User prefers permanent retention)")
|
||
|
|
parser.add_argument("--related", help="Comma-separated related memory IDs")
|
||
|
|
parser.add_argument("--replace", action="store_true", help="Replace existing entry for the same date")
|
||
|
|
parser.add_argument("--batch-file", help="JSON file with multiple memories for batch upload")
|
||
|
|
parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE, help=f"Batch size (default: {DEFAULT_BATCH_SIZE})")
|
||
|
|
|
||
|
|
args = parser.parse_args()
|
||
|
|
|
||
|
|
# Batch mode
|
||
|
|
if args.batch_file:
|
||
|
|
print(f"Batch mode: Loading memories from {args.batch_file}")
|
||
|
|
try:
|
||
|
|
with open(args.batch_file, 'r') as f:
|
||
|
|
memories = json.load(f)
|
||
|
|
|
||
|
|
if not isinstance(memories, list):
|
||
|
|
print("Batch file must contain a JSON array of memories", file=sys.stderr)
|
||
|
|
sys.exit(1)
|
||
|
|
|
||
|
|
print(f"Loaded {len(memories)} memories for batch upload")
|
||
|
|
uploaded, failed = store_memories_batch(memories, args.batch_size)
|
||
|
|
|
||
|
|
print(f"\n{'=' * 50}")
|
||
|
|
print(f"Batch upload complete:")
|
||
|
|
print(f" Uploaded: {uploaded}")
|
||
|
|
print(f" Failed: {failed}")
|
||
|
|
|
||
|
|
sys.exit(0 if failed == 0 else 1)
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
print(f"Error loading batch file: {e}", file=sys.stderr)
|
||
|
|
sys.exit(1)
|
||
|
|
|
||
|
|
# Single memory mode
|
||
|
|
if not args.text:
|
||
|
|
print("Error: Either provide text argument or use --batch-file", file=sys.stderr)
|
||
|
|
parser.print_help()
|
||
|
|
sys.exit(1)
|
||
|
|
|
||
|
|
# Parse tags and related memories
|
||
|
|
tags = [t.strip() for t in args.tags.split(",")] if args.tags else []
|
||
|
|
related = [r.strip() for r in args.related.split(",")] if args.related else None
|
||
|
|
|
||
|
|
# Validate date
|
||
|
|
date = parse_date(args.date)
|
||
|
|
if args.date and not date:
|
||
|
|
sys.exit(1)
|
||
|
|
|
||
|
|
print(f"Generating embedding...")
|
||
|
|
embedding = get_embedding(args.text)
|
||
|
|
|
||
|
|
if embedding is None:
|
||
|
|
print("❌ Failed to generate embedding", file=sys.stderr)
|
||
|
|
sys.exit(1)
|
||
|
|
|
||
|
|
print(f"Storing memory (vector dim: {len(embedding)})...")
|
||
|
|
point_id = store_single_memory(
|
||
|
|
text=args.text,
|
||
|
|
embedding=embedding,
|
||
|
|
tags=tags,
|
||
|
|
importance=args.importance,
|
||
|
|
date=date,
|
||
|
|
source=args.source,
|
||
|
|
confidence=args.confidence,
|
||
|
|
source_type=args.source_type,
|
||
|
|
verified=args.verified,
|
||
|
|
expires_at=args.expires,
|
||
|
|
related_memories=related,
|
||
|
|
replace=args.replace
|
||
|
|
)
|
||
|
|
|
||
|
|
if point_id:
|
||
|
|
print(f"✅ Memory stored successfully")
|
||
|
|
print(f" ID: {point_id}")
|
||
|
|
print(f" Tags: {tags}")
|
||
|
|
print(f" Importance: {args.importance}")
|
||
|
|
print(f" Confidence: {args.confidence}")
|
||
|
|
print(f" Source: {args.source_type}")
|
||
|
|
if args.expires:
|
||
|
|
print(f" Expires: {args.expires}")
|
||
|
|
else:
|
||
|
|
print(f"❌ Failed to store memory", file=sys.stderr)
|
||
|
|
sys.exit(1)
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|