File size: 6,857 Bytes
28a08e7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6c6290b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28a08e7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
import logging
from .working import WorkingMemory
from .episodic import EpisodicMemory
from .semantic import SemanticMemory
from .reflection import ReflectionMemory

_logger = logging.getLogger("memory.manager")

class MemoryManager:
    """
    S569: Unified Memory Manager (ARCH-K2.3).
    Coordina i 4 layer di memoria dell'agente.
    """
    def __init__(self, sb_client=None, chroma_client=None):
        self.working    = WorkingMemory()
        self.episodic   = EpisodicMemory()
        self.semantic   = SemanticMemory(sb_client, chroma_client)
        self.reflection = ReflectionMemory()
        
    async def init(self):
        """Inizializzazione asincrona (es. caricamento snapshot)."""
        await self.semantic.init()
        # S569: Auto-restore semantica se vuota
        await self._auto_restore_semantic()
        _logger.info("[MemoryManager] Layer inizializzati: working, episodic, semantic (pgvector=%s), reflection", 
                     getattr(self.semantic, '_pgvector', False))

    async def save_working(self, goal: str, plan: list, facts: list):
        self.working.update(goal, plan, facts)
        # S569: backup periodico della working memory su episodic
        await self.save_episode("checkpoint", goal, f"Plan: {len(plan)} steps, Facts: {len(facts)}", True)

    async def save_episode(self, type_: str, task: str, output: str, success: bool, tags: list | None = None):
        self.episodic.add(type_, task, output, success, tags)
        if self.semantic.available and task:
            self.semantic.add(task, {"type": type_, "success": success})

    async def search(self, query: str, n: int = 5, layer: str | None = None) -> list[dict]:
        results = []
        if layer in (None, "semantic") and self.semantic.available:
            for h in self.semantic.search(query, n_results=n):
                results.append({**h, "layer": "semantic"})
        if layer in (None, "episodic"):
            for ep in self.episodic.search_text(query, n=n):
                results.append({
                    "content": f"{ep.task} β†’ {ep.output[:300]}",
                    "layer":   "episodic",
                    "type":    ep.type,
                    "success": ep.success,
                })
        if layer == "reflection":
            lessons = self.reflection.get_relevant_lessons(query, n=n)
            results.extend([{**l, "layer": "reflection"} for l in lessons])
        return results[:n]

    async def get_context(self, query: str, code_length: int = 0, n: int = 5) -> str:
        """Return a bounded text context for consumers such as UnifiedAgentLoop.

        The loop needs a context-shaped view, while the public manager API exposes
        structured search results. Keep this adapter here so callers do not reach
        into individual memory layers or depend on their implementation details.
        """
        if not query:
            return ""

        hits = await self.search(query, n=n)
        if not hits:
            return ""

        # Leave room for the current prompt/context; never inject an unbounded
        # memory payload into a long-running agent loop.
        max_chars = max(1000, min(4000, 4000 - max(0, code_length)))
        parts: list[str] = []
        used = 0
        for hit in hits:
            content = str(hit.get("content", "")).strip()
            if not content:
                continue
            layer = str(hit.get("layer", "memory"))
            block = f"[{layer}] {content}"
            remaining = max_chars - used
            if remaining <= 0:
                break
            parts.append(block[:remaining])
            used += len(parts[-1]) + 1

        return "\n".join(parts).strip()

    async def reflect(self, task: str, output: str, success: bool, error: str | None = None) -> dict:
        if success:
            self.reflection.record_success(task, output[:500])
            await self.save_episode("fix", task, output, True)
        else:
            self.reflection.record_failure(task, error or output[:500])
            await self.save_episode("error", task, error or output[:500], False)
        return {
            "recorded":     True,
            "top_patterns": self.reflection.get_top_patterns(5),
            "lessons":      self.reflection.get_relevant_lessons(task, 4),
        }

    async def _auto_restore_semantic(self) -> None:
        """Auto-restore: se la semantic memory Γ¨ vuota, carica l'ultimo snapshot da GitHub."""
        import asyncio as _asyncio, os
        if not self.semantic.available:
            return
        count = await _asyncio.to_thread(self.semantic.count)
        if count > 0:
            return
        token = os.environ.get("GITHUB_TOKEN", "")
        if not token:
            return
        try:
            import urllib.request as _urq, json as _json, base64 as _b64
            req = _urq.Request(
                "https://api.github.com/repos/Baida98/AI/contents/data/semantic_snapshot.json",
                headers={
                    "Authorization": f"Bearer {token}",
                    "Accept":        "application/vnd.github.v3+json",
                    "User-Agent":    "agente-ai-backend",
                },
            )
            with _urq.urlopen(req, timeout=10) as resp:
                meta = _json.loads(resp.read())
            records = _json.loads(_b64.b64decode(meta["content"]).decode("utf-8"))
            if not records:
                return
            result = await _asyncio.to_thread(self.semantic.import_all, records)
            _logger.info("[MemoryManager] βœ“ Auto-restore semantica: %d record da GitHub snapshot", result["imported"])
        except Exception as exc:
            _logger.debug("[MemoryManager] Auto-restore semantica: snapshot non disponibile (%s)", exc.__class__.__name__)

    def stats(self) -> dict:
        return {
            "working":    self.working.stats(),
            "episodic":   self.episodic.stats(),
            "semantic":   self.semantic.stats(),
            "reflection": self.reflection.stats(),
        }

    async def clear(self, layer: str | None = None):
        if layer in (None, "working"):
            self.working.clear()
        if layer in (None, "episodic"):
            import sqlite3
            if self.episodic._db:
                try:
                    self.episodic._db.execute("DELETE FROM episodes")
                    self.episodic._db.commit()
                except Exception as _e:
                    try:
                        self.episodic._db.rollback()
                    except Exception:
                        pass
                    raise RuntimeError(f"clear episodic fallito: {_e}") from _e


# ── Singleton globale β€” inizializzato in main.py _on_startup (GAP-5-fix) ─────
_global_manager: 'MemoryManager | None' = None