Initial commit: add .gitignore and README
This commit is contained in:
43
fusionagi/memory/__init__.py
Normal file
43
fusionagi/memory/__init__.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Memory system: working, episodic, reflective, semantic, procedural, trust, consolidation."""
|
||||
|
||||
from fusionagi.memory.working import WorkingMemory
|
||||
from fusionagi.memory.episodic import EpisodicMemory
|
||||
from fusionagi.memory.reflective import ReflectiveMemory
|
||||
from fusionagi.memory.semantic import SemanticMemory
|
||||
from fusionagi.memory.procedural import ProceduralMemory
|
||||
from fusionagi.memory.trust import TrustMemory
|
||||
from fusionagi.memory.consolidation import ConsolidationJob
|
||||
from fusionagi.memory.service import MemoryService, VectorMemory
|
||||
from fusionagi.memory.vector_pgvector import create_vector_memory_pgvector, VectorMemoryPgvector
|
||||
from fusionagi.memory.postgres_backend import (
|
||||
MemoryBackend,
|
||||
InMemoryBackend,
|
||||
create_postgres_backend,
|
||||
)
|
||||
from fusionagi.memory.semantic_graph import SemanticGraphMemory
|
||||
from fusionagi.memory.sharding import Shard, shard_context
|
||||
from fusionagi.memory.scratchpad import LatentScratchpad, ThoughtState
|
||||
|
||||
__all__ = [
|
||||
"WorkingMemory",
|
||||
"EpisodicMemory",
|
||||
"ReflectiveMemory",
|
||||
"SemanticMemory",
|
||||
"ProceduralMemory",
|
||||
"TrustMemory",
|
||||
"ConsolidationJob",
|
||||
"MemoryService",
|
||||
"VectorMemory",
|
||||
"create_vector_memory_pgvector",
|
||||
"VectorMemoryPgvector",
|
||||
"MemoryBackend",
|
||||
"InMemoryBackend",
|
||||
"create_postgres_backend",
|
||||
"SemanticGraphMemory",
|
||||
"Shard",
|
||||
"shard_context",
|
||||
"LatentScratchpad",
|
||||
"ThoughtState",
|
||||
"ThoughtVersioning",
|
||||
"ThoughtStateSnapshot",
|
||||
]
|
||||
87
fusionagi/memory/consolidation.py
Normal file
87
fusionagi/memory/consolidation.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Consolidation: distillation of experiences into knowledge; write/forget rules for AGI."""
|
||||
|
||||
from typing import Any, Callable, Protocol
|
||||
|
||||
from fusionagi._logger import logger
|
||||
|
||||
|
||||
class EpisodicLike(Protocol):
|
||||
def get_lessons(self, limit: int) -> list[dict[str, Any]]: ...
|
||||
def get_recent(self, limit: int) -> list[dict[str, Any]]: ...
|
||||
|
||||
|
||||
class ReflectiveLike(Protocol):
|
||||
def get_lessons(self, limit: int) -> list[dict[str, Any]]: ...
|
||||
|
||||
|
||||
class SemanticLike(Protocol):
|
||||
def add_fact(self, fact_id: str, statement: str, source: str, domain: str, metadata: dict | None) -> None: ...
|
||||
|
||||
|
||||
class ConsolidationJob:
|
||||
"""
|
||||
Periodic distillation: take recent episodic/reflective lessons and
|
||||
write summarized facts into semantic memory. Write/forget rules
|
||||
are applied by the distiller callback.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
episodic: EpisodicLike | None = None,
|
||||
reflective: ReflectiveLike | None = None,
|
||||
semantic: SemanticLike | None = None,
|
||||
distiller: Callable[[list[dict[str, Any]]], list[dict[str, Any]]] | None = None,
|
||||
) -> None:
|
||||
self._episodic = episodic
|
||||
self._reflective = reflective
|
||||
self._semantic = semantic
|
||||
self._distiller = distiller or _default_distiller
|
||||
|
||||
def run(self, episodic_limit: int = 100, reflective_limit: int = 50) -> int:
|
||||
"""
|
||||
Run consolidation: gather recent lessons, distill, write to semantic.
|
||||
Returns number of facts written.
|
||||
"""
|
||||
lessons: list[dict[str, Any]] = []
|
||||
if self._episodic:
|
||||
try:
|
||||
lessons.extend(self._episodic.get_recent(episodic_limit) if hasattr(self._episodic, "get_recent") else [])
|
||||
except Exception:
|
||||
pass
|
||||
if self._reflective:
|
||||
try:
|
||||
lessons.extend(self._reflective.get_lessons(reflective_limit))
|
||||
except Exception:
|
||||
pass
|
||||
if not lessons:
|
||||
return 0
|
||||
facts = self._distiller(lessons)
|
||||
written = 0
|
||||
if self._semantic and facts:
|
||||
for i, f in enumerate(facts[:50]):
|
||||
fact_id = f.get("fact_id", f"consolidated_{i}")
|
||||
statement = f.get("statement", str(f))
|
||||
source = f.get("source", "consolidation")
|
||||
domain = f.get("domain", "general")
|
||||
try:
|
||||
self._semantic.add_fact(fact_id, statement, source=source, domain=domain, metadata=f)
|
||||
written += 1
|
||||
except Exception:
|
||||
logger.exception("Consolidation: failed to add fact", extra={"fact_id": fact_id})
|
||||
logger.info("Consolidation run", extra={"lessons": len(lessons), "facts_written": written})
|
||||
return written
|
||||
|
||||
|
||||
def _default_distiller(lessons: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Default: turn each lesson into one fact (summary)."""
|
||||
out = []
|
||||
for i, le in enumerate(lessons[-100:]):
|
||||
outcome = le.get("outcome", le.get("result", ""))
|
||||
task_id = le.get("task_id", "")
|
||||
out.append({
|
||||
"fact_id": f"cons_{task_id}_{i}",
|
||||
"statement": f"Task {task_id} outcome: {outcome}",
|
||||
"source": "consolidation",
|
||||
"domain": "general",
|
||||
})
|
||||
return out
|
||||
226
fusionagi/memory/episodic.py
Normal file
226
fusionagi/memory/episodic.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""Episodic memory: append-only log of task/step outcomes; query by task_id or time range.
|
||||
|
||||
Episodic memory stores historical records of agent actions and outcomes:
|
||||
- Task execution traces
|
||||
- Step outcomes (success/failure)
|
||||
- Tool invocation results
|
||||
- Decision points and their outcomes
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Any, Callable, Iterator
|
||||
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi._time import utc_now_iso
|
||||
|
||||
|
||||
class EpisodicMemory:
|
||||
"""
|
||||
Append-only log of task and step outcomes.
|
||||
|
||||
Features:
|
||||
- Time-stamped event logging
|
||||
- Query by task ID
|
||||
- Query by time range
|
||||
- Query by event type
|
||||
- Statistical summaries
|
||||
- Memory size limits with optional archival
|
||||
"""
|
||||
|
||||
def __init__(self, max_entries: int = 10000) -> None:
|
||||
"""
|
||||
Initialize episodic memory.
|
||||
|
||||
Args:
|
||||
max_entries: Maximum entries before oldest are archived/removed.
|
||||
"""
|
||||
self._entries: list[dict[str, Any]] = []
|
||||
self._by_task: dict[str, list[int]] = {} # task_id -> indices into _entries
|
||||
self._by_type: dict[str, list[int]] = {} # event_type -> indices
|
||||
self._max_entries = max_entries
|
||||
self._archived_count = 0
|
||||
|
||||
def append(
|
||||
self,
|
||||
task_id: str,
|
||||
event: dict[str, Any],
|
||||
event_type: str | None = None,
|
||||
) -> int:
|
||||
"""
|
||||
Append an episodic entry.
|
||||
|
||||
Args:
|
||||
task_id: Task identifier this event belongs to.
|
||||
event: Event data dictionary.
|
||||
event_type: Optional event type for categorization (e.g., "step_done", "tool_call").
|
||||
|
||||
Returns:
|
||||
Index of the appended entry.
|
||||
"""
|
||||
# Enforce size limits
|
||||
if len(self._entries) >= self._max_entries:
|
||||
self._archive_oldest(self._max_entries // 10)
|
||||
|
||||
# Add metadata
|
||||
entry = {
|
||||
**event,
|
||||
"task_id": task_id,
|
||||
"timestamp": event.get("timestamp", time.monotonic()),
|
||||
"datetime": event.get("datetime", utc_now_iso()),
|
||||
}
|
||||
|
||||
if event_type:
|
||||
entry["event_type"] = event_type
|
||||
|
||||
idx = len(self._entries)
|
||||
self._entries.append(entry)
|
||||
|
||||
# Index by task
|
||||
self._by_task.setdefault(task_id, []).append(idx)
|
||||
|
||||
# Index by type if provided
|
||||
etype = event_type or event.get("type") or event.get("event_type")
|
||||
if etype:
|
||||
self._by_type.setdefault(etype, []).append(idx)
|
||||
|
||||
return idx
|
||||
|
||||
def get_by_task(self, task_id: str, limit: int | None = None) -> list[dict[str, Any]]:
|
||||
"""Return all entries for a task (copy), optionally limited."""
|
||||
indices = self._by_task.get(task_id, [])
|
||||
if limit:
|
||||
indices = indices[-limit:]
|
||||
return [self._entries[i].copy() for i in indices]
|
||||
|
||||
def get_by_type(self, event_type: str, limit: int | None = None) -> list[dict[str, Any]]:
|
||||
"""Return entries of a specific type."""
|
||||
indices = self._by_type.get(event_type, [])
|
||||
if limit:
|
||||
indices = indices[-limit:]
|
||||
return [self._entries[i].copy() for i in indices]
|
||||
|
||||
def get_recent(self, limit: int = 100) -> list[dict[str, Any]]:
|
||||
"""Return most recent entries (copy)."""
|
||||
return [e.copy() for e in self._entries[-limit:]]
|
||||
|
||||
def get_by_time_range(
|
||||
self,
|
||||
start_timestamp: float | None = None,
|
||||
end_timestamp: float | None = None,
|
||||
limit: int | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Return entries within a time range (using monotonic timestamps).
|
||||
|
||||
Args:
|
||||
start_timestamp: Start of range (inclusive).
|
||||
end_timestamp: End of range (inclusive).
|
||||
limit: Maximum entries to return.
|
||||
"""
|
||||
results = []
|
||||
for entry in self._entries:
|
||||
ts = entry.get("timestamp", 0)
|
||||
if start_timestamp and ts < start_timestamp:
|
||||
continue
|
||||
if end_timestamp and ts > end_timestamp:
|
||||
continue
|
||||
results.append(entry.copy())
|
||||
if limit and len(results) >= limit:
|
||||
break
|
||||
return results
|
||||
|
||||
def query(
|
||||
self,
|
||||
filter_fn: Callable[[dict[str, Any]], bool],
|
||||
limit: int | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Query entries using a custom filter function.
|
||||
|
||||
Args:
|
||||
filter_fn: Function that returns True for entries to include.
|
||||
limit: Maximum entries to return.
|
||||
"""
|
||||
results = []
|
||||
for entry in self._entries:
|
||||
if filter_fn(entry):
|
||||
results.append(entry.copy())
|
||||
if limit and len(results) >= limit:
|
||||
break
|
||||
return results
|
||||
|
||||
def get_task_summary(self, task_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Get a summary of episodes for a task.
|
||||
|
||||
Returns statistics like count, first/last timestamps, event types.
|
||||
"""
|
||||
entries = self.get_by_task(task_id)
|
||||
if not entries:
|
||||
return {"task_id": task_id, "count": 0}
|
||||
|
||||
event_types: dict[str, int] = {}
|
||||
success_count = 0
|
||||
failure_count = 0
|
||||
|
||||
for entry in entries:
|
||||
etype = entry.get("event_type") or entry.get("type") or "unknown"
|
||||
event_types[etype] = event_types.get(etype, 0) + 1
|
||||
|
||||
if entry.get("success"):
|
||||
success_count += 1
|
||||
elif entry.get("error") or entry.get("success") is False:
|
||||
failure_count += 1
|
||||
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"count": len(entries),
|
||||
"first_timestamp": entries[0].get("datetime"),
|
||||
"last_timestamp": entries[-1].get("datetime"),
|
||||
"event_types": event_types,
|
||||
"success_count": success_count,
|
||||
"failure_count": failure_count,
|
||||
}
|
||||
|
||||
def get_statistics(self) -> dict[str, Any]:
|
||||
"""Get overall memory statistics."""
|
||||
return {
|
||||
"total_entries": len(self._entries),
|
||||
"archived_entries": self._archived_count,
|
||||
"task_count": len(self._by_task),
|
||||
"event_type_count": len(self._by_type),
|
||||
"event_types": list(self._by_type.keys()),
|
||||
}
|
||||
|
||||
def _archive_oldest(self, count: int) -> None:
|
||||
"""Archive/remove oldest entries to enforce size limits."""
|
||||
if count <= 0 or count >= len(self._entries):
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Archiving episodic memory entries",
|
||||
extra={"count": count, "total": len(self._entries)},
|
||||
)
|
||||
|
||||
# Remove oldest entries
|
||||
self._entries = self._entries[count:]
|
||||
self._archived_count += count
|
||||
|
||||
# Rebuild indices (entries shifted)
|
||||
self._by_task = {}
|
||||
self._by_type = {}
|
||||
for idx, entry in enumerate(self._entries):
|
||||
task_id = entry.get("task_id")
|
||||
if task_id:
|
||||
self._by_task.setdefault(task_id, []).append(idx)
|
||||
|
||||
etype = entry.get("event_type") or entry.get("type")
|
||||
if etype:
|
||||
self._by_type.setdefault(etype, []).append(idx)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all entries (for tests)."""
|
||||
self._entries.clear()
|
||||
self._by_task.clear()
|
||||
self._by_type.clear()
|
||||
self._archived_count = 0
|
||||
231
fusionagi/memory/postgres_backend.py
Normal file
231
fusionagi/memory/postgres_backend.py
Normal file
@@ -0,0 +1,231 @@
|
||||
"""Optional Postgres backend for memory. Requires: pip install fusionagi[memory]."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from fusionagi._logger import logger
|
||||
|
||||
|
||||
class MemoryBackend(ABC):
|
||||
"""Abstract backend for persistent memory storage."""
|
||||
|
||||
@abstractmethod
|
||||
def store(
|
||||
self,
|
||||
id: str,
|
||||
tenant_id: str,
|
||||
user_id: str,
|
||||
session_id: str,
|
||||
type: str,
|
||||
content: dict[str, Any],
|
||||
metadata: dict[str, Any] | None = None,
|
||||
retention_policy: str = "session",
|
||||
) -> None:
|
||||
"""Store a memory item."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def get(self, id: str) -> dict[str, Any] | None:
|
||||
"""Get a memory item by id."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def query(
|
||||
self,
|
||||
tenant_id: str,
|
||||
user_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
type: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Query memory items."""
|
||||
...
|
||||
|
||||
|
||||
class InMemoryBackend(MemoryBackend):
|
||||
"""In-memory implementation for development."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._store: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def store(
|
||||
self,
|
||||
id: str,
|
||||
tenant_id: str,
|
||||
user_id: str,
|
||||
session_id: str,
|
||||
type: str,
|
||||
content: dict[str, Any],
|
||||
metadata: dict[str, Any] | None = None,
|
||||
retention_policy: str = "session",
|
||||
) -> None:
|
||||
self._store[id] = {
|
||||
"id": id,
|
||||
"tenant_id": tenant_id,
|
||||
"user_id": user_id,
|
||||
"session_id": session_id,
|
||||
"type": type,
|
||||
"content": content,
|
||||
"metadata": metadata or {},
|
||||
"retention_policy": retention_policy,
|
||||
}
|
||||
|
||||
def get(self, id: str) -> dict[str, Any] | None:
|
||||
return self._store.get(id)
|
||||
|
||||
def query(
|
||||
self,
|
||||
tenant_id: str,
|
||||
user_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
type: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> list[dict[str, Any]]:
|
||||
out = []
|
||||
for v in self._store.values():
|
||||
if v["tenant_id"] != tenant_id:
|
||||
continue
|
||||
if user_id and v["user_id"] != user_id:
|
||||
continue
|
||||
if session_id and v["session_id"] != session_id:
|
||||
continue
|
||||
if type and v["type"] != type:
|
||||
continue
|
||||
out.append(v)
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def create_postgres_backend(connection_string: str) -> MemoryBackend | None:
|
||||
"""Create Postgres-backed MemoryBackend when psycopg is available."""
|
||||
try:
|
||||
import psycopg
|
||||
except ImportError:
|
||||
logger.debug("psycopg not installed; use pip install fusionagi[memory]")
|
||||
return None
|
||||
|
||||
return PostgresMemoryBackend(connection_string)
|
||||
|
||||
|
||||
class PostgresMemoryBackend(MemoryBackend):
|
||||
"""Postgres-backed memory storage."""
|
||||
|
||||
def __init__(self, connection_string: str) -> None:
|
||||
self._conn_str = connection_string
|
||||
self._init_schema()
|
||||
|
||||
def _init_schema(self) -> None:
|
||||
import psycopg
|
||||
|
||||
with psycopg.connect(self._conn_str) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS memory_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
content JSONB NOT NULL,
|
||||
metadata JSONB DEFAULT '{}',
|
||||
retention_policy TEXT DEFAULT 'session',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def store(
|
||||
self,
|
||||
id: str,
|
||||
tenant_id: str,
|
||||
user_id: str,
|
||||
session_id: str,
|
||||
type: str,
|
||||
content: dict[str, Any],
|
||||
metadata: dict[str, Any] | None = None,
|
||||
retention_policy: str = "session",
|
||||
) -> None:
|
||||
import json
|
||||
import psycopg
|
||||
|
||||
with psycopg.connect(self._conn_str) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO memory_items (id, tenant_id, user_id, session_id, type, content, metadata, retention_policy)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (id) DO UPDATE SET content = EXCLUDED.content, metadata = EXCLUDED.metadata
|
||||
""",
|
||||
(id, tenant_id, user_id, session_id, type, json.dumps(content), json.dumps(metadata or {}), retention_policy),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get(self, id: str) -> dict[str, Any] | None:
|
||||
import json
|
||||
import psycopg
|
||||
|
||||
with psycopg.connect(self._conn_str) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id, tenant_id, user_id, session_id, type, content, metadata, retention_policy FROM memory_items WHERE id = %s",
|
||||
(id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"id": row[0],
|
||||
"tenant_id": row[1],
|
||||
"user_id": row[2],
|
||||
"session_id": row[3],
|
||||
"type": row[4],
|
||||
"content": json.loads(row[5]) if row[5] else {},
|
||||
"metadata": json.loads(row[6]) if row[6] else {},
|
||||
"retention_policy": row[7],
|
||||
}
|
||||
|
||||
def query(
|
||||
self,
|
||||
tenant_id: str,
|
||||
user_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
type: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> list[dict[str, Any]]:
|
||||
import json
|
||||
import psycopg
|
||||
|
||||
q = "SELECT id, tenant_id, user_id, session_id, type, content, metadata, retention_policy FROM memory_items WHERE tenant_id = %s"
|
||||
params: list[Any] = [tenant_id]
|
||||
if user_id:
|
||||
q += " AND user_id = %s"
|
||||
params.append(user_id)
|
||||
if session_id:
|
||||
q += " AND session_id = %s"
|
||||
params.append(session_id)
|
||||
if type:
|
||||
q += " AND type = %s"
|
||||
params.append(type)
|
||||
q += " ORDER BY created_at DESC LIMIT %s"
|
||||
params.append(limit)
|
||||
|
||||
with psycopg.connect(self._conn_str) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(q, params)
|
||||
rows = cur.fetchall()
|
||||
return [
|
||||
{
|
||||
"id": r[0],
|
||||
"tenant_id": r[1],
|
||||
"user_id": r[2],
|
||||
"session_id": r[3],
|
||||
"type": r[4],
|
||||
"content": json.loads(r[5]) if r[5] else {},
|
||||
"metadata": json.loads(r[6]) if r[6] else {},
|
||||
"retention_policy": r[7],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
55
fusionagi/memory/procedural.py
Normal file
55
fusionagi/memory/procedural.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Procedural memory: reusable skills/workflows for AGI."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fusionagi.schemas.skill import Skill
|
||||
from fusionagi._logger import logger
|
||||
|
||||
|
||||
class ProceduralMemory:
|
||||
"""
|
||||
Skill store: reusable workflows and procedures. Invokable by name;
|
||||
write/update rules enforced by caller.
|
||||
"""
|
||||
|
||||
def __init__(self, max_skills: int = 5000) -> None:
|
||||
self._skills: dict[str, Skill] = {}
|
||||
self._by_name: dict[str, str] = {} # name -> skill_id (latest)
|
||||
self._max_skills = max_skills
|
||||
|
||||
def add_skill(self, skill: Skill) -> None:
|
||||
"""Register a skill (overwrites same skill_id)."""
|
||||
if len(self._skills) >= self._max_skills and skill.skill_id not in self._skills:
|
||||
self._evict_one()
|
||||
self._skills[skill.skill_id] = skill
|
||||
self._by_name[skill.name] = skill.skill_id
|
||||
logger.debug("Procedural memory: skill added", extra={"skill_id": skill.skill_id, "name": skill.name})
|
||||
|
||||
def get_skill(self, skill_id: str) -> Skill | None:
|
||||
"""Return skill by id or None."""
|
||||
return self._skills.get(skill_id)
|
||||
|
||||
def get_skill_by_name(self, name: str) -> Skill | None:
|
||||
"""Return latest skill with this name or None."""
|
||||
sid = self._by_name.get(name)
|
||||
return self._skills.get(sid) if sid else None
|
||||
|
||||
def list_skills(self, limit: int = 200) -> list[Skill]:
|
||||
"""Return skills (e.g. for planner)."""
|
||||
return list(self._skills.values())[-limit:]
|
||||
|
||||
def remove_skill(self, skill_id: str) -> bool:
|
||||
"""Remove skill. Returns True if existed."""
|
||||
if skill_id not in self._skills:
|
||||
return False
|
||||
name = self._skills[skill_id].name
|
||||
del self._skills[skill_id]
|
||||
if self._by_name.get(name) == skill_id:
|
||||
del self._by_name[name]
|
||||
return True
|
||||
|
||||
def _evict_one(self) -> None:
|
||||
if not self._skills:
|
||||
return
|
||||
rid = next(iter(self._skills))
|
||||
self.remove_skill(rid)
|
||||
31
fusionagi/memory/reflective.py
Normal file
31
fusionagi/memory/reflective.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Reflective memory: simple store for lessons learned / heuristics (used by Phase 3)."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ReflectiveMemory:
|
||||
"""Simple store for lessons and heuristics; append-only list or key-value."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lessons: list[dict[str, Any]] = []
|
||||
self._heuristics: dict[str, Any] = {}
|
||||
|
||||
def add_lesson(self, lesson: dict[str, Any]) -> None:
|
||||
"""Append a lesson (e.g. from Critic)."""
|
||||
self._lessons.append(lesson)
|
||||
|
||||
def get_lessons(self, limit: int = 50) -> list[dict[str, Any]]:
|
||||
"""Return recent lessons (copy)."""
|
||||
return [l.copy() for l in self._lessons[-limit:]]
|
||||
|
||||
def set_heuristic(self, key: str, value: Any) -> None:
|
||||
"""Set a heuristic (e.g. strategy hint)."""
|
||||
self._heuristics[key] = value
|
||||
|
||||
def get_heuristic(self, key: str) -> Any:
|
||||
"""Get heuristic by key."""
|
||||
return self._heuristics.get(key)
|
||||
|
||||
def get_all_heuristics(self) -> dict[str, Any]:
|
||||
"""Return all heuristics (copy)."""
|
||||
return dict(self._heuristics)
|
||||
70
fusionagi/memory/scratchpad.py
Normal file
70
fusionagi/memory/scratchpad.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Latent scratchpad: internal reasoning buffers for hypotheses and discarded paths."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from fusionagi._logger import logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class ThoughtState:
|
||||
"""Internal reasoning state: hypotheses, partial conclusions, discarded paths."""
|
||||
|
||||
hypotheses: list[str] = field(default_factory=list)
|
||||
partial_conclusions: list[str] = field(default_factory=list)
|
||||
discarded_paths: list[str] = field(default_factory=list)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class LatentScratchpad:
|
||||
"""
|
||||
Internal buffer for intermediate reasoning; not exposed to user.
|
||||
|
||||
Stores hypotheses, discarded paths, partial conclusions for meta-tracking.
|
||||
"""
|
||||
|
||||
def __init__(self, max_hypotheses: int = 100, max_discarded: int = 50) -> None:
|
||||
self._hypotheses: deque[str] = deque(maxlen=max_hypotheses)
|
||||
self._partial: deque[str] = deque(maxlen=50)
|
||||
self._discarded: deque[str] = deque(maxlen=max_discarded)
|
||||
self._metadata: dict[str, Any] = {}
|
||||
|
||||
def append_hypothesis(self, hypothesis: str) -> None:
|
||||
"""Append a reasoning hypothesis."""
|
||||
self._hypotheses.append(hypothesis)
|
||||
logger.debug("Scratchpad: hypothesis appended", extra={"len": len(self._hypotheses)})
|
||||
|
||||
def append_discarded(self, path: str) -> None:
|
||||
"""Append a discarded reasoning path."""
|
||||
self._discarded.append(path)
|
||||
|
||||
def append_partial(self, conclusion: str) -> None:
|
||||
"""Append a partial conclusion."""
|
||||
self._partial.append(conclusion)
|
||||
|
||||
def get_intermediate(self) -> ThoughtState:
|
||||
"""Get current intermediate state."""
|
||||
return ThoughtState(
|
||||
hypotheses=list(self._hypotheses),
|
||||
partial_conclusions=list(self._partial),
|
||||
discarded_paths=list(self._discarded),
|
||||
metadata=dict(self._metadata),
|
||||
)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear scratchpad."""
|
||||
self._hypotheses.clear()
|
||||
self._partial.clear()
|
||||
self._discarded.clear()
|
||||
self._metadata.clear()
|
||||
|
||||
def set_metadata(self, key: str, value: Any) -> None:
|
||||
"""Set metadata entry."""
|
||||
self._metadata[key] = value
|
||||
|
||||
def get_metadata(self, key: str, default: Any = None) -> Any:
|
||||
"""Get metadata entry."""
|
||||
return self._metadata.get(key, default)
|
||||
55
fusionagi/memory/semantic.py
Normal file
55
fusionagi/memory/semantic.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Semantic memory: facts, policies, domain knowledge for AGI."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fusionagi._logger import logger
|
||||
|
||||
|
||||
class SemanticMemory:
|
||||
"""Stores facts, policies, domain knowledge. Queryable and updatable."""
|
||||
|
||||
def __init__(self, max_facts: int = 50000) -> None:
|
||||
self._facts: dict[str, dict[str, Any]] = {}
|
||||
self._by_key: dict[str, list[str]] = {}
|
||||
self._max_facts = max_facts
|
||||
|
||||
def add_fact(self, fact_id: str, statement: str, source: str = "", domain: str = "", metadata: dict[str, Any] | None = None) -> None:
|
||||
if len(self._facts) >= self._max_facts and fact_id not in self._facts:
|
||||
self._evict_one()
|
||||
entry = {"statement": statement, "source": source, "domain": domain, "metadata": metadata or {}}
|
||||
self._facts[fact_id] = entry
|
||||
if domain:
|
||||
self._by_key.setdefault(domain, []).append(fact_id)
|
||||
logger.debug("Semantic memory: fact added", extra={"fact_id": fact_id, "domain": domain})
|
||||
|
||||
def get_fact(self, fact_id: str) -> dict[str, Any] | None:
|
||||
return self._facts.get(fact_id)
|
||||
|
||||
def query(self, domain: str | None = None, limit: int = 100) -> list[dict[str, Any]]:
|
||||
if domain:
|
||||
ids = self._by_key.get(domain, [])[-limit:]
|
||||
return [self._facts[id] for id in ids if id in self._facts]
|
||||
return list(self._facts.values())[-limit:]
|
||||
|
||||
def update_fact(self, fact_id: str, **kwargs: Any) -> bool:
|
||||
if fact_id not in self._facts:
|
||||
return False
|
||||
for k, v in kwargs.items():
|
||||
if k in self._facts[fact_id]:
|
||||
self._facts[fact_id][k] = v
|
||||
return True
|
||||
|
||||
def forget(self, fact_id: str) -> bool:
|
||||
if fact_id not in self._facts:
|
||||
return False
|
||||
entry = self._facts.pop(fact_id)
|
||||
domain = entry.get("domain", "")
|
||||
if domain and fact_id in self._by_key.get(domain, []):
|
||||
self._by_key[domain] = [x for x in self._by_key[domain] if x != fact_id]
|
||||
return True
|
||||
|
||||
def _evict_one(self) -> None:
|
||||
if not self._facts:
|
||||
return
|
||||
rid = next(iter(self._facts))
|
||||
self.forget(rid)
|
||||
106
fusionagi/memory/semantic_graph.py
Normal file
106
fusionagi/memory/semantic_graph.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Semantic memory graph: nodes = AtomicSemanticUnit, edges = SemanticRelation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
from fusionagi.schemas.atomic import (
|
||||
AtomicSemanticUnit,
|
||||
AtomicUnitType,
|
||||
SemanticRelation,
|
||||
)
|
||||
from fusionagi._logger import logger
|
||||
|
||||
|
||||
class SemanticGraphMemory:
|
||||
"""
|
||||
Graph-backed semantic memory: nodes = atomic units, edges = relations.
|
||||
|
||||
Supports add_unit, add_relation, query_units, query_neighbors, query_by_type.
|
||||
In-memory implementation with dict + adjacency list.
|
||||
"""
|
||||
|
||||
def __init__(self, max_units: int = 50000) -> None:
|
||||
self._units: dict[str, AtomicSemanticUnit] = {}
|
||||
self._by_type: dict[AtomicUnitType, list[str]] = defaultdict(list)
|
||||
self._outgoing: dict[str, list[SemanticRelation]] = defaultdict(list)
|
||||
self._incoming: dict[str, list[SemanticRelation]] = defaultdict(list)
|
||||
self._max_units = max_units
|
||||
|
||||
def add_unit(self, unit: AtomicSemanticUnit) -> None:
|
||||
"""Add an atomic semantic unit."""
|
||||
if len(self._units) >= self._max_units and unit.unit_id not in self._units:
|
||||
self._evict_one()
|
||||
self._units[unit.unit_id] = unit
|
||||
self._by_type[unit.type].append(unit.unit_id)
|
||||
logger.debug("Semantic graph: unit added", extra={"unit_id": unit.unit_id, "type": unit.type.value})
|
||||
|
||||
def add_relation(self, relation: SemanticRelation) -> None:
|
||||
"""Add a relation between units."""
|
||||
if relation.from_id in self._units and relation.to_id in self._units:
|
||||
self._outgoing[relation.from_id].append(relation)
|
||||
self._incoming[relation.to_id].append(relation)
|
||||
|
||||
def get_unit(self, unit_id: str) -> AtomicSemanticUnit | None:
|
||||
"""Get unit by ID."""
|
||||
return self._units.get(unit_id)
|
||||
|
||||
def query_units(
|
||||
self,
|
||||
unit_ids: list[str] | None = None,
|
||||
unit_type: AtomicUnitType | None = None,
|
||||
limit: int = 100,
|
||||
) -> list[AtomicSemanticUnit]:
|
||||
"""Query units by IDs or type."""
|
||||
if unit_ids:
|
||||
return [self._units[uid] for uid in unit_ids if uid in self._units][:limit]
|
||||
if unit_type:
|
||||
ids = self._by_type.get(unit_type, [])[-limit:]
|
||||
return [self._units[uid] for uid in ids if uid in self._units]
|
||||
return list(self._units.values())[-limit:]
|
||||
|
||||
def query_neighbors(
|
||||
self,
|
||||
unit_id: str,
|
||||
direction: str = "outgoing",
|
||||
relation_type: str | None = None,
|
||||
) -> list[tuple[AtomicSemanticUnit, SemanticRelation]]:
|
||||
"""Get neighboring units and relations."""
|
||||
edges = self._outgoing[unit_id] if direction == "outgoing" else self._incoming[unit_id]
|
||||
results: list[tuple[AtomicSemanticUnit, SemanticRelation]] = []
|
||||
for rel in edges:
|
||||
if relation_type and rel.relation_type.value != relation_type:
|
||||
continue
|
||||
other_id = rel.to_id if direction == "outgoing" else rel.from_id
|
||||
other = self._units.get(other_id)
|
||||
if other:
|
||||
results.append((other, rel))
|
||||
return results
|
||||
|
||||
def query_by_type(self, unit_type: AtomicUnitType, limit: int = 100) -> list[AtomicSemanticUnit]:
|
||||
"""Query units by type."""
|
||||
return self.query_units(unit_type=unit_type, limit=limit)
|
||||
|
||||
def ingest_decomposition(
|
||||
self,
|
||||
units: list[AtomicSemanticUnit],
|
||||
relations: list[SemanticRelation],
|
||||
) -> None:
|
||||
"""Ingest a DecompositionResult into the graph."""
|
||||
for u in units:
|
||||
self.add_unit(u)
|
||||
for r in relations:
|
||||
self.add_relation(r)
|
||||
|
||||
def _evict_one(self) -> None:
|
||||
"""Evict oldest unit (simple FIFO on first key)."""
|
||||
if not self._units:
|
||||
return
|
||||
uid = next(iter(self._units))
|
||||
unit = self._units.pop(uid, None)
|
||||
if unit:
|
||||
self._by_type[unit.type] = [x for x in self._by_type[unit.type] if x != uid]
|
||||
self._outgoing.pop(uid, None)
|
||||
self._incoming.pop(uid, None)
|
||||
logger.debug("Semantic graph: evicted unit", extra={"unit_id": uid})
|
||||
97
fusionagi/memory/service.py
Normal file
97
fusionagi/memory/service.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Unified memory service: session, episodic, semantic, vector with tenant isolation."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fusionagi.memory.working import WorkingMemory
|
||||
from fusionagi.memory.episodic import EpisodicMemory
|
||||
from fusionagi.memory.semantic import SemanticMemory
|
||||
|
||||
|
||||
def _scoped_key(tenant_id: str, user_id: str, base: str) -> str:
|
||||
"""Scope key by tenant and user."""
|
||||
parts = [tenant_id or "default", user_id or "anonymous", base]
|
||||
return ":".join(parts)
|
||||
|
||||
|
||||
class VectorMemory:
|
||||
"""
|
||||
Vector memory for embeddings retrieval.
|
||||
Stub implementation; replace with pgvector or Pinecone adapter for production.
|
||||
"""
|
||||
|
||||
def __init__(self, max_entries: int = 10000) -> None:
|
||||
self._store: list[dict[str, Any]] = []
|
||||
self._max_entries = max_entries
|
||||
|
||||
def add(self, id: str, embedding: list[float], metadata: dict[str, Any] | None = None) -> None:
|
||||
"""Add embedding (stub: stores in-memory)."""
|
||||
if len(self._store) >= self._max_entries:
|
||||
self._store.pop(0)
|
||||
self._store.append({"id": id, "embedding": embedding, "metadata": metadata or {}})
|
||||
|
||||
def search(self, query_embedding: list[float], top_k: int = 10) -> list[dict[str, Any]]:
|
||||
"""Search by embedding (stub: returns empty)."""
|
||||
return []
|
||||
|
||||
|
||||
class MemoryService:
|
||||
"""
|
||||
Unified memory service with tenant isolation.
|
||||
Wraps WorkingMemory (session), EpisodicMemory, SemanticMemory, VectorMemory.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tenant_id: str = "default",
|
||||
user_id: str | None = None,
|
||||
) -> None:
|
||||
self._tenant_id = tenant_id
|
||||
self._user_id = user_id or "anonymous"
|
||||
self._working = WorkingMemory()
|
||||
self._episodic = EpisodicMemory()
|
||||
self._semantic = SemanticMemory()
|
||||
self._vector = VectorMemory()
|
||||
|
||||
@property
|
||||
def session(self) -> WorkingMemory:
|
||||
"""Short-term session memory."""
|
||||
return self._working
|
||||
|
||||
@property
|
||||
def episodic(self) -> EpisodicMemory:
|
||||
"""Episodic memory (what happened, decisions, outcomes)."""
|
||||
return self._episodic
|
||||
|
||||
@property
|
||||
def semantic(self) -> SemanticMemory:
|
||||
"""Semantic memory (facts, preferences)."""
|
||||
return self._semantic
|
||||
|
||||
@property
|
||||
def vector(self) -> VectorMemory:
|
||||
"""Vector memory (embeddings for retrieval)."""
|
||||
return self._vector
|
||||
|
||||
def scope_session(self, session_id: str) -> str:
|
||||
"""Return tenant/user scoped session key."""
|
||||
return _scoped_key(self._tenant_id, self._user_id, session_id)
|
||||
|
||||
def get(self, session_id: str, key: str, default: Any = None) -> Any:
|
||||
"""Get from session memory (scoped)."""
|
||||
scoped = self.scope_session(session_id)
|
||||
return self._working.get(scoped, key, default)
|
||||
|
||||
def set(self, session_id: str, key: str, value: Any) -> None:
|
||||
"""Set in session memory (scoped)."""
|
||||
scoped = self.scope_session(session_id)
|
||||
self._working.set(scoped, key, value)
|
||||
|
||||
def append_episode(self, task_id: str, event: dict[str, Any], event_type: str | None = None) -> int:
|
||||
"""Append to episodic memory (with tenant in metadata)."""
|
||||
event = dict(event)
|
||||
meta = event.setdefault("metadata", {})
|
||||
meta = dict(meta) if meta else {}
|
||||
meta["tenant_id"] = self._tenant_id
|
||||
meta["user_id"] = self._user_id
|
||||
event["metadata"] = meta
|
||||
return self._episodic.append(task_id, event, event_type)
|
||||
79
fusionagi/memory/sharding.py
Normal file
79
fusionagi/memory/sharding.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Context sharding: cluster atomic units by semantic similarity or domain."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from fusionagi.schemas.atomic import AtomicSemanticUnit
|
||||
|
||||
|
||||
@dataclass
|
||||
class Shard:
|
||||
"""A cluster of atomic units with optional summary and embedding."""
|
||||
|
||||
shard_id: str = field(default_factory=lambda: f"shard_{uuid.uuid4().hex[:12]}")
|
||||
unit_ids: list[str] = field(default_factory=list)
|
||||
summary: str = ""
|
||||
embedding: list[float] | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _extract_keywords(text: str) -> set[str]:
|
||||
"""Extract keywords for clustering."""
|
||||
content = " ".join(text.lower().split())
|
||||
return set(re.findall(r"\b[a-z0-9]{3,}\b", content))
|
||||
|
||||
|
||||
def _keyword_similarity(a: set[str], b: set[str]) -> float:
|
||||
"""Jaccard similarity between keyword sets."""
|
||||
if not a and not b:
|
||||
return 1.0
|
||||
inter = len(a & b)
|
||||
union = len(a | b)
|
||||
return inter / union if union else 0.0
|
||||
|
||||
|
||||
def _cluster_by_keywords(
|
||||
units: list[AtomicSemanticUnit],
|
||||
max_cluster_size: int,
|
||||
) -> list[list[AtomicSemanticUnit]]:
|
||||
"""Cluster units by keyword overlap (greedy)."""
|
||||
if not units:
|
||||
return []
|
||||
if len(units) <= max_cluster_size:
|
||||
return [units]
|
||||
unit_keywords: list[set[str]] = [_extract_keywords(u.content) for u in units]
|
||||
clusters: list[list[int]] = []
|
||||
assigned: set[int] = set()
|
||||
for i in range(len(units)):
|
||||
if i in assigned:
|
||||
continue
|
||||
cluster = [i]
|
||||
assigned.add(i)
|
||||
for j in range(i + 1, len(units)):
|
||||
if j in assigned or len(cluster) >= max_cluster_size:
|
||||
continue
|
||||
sim = _keyword_similarity(unit_keywords[i], unit_keywords[j])
|
||||
if sim > 0.1:
|
||||
cluster.append(j)
|
||||
assigned.add(j)
|
||||
clusters.append(cluster)
|
||||
return [[units[idx] for idx in c] for c in clusters]
|
||||
|
||||
|
||||
def shard_context(
|
||||
units: list[AtomicSemanticUnit],
|
||||
max_cluster_size: int = 20,
|
||||
) -> list[Shard]:
|
||||
"""Shard atomic units into clusters by semantic similarity."""
|
||||
clusters = _cluster_by_keywords(units, max_cluster_size)
|
||||
shards: list[Shard] = []
|
||||
for cluster in clusters:
|
||||
unit_ids = [u.unit_id for u in cluster]
|
||||
summary_parts = [u.content[:80] for u in cluster[:3]]
|
||||
summary = "; ".join(summary_parts) + ("..." if len(cluster) > 3 else "")
|
||||
shards.append(Shard(unit_ids=unit_ids, summary=summary, metadata={"unit_count": len(cluster)}))
|
||||
return shards
|
||||
134
fusionagi/memory/thought_versioning.py
Normal file
134
fusionagi/memory/thought_versioning.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Versioned thought states: snapshots, rollback, branching."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from fusionagi.memory.scratchpad import ThoughtState
|
||||
from fusionagi.reasoning.tot import ThoughtNode
|
||||
from fusionagi._logger import logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class ThoughtStateSnapshot:
|
||||
"""Snapshot of reasoning state: tree + scratchpad."""
|
||||
|
||||
version_id: str = field(default_factory=lambda: f"v_{uuid.uuid4().hex[:12]}")
|
||||
tree_state: dict[str, Any] | None = None
|
||||
scratchpad_state: ThoughtState | None = None
|
||||
timestamp: float = field(default_factory=time.monotonic)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _serialize_tree(node: ThoughtNode | None) -> dict[str, Any]:
|
||||
"""Serialize ThoughtNode to dict."""
|
||||
if node is None:
|
||||
return {}
|
||||
return {
|
||||
"node_id": node.node_id,
|
||||
"parent_id": node.parent_id,
|
||||
"thought": node.thought,
|
||||
"trace": node.trace,
|
||||
"score": node.score,
|
||||
"depth": node.depth,
|
||||
"unit_refs": node.unit_refs,
|
||||
"metadata": node.metadata,
|
||||
"children": [_serialize_tree(c) for c in node.children],
|
||||
}
|
||||
|
||||
|
||||
def _deserialize_tree(data: dict) -> ThoughtNode | None:
|
||||
"""Deserialize dict to ThoughtNode."""
|
||||
if not data:
|
||||
return None
|
||||
node = ThoughtNode(
|
||||
node_id=data.get("node_id", ""),
|
||||
parent_id=data.get("parent_id"),
|
||||
thought=data.get("thought", ""),
|
||||
trace=data.get("trace", []),
|
||||
score=float(data.get("score", 0)),
|
||||
depth=int(data.get("depth", 0)),
|
||||
unit_refs=list(data.get("unit_refs", [])),
|
||||
metadata=dict(data.get("metadata", {})),
|
||||
)
|
||||
for c in data.get("children", []):
|
||||
child = _deserialize_tree(c)
|
||||
if child:
|
||||
node.children.append(child)
|
||||
return node
|
||||
|
||||
|
||||
class ThoughtVersioning:
|
||||
"""Save, load, rollback, branch thought states."""
|
||||
|
||||
def __init__(self, max_snapshots: int = 50) -> None:
|
||||
self._snapshots: dict[str, ThoughtStateSnapshot] = {}
|
||||
self._max_snapshots = max_snapshots
|
||||
|
||||
def save_snapshot(
|
||||
self,
|
||||
tree: ThoughtNode | None,
|
||||
scratchpad: ThoughtState | None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""Save snapshot; return version_id."""
|
||||
snapshot = ThoughtStateSnapshot(
|
||||
tree_state=_serialize_tree(tree) if tree else {},
|
||||
scratchpad_state=scratchpad,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
self._snapshots[snapshot.version_id] = snapshot
|
||||
if len(self._snapshots) > self._max_snapshots:
|
||||
oldest = min(self._snapshots.keys(), key=lambda k: self._snapshots[k].timestamp)
|
||||
del self._snapshots[oldest]
|
||||
logger.debug("Thought snapshot saved", extra={"version_id": snapshot.version_id})
|
||||
return snapshot.version_id
|
||||
|
||||
def load_snapshot(
|
||||
self,
|
||||
version_id: str,
|
||||
) -> tuple[ThoughtNode | None, ThoughtState | None]:
|
||||
"""Load snapshot; return (tree, scratchpad)."""
|
||||
snap = self._snapshots.get(version_id)
|
||||
if not snap:
|
||||
return None, None
|
||||
tree = _deserialize_tree(snap.tree_state or {}) if snap.tree_state else None
|
||||
return tree, snap.scratchpad_state
|
||||
|
||||
def list_snapshots(self) -> list[dict[str, Any]]:
|
||||
"""List available snapshots."""
|
||||
return [
|
||||
{
|
||||
"version_id": v.version_id,
|
||||
"timestamp": v.timestamp,
|
||||
"metadata": v.metadata,
|
||||
}
|
||||
for v in self._snapshots.values()
|
||||
]
|
||||
|
||||
def rollback_to(
|
||||
self,
|
||||
version_id: str,
|
||||
) -> tuple[ThoughtNode | None, ThoughtState | None]:
|
||||
"""Load and return snapshot (alias for load_snapshot)."""
|
||||
return self.load_snapshot(version_id)
|
||||
|
||||
def branch_from(
|
||||
self,
|
||||
version_id: str,
|
||||
) -> tuple[ThoughtNode | None, ThoughtState | None]:
|
||||
"""Branch from snapshot (returns copy for further edits)."""
|
||||
tree, scratchpad = self.load_snapshot(version_id)
|
||||
if tree:
|
||||
tree = _deserialize_tree(_serialize_tree(tree))
|
||||
if scratchpad:
|
||||
scratchpad = ThoughtState(
|
||||
hypotheses=list(scratchpad.hypotheses),
|
||||
partial_conclusions=list(scratchpad.partial_conclusions),
|
||||
discarded_paths=list(scratchpad.discarded_paths),
|
||||
metadata=dict(scratchpad.metadata),
|
||||
)
|
||||
return tree, scratchpad
|
||||
64
fusionagi/memory/trust.py
Normal file
64
fusionagi/memory/trust.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Trust memory: verified vs unverified, provenance, confidence decay for AGI."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fusionagi._logger import logger
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class TrustMemory:
|
||||
"""
|
||||
Tracks verified vs unverified claims, source provenance, and optional
|
||||
confidence decay over time.
|
||||
"""
|
||||
|
||||
def __init__(self, decay_enabled: bool = False) -> None:
|
||||
self._entries: dict[str, dict[str, Any]] = {} # claim_id -> {verified, source, confidence, created_at}
|
||||
self._decay_enabled = decay_enabled
|
||||
|
||||
def add(
|
||||
self,
|
||||
claim_id: str,
|
||||
verified: bool,
|
||||
source: str = "",
|
||||
confidence: float = 1.0,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Record a claim with verification status and provenance."""
|
||||
self._entries[claim_id] = {
|
||||
"verified": verified,
|
||||
"source": source,
|
||||
"confidence": confidence,
|
||||
"created_at": _utc_now(),
|
||||
"metadata": metadata or {},
|
||||
}
|
||||
logger.debug("Trust memory: claim added", extra={"claim_id": claim_id, "verified": verified})
|
||||
|
||||
def get(self, claim_id: str) -> dict[str, Any] | None:
|
||||
"""Return trust entry or None. Applies decay if enabled."""
|
||||
e = self._entries.get(claim_id)
|
||||
if not e:
|
||||
return None
|
||||
if self._decay_enabled:
|
||||
# Simple decay: reduce confidence by 0.01 per day (placeholder)
|
||||
from datetime import timedelta
|
||||
age_days = (_utc_now() - e["created_at"]).total_seconds() / 86400
|
||||
e = dict(e)
|
||||
e["confidence"] = max(0.0, e["confidence"] - 0.01 * age_days)
|
||||
return e
|
||||
|
||||
def is_verified(self, claim_id: str) -> bool:
|
||||
"""Return True if claim is marked verified."""
|
||||
e = self._entries.get(claim_id)
|
||||
return e.get("verified", False) if e else False
|
||||
|
||||
def set_verified(self, claim_id: str, verified: bool) -> bool:
|
||||
"""Update verified status. Returns True if claim existed."""
|
||||
if claim_id not in self._entries:
|
||||
return False
|
||||
self._entries[claim_id]["verified"] = verified
|
||||
return True
|
||||
101
fusionagi/memory/vector_pgvector.py
Normal file
101
fusionagi/memory/vector_pgvector.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""pgvector-backed VectorMemory adapter. Requires: pip install fusionagi[vector]."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fusionagi._logger import logger
|
||||
|
||||
|
||||
def create_vector_memory_pgvector(
|
||||
connection_string: str,
|
||||
table_name: str = "embeddings",
|
||||
dimension: int = 1536,
|
||||
) -> Any:
|
||||
"""
|
||||
Create pgvector-backed VectorMemory when pgvector is installed.
|
||||
Returns None if pgvector/database unavailable.
|
||||
"""
|
||||
try:
|
||||
import pgvector
|
||||
from pgvector.psycopg import register_vector
|
||||
except ImportError:
|
||||
logger.debug("pgvector not installed; use pip install fusionagi[vector]")
|
||||
return None
|
||||
|
||||
try:
|
||||
import psycopg
|
||||
except ImportError:
|
||||
logger.debug("psycopg not installed; use pip install fusionagi[memory]")
|
||||
return None
|
||||
|
||||
return VectorMemoryPgvector(connection_string, table_name, dimension)
|
||||
|
||||
|
||||
class VectorMemoryPgvector:
|
||||
"""VectorMemory implementation using pgvector."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
connection_string: str,
|
||||
table_name: str = "embeddings",
|
||||
dimension: int = 1536,
|
||||
) -> None:
|
||||
import pgvector
|
||||
from pgvector.psycopg import register_vector
|
||||
|
||||
self._conn_str = connection_string
|
||||
self._table = table_name
|
||||
self._dim = dimension
|
||||
|
||||
with psycopg.connect(connection_string) as conn:
|
||||
register_vector(conn)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
||||
cur.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS {table_name} (
|
||||
id TEXT PRIMARY KEY,
|
||||
embedding vector({dimension}),
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def add(self, id: str, embedding: list[float], metadata: dict[str, Any] | None = None) -> None:
|
||||
import json
|
||||
import psycopg
|
||||
from pgvector.psycopg import register_vector
|
||||
|
||||
with psycopg.connect(self._conn_str) as conn:
|
||||
register_vector(conn)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
INSERT INTO {self._table} (id, embedding, metadata)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (id) DO UPDATE SET embedding = EXCLUDED.embedding, metadata = EXCLUDED.metadata
|
||||
""",
|
||||
(id, embedding, json.dumps(metadata or {})),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def search(self, query_embedding: list[float], top_k: int = 10) -> list[dict[str, Any]]:
|
||||
import json
|
||||
import psycopg
|
||||
from pgvector.psycopg import register_vector
|
||||
|
||||
with psycopg.connect(self._conn_str) as conn:
|
||||
register_vector(conn)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT id, metadata
|
||||
FROM {self._table}
|
||||
ORDER BY embedding <-> %s
|
||||
LIMIT %s
|
||||
""",
|
||||
(query_embedding, top_k),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [{"id": r[0], "metadata": json.loads(r[1]) if r[1] else {}} for r in rows]
|
||||
150
fusionagi/memory/working.py
Normal file
150
fusionagi/memory/working.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""Working memory: in-memory key-value / list per task/session.
|
||||
|
||||
Working memory provides short-term storage for active tasks:
|
||||
- Key-value storage per session/task
|
||||
- List append operations for accumulating results
|
||||
- Context retrieval for reasoning
|
||||
- Session lifecycle management
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from typing import Any, Iterator
|
||||
|
||||
from fusionagi._logger import logger
|
||||
from fusionagi._time import utc_now
|
||||
|
||||
|
||||
class WorkingMemory:
|
||||
"""
|
||||
Short-term working memory per task/session.
|
||||
|
||||
Features:
|
||||
- Key-value get/set operations
|
||||
- List append with automatic coercion
|
||||
- Context summary for LLM prompts
|
||||
- Session management and cleanup
|
||||
- Size limits to prevent unbounded growth
|
||||
"""
|
||||
|
||||
def __init__(self, max_entries_per_session: int = 1000) -> None:
|
||||
"""
|
||||
Initialize working memory.
|
||||
|
||||
Args:
|
||||
max_entries_per_session: Maximum entries per session before oldest are removed.
|
||||
"""
|
||||
self._store: dict[str, dict[str, Any]] = defaultdict(dict)
|
||||
self._timestamps: dict[str, datetime] = {}
|
||||
self._max_entries = max_entries_per_session
|
||||
|
||||
def get(self, session_id: str, key: str, default: Any = None) -> Any:
|
||||
"""Get value for session and key; returns default if not found."""
|
||||
return self._store[session_id].get(key, default)
|
||||
|
||||
def set(self, session_id: str, key: str, value: Any) -> None:
|
||||
"""Set value for session and key."""
|
||||
self._store[session_id][key] = value
|
||||
self._timestamps[session_id] = utc_now()
|
||||
self._enforce_limits(session_id)
|
||||
|
||||
def append(self, session_id: str, key: str, value: Any) -> None:
|
||||
"""Append to list for session and key (creates list if needed)."""
|
||||
if key not in self._store[session_id]:
|
||||
self._store[session_id][key] = []
|
||||
lst = self._store[session_id][key]
|
||||
if not isinstance(lst, list):
|
||||
lst = [lst]
|
||||
self._store[session_id][key] = lst
|
||||
lst.append(value)
|
||||
self._timestamps[session_id] = utc_now()
|
||||
self._enforce_limits(session_id)
|
||||
|
||||
def get_list(self, session_id: str, key: str) -> list[Any]:
|
||||
"""Return list for session and key (copy)."""
|
||||
val = self._store[session_id].get(key)
|
||||
if isinstance(val, list):
|
||||
return list(val)
|
||||
return [val] if val is not None else []
|
||||
|
||||
def has(self, session_id: str, key: str) -> bool:
|
||||
"""Check if a key exists in session."""
|
||||
return key in self._store.get(session_id, {})
|
||||
|
||||
def keys(self, session_id: str) -> list[str]:
|
||||
"""Return all keys for a session."""
|
||||
return list(self._store.get(session_id, {}).keys())
|
||||
|
||||
def delete(self, session_id: str, key: str) -> bool:
|
||||
"""Delete a key from session. Returns True if existed."""
|
||||
if session_id in self._store and key in self._store[session_id]:
|
||||
del self._store[session_id][key]
|
||||
return True
|
||||
return False
|
||||
|
||||
def clear_session(self, session_id: str) -> None:
|
||||
"""Clear all data for a session."""
|
||||
self._store.pop(session_id, None)
|
||||
self._timestamps.pop(session_id, None)
|
||||
|
||||
def get_context_summary(self, session_id: str, max_items: int = 10) -> dict[str, Any]:
|
||||
"""
|
||||
Get a summary of working memory for context injection.
|
||||
|
||||
Useful for including relevant context in LLM prompts.
|
||||
"""
|
||||
session_data = self._store.get(session_id, {})
|
||||
summary = {}
|
||||
|
||||
for key, value in list(session_data.items())[:max_items]:
|
||||
if isinstance(value, list):
|
||||
# For lists, include count and last few items
|
||||
summary[key] = {
|
||||
"type": "list",
|
||||
"count": len(value),
|
||||
"recent": value[-3:] if len(value) > 3 else value,
|
||||
}
|
||||
elif isinstance(value, dict):
|
||||
# For dicts, include keys
|
||||
summary[key] = {
|
||||
"type": "dict",
|
||||
"keys": list(value.keys())[:10],
|
||||
}
|
||||
else:
|
||||
# For scalars, include the value (truncated if string)
|
||||
if isinstance(value, str) and len(value) > 200:
|
||||
summary[key] = value[:200] + "..."
|
||||
else:
|
||||
summary[key] = value
|
||||
|
||||
return summary
|
||||
|
||||
def get_all(self, session_id: str) -> dict[str, Any]:
|
||||
"""Return all data for a session (copy)."""
|
||||
return dict(self._store.get(session_id, {}))
|
||||
|
||||
def session_exists(self, session_id: str) -> bool:
|
||||
"""Check if a session has any data."""
|
||||
return session_id in self._store and bool(self._store[session_id])
|
||||
|
||||
def active_sessions(self) -> list[str]:
|
||||
"""Return list of sessions with data."""
|
||||
return [sid for sid, data in self._store.items() if data]
|
||||
|
||||
def session_count(self) -> int:
|
||||
"""Return number of active sessions."""
|
||||
return len([s for s in self._store.values() if s])
|
||||
|
||||
def _enforce_limits(self, session_id: str) -> None:
|
||||
"""Enforce size limits on session data."""
|
||||
session_data = self._store.get(session_id, {})
|
||||
total_items = sum(
|
||||
len(v) if isinstance(v, (list, dict)) else 1
|
||||
for v in session_data.values()
|
||||
)
|
||||
|
||||
if total_items > self._max_entries:
|
||||
logger.warning(
|
||||
"Working memory size limit exceeded",
|
||||
extra={"session_id": session_id, "items": total_items},
|
||||
)
|
||||
Reference in New Issue
Block a user