diff --git a/agent_core/core/impl/event_stream/manager.py b/agent_core/core/impl/event_stream/manager.py index 79d562bb..41a2a812 100644 --- a/agent_core/core/impl/event_stream/manager.py +++ b/agent_core/core/impl/event_stream/manager.py @@ -234,7 +234,7 @@ def _log_to_files(self, kind: str, message: str) -> None: Append an event to EVENT.md and optionally EVENT_UNPROCESSED.md. This method is thread-safe and handles file I/O errors gracefully. - Events are written in the format: [YYYY/MM/DD HH:MM:SS] [kind]: message + Events are written in the format: [YYYY-MM-DD HH:MM:SS] [kind]: message Args: kind: Event category (e.g., "action", "trigger") @@ -243,9 +243,9 @@ def _log_to_files(self, kind: str, message: str) -> None: if not self._agent_file_system_path: return - # Format: [YYYY/MM/DD HH:MM:SS] [kind]: message — LOCAL time, matching - # the loguru log files. - timestamp = datetime.now().astimezone().strftime("%Y/%m/%d %H:%M:%S") + # Format: [YYYY-MM-DD HH:MM:SS] [kind]: message — LOCAL time, in the + # canonical stamp format shared with MEMORY.md items. + timestamp = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S") event_line = f"[{timestamp}] [{kind}]: {message}\n" with self._file_lock: diff --git a/agent_core/core/impl/memory/bm25_index.py b/agent_core/core/impl/memory/bm25_index.py index 93d67a99..6e8b775d 100644 --- a/agent_core/core/impl/memory/bm25_index.py +++ b/agent_core/core/impl/memory/bm25_index.py @@ -22,6 +22,7 @@ BM25Okapi = None _HAS_BM25 = False +from agent_core.core.impl.memory.tuning import BM25_SEARCH_TOP_K from agent_core.utils.logger import logger @@ -76,7 +77,9 @@ def rebuild(self, chunks: Dict[str, str]) -> None: logger.warning(f"[BM25Index] Failed to build index: {e}") self._bm25 = None - def search(self, query: str, top_k: int = 20) -> List[Tuple[str, float]]: + def search( + self, query: str, top_k: int = BM25_SEARCH_TOP_K + ) -> List[Tuple[str, float]]: """Return ``[(chunk_id, score)]`` sorted high-to-low. Empty when index unavailable.""" if not query or not query.strip(): return [] diff --git a/agent_core/core/impl/memory/entity_extractor.py b/agent_core/core/impl/memory/entity_extractor.py deleted file mode 100644 index 282d9b69..00000000 --- a/agent_core/core/impl/memory/entity_extractor.py +++ /dev/null @@ -1,152 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Lightweight heuristic entity extractor for memory chunks. - -This is intentionally simple — Phase 1 just needs to surface proper-noun-like -tokens so they end up in chunk metadata (and in the BM25 corpus). Higher-quality -LLM-based NER is a future phase. - -The extractor pulls: -- Capitalised multi-word sequences (proper nouns) -- Tokens that look like identifiers (CamelCase, snake_case with caps) -- Quoted strings - -Stopword filtering trims common English starters that get capitalised at -sentence boundaries. -""" - -from __future__ import annotations - -import re -from typing import List - -_STOP = { - "the", - "a", - "an", - "and", - "or", - "but", - "of", - "in", - "on", - "at", - "to", - "for", - "with", - "by", - "from", - "as", - "is", - "are", - "was", - "were", - "be", - "been", - "being", - "have", - "has", - "had", - "do", - "does", - "did", - "will", - "would", - "should", - "could", - "may", - "might", - "must", - "can", - "i", - "you", - "he", - "she", - "it", - "we", - "they", - "this", - "that", - "these", - "those", - "user", - "agent", - "task", - "action", - "event", - "memory", - "system", - "note", - "today", - "yesterday", - "tomorrow", - "monday", - "tuesday", - "wednesday", - "thursday", - "friday", - "saturday", - "sunday", - "january", - "february", - "march", - "april", - "may", - "june", - "july", - "august", - "september", - "october", - "november", - "december", -} - -# Capitalised words (incl. CamelCase), optionally chained: "Trading View", -# "OpenAI", "CraftBot", "John Doe" -_PROPER_NOUN_RE = re.compile(r"\b[A-Z][A-Za-z0-9]*(?:[ \-_][A-Z][A-Za-z0-9]*)*\b") - -# Quoted strings (single or double) -_QUOTED_RE = re.compile(r"\"([^\"]{2,40})\"|'([^']{2,40})'") - - -def extract_entities(text: str, max_entities: int = 12) -> List[str]: - """Extract candidate entity strings from text. - - Returns a deduplicated, order-preserving list. The cap exists so chunk - metadata stays compact (ChromaDB stores it for every chunk). - """ - if not text: - return [] - - seen: set[str] = set() - out: List[str] = [] - - for match in _PROPER_NOUN_RE.finditer(text): - candidate = match.group(0).strip() - if not candidate: - continue - lowered = candidate.lower() - if lowered in _STOP: - continue - # Drop single-letter or pure-numeric tokens - if len(candidate) < 2: - continue - if candidate.isdigit(): - continue - if lowered in seen: - continue - seen.add(lowered) - out.append(candidate) - if len(out) >= max_entities: - return out - - for match in _QUOTED_RE.finditer(text): - candidate = (match.group(1) or match.group(2) or "").strip() - if not candidate or candidate.lower() in seen: - continue - seen.add(candidate.lower()) - out.append(candidate) - if len(out) >= max_entities: - break - - return out diff --git a/agent_core/core/impl/memory/graph.py b/agent_core/core/impl/memory/graph.py new file mode 100644 index 00000000..fa61139b --- /dev/null +++ b/agent_core/core/impl/memory/graph.py @@ -0,0 +1,898 @@ +# -*- coding: utf-8 -*- +""" +Memory graph — the semantic layer over the indexed memory corpus. + +Builds an in-memory entity/fact graph from the chunks already indexed in +ChromaDB (the same corpus BM25 uses), so the graph is a pure derived cache: +the markdown files remain the source of truth and the graph can always be +rebuilt from them. + +Structure (three node kinds, bipartite-style edges): +- entity nodes — LLM-extracted entities ("tham yik foong", "Living UI", + ...). Size grows with mention count. +- memory nodes — TWO equal-rank sources: MEMORY.md items (source + "memory": distilled facts, editable, supersedable) and section chunks + of indexed files (source "file": read-only, re-derived when the file + changes). +- file nodes — one per indexed non-memory file, grouping its chunk + memories. + +Edges: memory↔entity ("mentions") and file↔chunk-memory ("contains"). +Entity co-occurrence is implicit through shared memory neighbours, which +keeps the edge count low and the visualisation readable. + +CONNECTIONS ARE ESTABLISHED IN EXACTLY ONE PLACE: the graph build. For +every memory, the deterministic matcher connects it to each known entity +whose name appears in its text. Nothing else creates a connection — not +the entity-indexer, not any record. + +CONNECTIONS ARE RECORDED IN ENTITIES.md BY THE SYSTEM: after every build, +the ``## Connections`` section is re-synced to one line per memory — +``[chunk-id] [status] names :: text preview`` — carrying each established +connection's state as a mark on the entity name: plain = CONFIRMED, +``!`` = REJECTED (no edge), ``?`` = PENDING (edge drawn as provisional, +awaiting judgment). The entity-indexer's ONLY connection job is flipping +``?`` marks to plain or ``!`` and setting the line's status to [judged]; +it never adds names. A mark on a name the matcher did not establish is +ignored — structurally, nothing but the matcher can introduce a +connection. Dead chunk ids (memory changed or deleted) drop out of the +section automatically at the next sync; changed content produces a new +chunk id whose line starts pending again, so the records self-invalidate +with no hashes and no staleness bookkeeping. + +ENTITIES COME FROM EXACTLY ONE PLACE: the ``## Entities`` list in +ENTITIES.md (one name per line), created and maintained solely by the +entity-indexer skill. The matcher's known-entity set IS that list. When a +new entity is created, the next build matches it and the sync appends it +as a ``?`` candidate on the affected memories' lines for judgment. + +Communities are computed with deterministic label propagation (no LLM, no +external dependency) and are used for graph colouring and as retrieval +seed expansion. + +Item grammar (superset of the historical format, so existing MEMORY.md +lines remain valid without migration): + + [YYYY-MM-DD HH:MM:SS] [category] content {entities: A, B} {superseded} + +- ``{superseded}`` marks an invalidated fact. Superseded items are kept + (never deleted — history is preserved) but excluded from retrieval. +- The item id is a deterministic hash of (timestamp, clean content), so + the same line always maps to the same node/chunk id across rebuilds. +""" + +from __future__ import annotations + +import hashlib +import re +from collections import Counter +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Dict, List, Optional, Set, Tuple + +# All numeric behavior constants live in tuning.py — the single typed home +# of the memory system's magic numbers. +from agent_core.core.impl.memory.tuning import ( + CONNECTION_PREVIEW_MAX_CHARS, + ENTITY_HUB_FRACTION, + ENTITY_HUB_MIN_LINKS, + ENTITY_SEED_STRENGTH, + LABEL_PROPAGATION_ROUNDS, + SECOND_HOP_DECAY, + STRING_SEEDS_MAX, +) + +# ───────────────────────────── Item grammar ───────────────────────────── + +# Marks an invalidated fact. The memory-processor appends this marker +# instead of deleting contradicted items. +SUPERSEDED_MARKER = "{superseded}" + +# Legacy structured entity field on an item line ({entities: Name1, ...}). +# It is part of the item-line grammar only so its markup is STRIPPED from +# item content; it plays no role in the connection system. +ENTITIES_FIELD_RE = re.compile(r"\{entities:([^{}]*)\}") + +# The entity registry file, with two code-defined sections: +# - "## Entities": one entity name per line, created only by the +# entity-indexer skill. The graph's entire entity set. +# - "## Connections": one record per memory, WRITTEN AND RE-SYNCED BY THE +# SYSTEM after every graph build. The entity-indexer only flips marks. +ENTITY_REGISTRY_FILE = "ENTITIES.md" + +# A connection record line under "## Connections": +# [] [pending|judged] Name1, !Name2, ?Name3 :: +# Chunk ids are the memory content hashes ("m"/"c" + 12 hex, optional "-N" +# duplicate suffix) — the one identity shared by Chroma, graph, and UI. +# Name marks: plain = confirmed, "!" = rejected, "?" = awaiting judgment. +# Status is [pending] while any "?" remains (or the memory was never +# judged), [judged] once the entity-indexer has decided every name. +CONNECTION_LINE_RE = re.compile( + r"^\[([mc][0-9a-f]{12}(?:-\d+)?)\]\s+\[(pending|judged)\]\s*(.*)$" +) +_CONNECTION_TEXT_SEPARATOR = " :: " + + + +def normalize_timestamp(ts: str) -> str: + """Validate an item timestamp against the canonical 'YYYY-MM-DD HH:MM:SS'. + + That is the ONLY stamp format; every writer emits it exactly. Returns + the stamp when valid, '' when it is not. Every consumer that derives an + item id MUST go through this so the same line always hashes to the same + identity. + """ + cleaned = (ts or "").strip() + try: + datetime.strptime(cleaned, "%Y-%m-%d %H:%M:%S") + except ValueError: + return "" + return cleaned + + +def compute_item_id(timestamp: str, content: str) -> str: + """Deterministic id for a memory item line. + + Same (timestamp, content) → same id across processes and rebuilds, + which lets the graph node, the Chroma chunk, and the UI item share + one identity. + """ + digest = hashlib.md5(f"{timestamp}|{content}".encode("utf-8")).hexdigest() + return f"m{digest[:12]}" + + +def _dedup_names(names: List[str]) -> List[str]: + """Order-preserving, case-insensitive dedup of entity names.""" + seen: Set[str] = set() + out: List[str] = [] + for name in names: + name = name.strip() + key = name.lower() + if not name or key in seen: + continue + seen.add(key) + out.append(name) + return out + + +def split_item_fields(content: str) -> Tuple[str, Optional[List[str]], bool]: + """Parse an item's structured tail fields. + + Returns ``(clean_content, entities, superseded)``. ``entities`` is + None when the line carries no ``{entities: ...}`` field at all (the + memory-processor has not annotated it yet) and a list — possibly + empty — when it does. This distinction is what lets the backfill + trigger find unannotated items without re-processing annotated ones. + """ + text = content or "" + superseded = SUPERSEDED_MARKER in text + if superseded: + text = text.replace(SUPERSEDED_MARKER, " ") + + entities: Optional[List[str]] = None + match = ENTITIES_FIELD_RE.search(text) + if match: + entities = _dedup_names(match.group(1).split(",")) + text = ENTITIES_FIELD_RE.sub(" ", text) + + clean = re.sub(r"\s{2,}", " ", text).strip() + return clean, entities, superseded + + +def parse_entity_registry(content: str) -> Dict[str, Any]: + """Parse ENTITIES.md into ``{"entities": [...], "connections": {...}}``. + + - ``entities``: the names listed one-per-line under ``## Entities`` + (entity-indexer-owned; the graph's entire entity set). + - ``connections``: ``{chunk_id: {"status", "confirmed", "rejected", + "pending"}}`` from the system-synced connection record lines. Name + marks: plain = confirmed, ``!`` = rejected, ``?`` = awaiting + judgment. The text preview after ``" :: "`` is display-only and + ignored here (the sync regenerates it). + """ + entities: List[str] = [] + connections: Dict[str, Dict[str, Any]] = {} + in_entities_section = False + + for line in (content or "").splitlines(): + line = line.strip() + if line.startswith("#"): + in_entities_section = line.lstrip("#").strip().lower() == "entities" + continue + if not line or line.startswith(">"): + continue + match = CONNECTION_LINE_RE.match(line) + if match: + names_part = match.group(3).split(_CONNECTION_TEXT_SEPARATOR, 1)[0] + confirmed: List[str] = [] + rejected: List[str] = [] + pending: List[str] = [] + for raw in names_part.split(","): + name = raw.strip() + if not name: + continue + if name.startswith("!"): + rejected.append(name[1:].strip()) + elif name.startswith("?"): + pending.append(name[1:].strip()) + else: + confirmed.append(name) + connections[match.group(1)] = { + "status": match.group(2), + "confirmed": _dedup_names(confirmed), + "rejected": _dedup_names(rejected), + "pending": _dedup_names(pending), + } + continue + if in_entities_section: + entities.append(line) + + return {"entities": _dedup_names(entities), "connections": connections} + + +# ───────────────────────────── Graph model ───────────────────────────── + + +@dataclass +class _EntityNode: + key: str # normalised (lowercased) name + name: str # preferred display form + item_ids: Set[str] = field(default_factory=set) + file_paths: Set[str] = field(default_factory=set) + # Memories provisionally attached to this entity (deterministic match, + # not yet confirmed by the entity-indexer). Kept separate so the + # canonical mention_count reflects CONFIRMED knowledge only. + pending_item_ids: Set[str] = field(default_factory=set) + + @property + def mention_count(self) -> int: + return len(self.item_ids) + len(self.file_paths) + + +@dataclass +class _ItemNode: + """A memory node. Two sources, equal rank in the brain: + + - ``source="memory"`` — a distilled MEMORY.md item (editable, can be + superseded, entities from its {entities: ...} field). + - ``source="file"`` — a section chunk of an indexed file (read-only, + re-derived when the file changes, entities from the ENTITIES.md + registry). Carries its file_path and section key. + """ + + item_id: str + timestamp: str + category: str + content: str # clean text, structured fields stripped + entities: List[str] = field(default_factory=list) # CONFIRMED entity keys + # Matcher-established connections the entity-indexer REJECTED — no + # edge, kept so the connection-record sync preserves the "!" marks. + rejected_entities: List[str] = field(default_factory=list) + # Provisional entity keys from the deterministic matcher, present only + # on unreviewed memories. Confirmed by the entity-indexer on its next run. + pending_entities: List[str] = field(default_factory=list) + # True once the entity-indexer has reviewed this memory (MEMORY.md item + # carries an {entities:} field / indexed file matches the registry hash). + # Unreviewed memories are the ones that get pending links. + reviewed: bool = False + superseded: bool = False + source: str = "memory" + file_path: str = "" + section: str = "" + + +@dataclass +class _FileNode: + file_path: str + entities: Set[str] = field(default_factory=set) + chunk_ids: List[str] = field(default_factory=list) + + @property + def chunk_count(self) -> int: + return len(self.chunk_ids) + + +class MemoryGraph: + """In-memory entity/item/file graph with traversal and communities. + + Node keys are namespaced to keep the adjacency map unambiguous: + ``e:``, ``i:``, ``f:``. + """ + + def __init__(self) -> None: + self.entities: Dict[str, _EntityNode] = {} + self.items: Dict[str, _ItemNode] = {} + self.files: Dict[str, _FileNode] = {} + self._adjacency: Dict[str, Set[str]] = {} + self._communities: Dict[str, int] = {} + # Parsed ## Connections records keyed by chunk id: each holds the + # lowered confirmed / rejected name sets and the line status. A + # matched entity's state comes from its mark; matched entities with + # no mark (or no record) are pending. + self._records: Dict[str, Dict[str, Any]] = {} + + # ───────────────────────────── Building ───────────────────────────── + + @classmethod + def build( + cls, + chunks: List[Dict[str, Any]], + registry: Optional[Dict[str, Any]] = None, + ) -> "MemoryGraph": + """Build the graph from the indexed chunk corpus. + + Chunks of indexed files ARE memories: each section chunk becomes a + memory node (source="file") grouped under its file node. Entities + come solely from the registry's ``## Entities`` list. Connections + are then established here — and only here — by the deterministic + matcher (:meth:`_establish_connections`); the ``## Connections`` + records supply each matched name's mark (confirmed / rejected / + pending). + + Args: + chunks: dicts with ``chunk_id``, ``document`` and ``metadata`` + (the full ChromaDB collection contents). + registry: parse_entity_registry() output. Records for chunk ids + no longer in the corpus are ignored (and dropped by the + next connection-record sync). + """ + graph = cls() + registry = registry or {} + graph._records = registry.get("connections", {}) + + # Entities exist ONLY from the ## Entities list — including ones + # nothing connects to yet. + for name in registry.get("entities", []): + graph._ensure_entity(name) + + for chunk in chunks: + meta = chunk.get("metadata") or {} + file_path = meta.get("file_path", "") + if meta.get("item_kind") == "memory_log": + # Only MEMORY.md items are facts; EVENT_UNPROCESSED.md lines + # are a transient buffer and would pollute the graph. + if file_path == "MEMORY.md": + graph._add_item_chunk( + chunk.get("chunk_id", ""), chunk.get("document", ""), meta + ) + elif file_path and file_path != ENTITY_REGISTRY_FILE: + # The registry file itself is bookkeeping, not a knowledge + # source worth nodes. + graph._add_file_memory_chunk( + chunk.get("chunk_id", ""), + chunk.get("document", ""), + meta, + ) + + # THE single connection-establishment pass, then hub exclusion over + # the complete link set (pending + confirmed). + graph._establish_connections() + graph._prune_hub_entities() + graph._compute_communities() + return graph + + def _link(self, a: str, b: str) -> None: + self._adjacency.setdefault(a, set()).add(b) + self._adjacency.setdefault(b, set()).add(a) + + def _ensure_entity(self, name: str) -> _EntityNode: + key = name.strip().lower() + node = self.entities.get(key) + if node is None: + node = _EntityNode(key=key, name=name.strip()) + self.entities[key] = node + elif node.name.islower() and not name.islower(): + # Prefer a cased surface form for display. + node.name = name.strip() + return node + + def _add_item_chunk(self, chunk_id: str, document: str, meta: Dict[str, Any]) -> None: + # The chunk document is the full bracketed line; clean content and + # flags live in metadata written by the chunker. The entities value + # is the item's {entities: ...} field — LLM-authored, parsed from + # metadata (or re-parsed from the line itself, same record). + content = meta.get("item_content") or split_item_fields(document)[0] + superseded = bool(meta.get("superseded", False)) + file_path = meta.get("file_path", "MEMORY.md") + + item = _ItemNode( + item_id=chunk_id, + timestamp=meta.get("timestamp", ""), + category=meta.get("category", "fact"), + content=content, + # Reviewed iff the connection record for this chunk id says + # [judged] — the entity-indexer has decided every mark on it. + reviewed=(self._records.get(chunk_id) or {}).get("status") == "judged", + superseded=superseded, + file_path=file_path, + ) + self.items[chunk_id] = item + + # MEMORY.md shows up as a normal file node, exactly like the other + # indexed files: its items hang off it via contains edges. + file_node = self.files.get(file_path) + if file_node is None: + file_node = _FileNode(file_path=file_path) + self.files[file_path] = file_node + file_node.chunk_ids.append(chunk_id) + self._link(f"f:{file_path}", f"i:{chunk_id}") + + def _add_file_memory_chunk( + self, + chunk_id: str, + document: str, + meta: Dict[str, Any], + ) -> None: + """A section chunk of an indexed file — a memory sourced from a file. + + Creates the chunk's memory node linked under its file node. Its + connection marks come from the chunk id's ## Connections record, + exactly like MEMORY.md items — chunk ids are content-derived, so a + changed section is a new id with no record: automatically pending. + The node carries the chunk's FULL text (the summary is a truncated + derivative — showing it in detail views reads as the memory being + cut off, which it is not). + """ + file_path = meta.get("file_path", "") + if not chunk_id or not file_path: + return + + node = self.files.get(file_path) + if node is None: + node = _FileNode(file_path=file_path) + self.files[file_path] = node + node.chunk_ids.append(chunk_id) + + section = meta.get("section_path", "") + item = _ItemNode( + item_id=chunk_id, + timestamp=meta.get("file_modified_at", ""), + category="file", + content=document, + reviewed=(self._records.get(chunk_id) or {}).get("status") == "judged", + source="file", + file_path=file_path, + section=section, + ) + self.items[chunk_id] = item + self._link(f"f:{file_path}", f"i:{chunk_id}") + + def _prune_hub_entities(self) -> None: + """Exclude over-connected entities from the derived graph. + + An entity connected (pending or confirmed) to more than + ENTITY_HUB_FRACTION of all memories (past the ENTITY_HUB_MIN_LINKS + floor) is ambient context: a link that attaches to almost + everything carries no information, floods the graph retrieval + channel, and collapses communities into one blob. The entity list + and verdict records stay untouched — exclusion is recomputed on + every build, so a hub drops out while it is over the threshold and + returns automatically (links intact) when the corpus shifts below + it. + """ + total = len(self.items) + if total == 0: + return + limit = max(ENTITY_HUB_MIN_LINKS, ENTITY_HUB_FRACTION * total) + hub_keys = [ + key + for key, entity in self.entities.items() + if len(entity.item_ids | entity.pending_item_ids) > limit + ] + for key in hub_keys: + entity = self.entities.pop(key) + entity_node = f"e:{key}" + for item_id in entity.item_ids | entity.pending_item_ids: + item = self.items.get(item_id) + if item is not None: + if key in item.entities: + item.entities.remove(key) + if key in item.pending_entities: + item.pending_entities.remove(key) + self._adjacency.get(f"i:{item_id}", set()).discard(entity_node) + for file_path in entity.file_paths: + file_node = self.files.get(file_path) + if file_node is not None: + file_node.entities.discard(key) + self._adjacency.pop(entity_node, None) + + def _establish_connections(self) -> None: + """THE single place memory↔entity connections are made. + + For every memory, the deterministic matcher connects it to each + known entity (the ``## Entities`` list) whose whole normalised name + appears in the memory's text. The chunk id's ## Connections record + then sets each matched name's state by its mark: + - confirmed mark (plain name) → CONFIRMED edge; + - rejected mark (``!``) → no edge (kept for the record sync); + - ``?`` mark, unmarked, or no record → PENDING edge. + A mark on a name the matcher did not establish does nothing — the + entity-indexer structurally cannot introduce a connection. + """ + if not self.entities: + return + + # Precompute " normalised name " needles once, in deterministic order. + needles: List[Tuple[str, str]] = [] + for key in sorted(self.entities): + norm = re.sub(r"[^a-z0-9]+", " ", key).strip() + if norm: + needles.append((f" {norm} ", key)) + if not needles: + return + + for item in self.items.values(): + record = self._records.get(item.item_id) or {} + confirmed = {n.lower() for n in record.get("confirmed", [])} + rejected = {n.lower() for n in record.get("rejected", [])} + haystack = f" {re.sub(r'[^a-z0-9]+', ' ', item.content.lower())} " + for needle, key in needles: + if needle not in haystack: + continue + entity = self.entities[key] + if key in confirmed: + item.entities.append(key) + entity.item_ids.add(item.item_id) + if item.source == "file" and item.file_path: + entity.file_paths.add(item.file_path) + file_node = self.files.get(item.file_path) + if file_node is not None: + file_node.entities.add(key) + self._link(f"i:{item.item_id}", f"e:{key}") + elif key in rejected: + item.rejected_entities.append(key) + else: + # Superseded memories keep their judged history but + # never accrue new provisional links. + if item.superseded: + continue + item.pending_entities.append(key) + entity.pending_item_ids.add(item.item_id) + self._link(f"i:{item.item_id}", f"e:{key}") + + def connection_lines(self) -> List[str]: + """Render the ## Connections record lines for this build. + + One line per memory that has any established (or previously judged) + connection state, sorted by chunk id for a deterministic file. Marks + carry each matched name's state: plain = confirmed, ``!`` = + rejected, ``?`` = pending. Chunk ids no longer in the graph simply + aren't rendered — that IS the record cleanup. Superseded memories + render only their judged marks (never ``?``), and a memory with no + connection state at all still gets a ``[pending]`` line so the + entity-indexer reviews its text once for new entities. + """ + lines: List[str] = [] + for item_id in sorted(self.items): + item = self.items[item_id] + parts: List[str] = [] + for key in sorted(item.entities): + entity = self.entities.get(key) + if entity is not None: + parts.append(entity.name) + for key in sorted(item.rejected_entities): + entity = self.entities.get(key) + if entity is not None: + parts.append(f"!{entity.name}") + for key in sorted(item.pending_entities): + entity = self.entities.get(key) + if entity is not None: + parts.append(f"?{entity.name}") + if item.superseded and not parts: + continue + status = ( + "judged" + if item.reviewed and not item.pending_entities + else "pending" + ) + if item.superseded: + status = "judged" + preview = " ".join((item.content or "").split()) + if len(preview) > CONNECTION_PREVIEW_MAX_CHARS: + preview = preview[: CONNECTION_PREVIEW_MAX_CHARS - 3] + "..." + names = f" {', '.join(parts)}" if parts else "" + lines.append( + f"[{item_id}] [{status}]{names}" + f"{_CONNECTION_TEXT_SEPARATOR}{preview}" + ) + return lines + + # ─────────────────────────── Communities ─────────────────────────── + + def _compute_communities(self) -> None: + """Deterministic label propagation over the whole graph. + + Nodes are visited in sorted order every round with asynchronous + updates, ties broken by the smallest label — fully deterministic + for a given graph, so the panel colouring is stable across loads. + """ + nodes = sorted(self._adjacency.keys()) + labels: Dict[str, int] = {key: i for i, key in enumerate(nodes)} + + for _ in range(LABEL_PROPAGATION_ROUNDS): + changed = False + for key in nodes: + neighbour_labels = Counter( + labels[n] for n in self._adjacency.get(key, ()) if n in labels + ) + if not neighbour_labels: + continue + best_count = max(neighbour_labels.values()) + best = min( + label for label, count in neighbour_labels.items() if count == best_count + ) + if labels[key] != best: + labels[key] = best + changed = True + if not changed: + break + + # Compact label ids to 0..n-1 ordered by community size (largest first) + # so colour palettes assign their strongest colours to the big clusters. + sizes = Counter(labels.values()) + order = { + label: rank + for rank, (label, _) in enumerate( + sorted(sizes.items(), key=lambda kv: (-kv[1], kv[0])) + ) + } + self._communities = {key: order[label] for key, label in labels.items()} + + def community_of(self, node_key: str) -> int: + return self._communities.get(node_key, 0) + + @property + def community_count(self) -> int: + return len(set(self._communities.values())) if self._communities else 0 + + # ───────────────────────────── Retrieval ───────────────────────────── + + def match_entities( + self, query: str, max_seeds: int = STRING_SEEDS_MAX + ) -> List[Tuple[str, float]]: + """Match query text against entity names. + + Returns (entity_key, strength) pairs. Exact phrase presence and + all name tokens present both score ENTITY_SEED_STRENGTH. + """ + if not query or not self.entities: + return [] + + query_lower = f" {re.sub(r'[^a-z0-9]+', ' ', query.lower())} " + query_tokens = set(query_lower.split()) + + matches: List[Tuple[str, float]] = [] + for key, entity in self.entities.items(): + name_norm = re.sub(r"[^a-z0-9]+", " ", key).strip() + if not name_norm: + continue + if f" {name_norm} " in query_lower: + matches.append((key, ENTITY_SEED_STRENGTH)) + continue + tokens = name_norm.split() + if len(tokens) > 1 and all(t in query_tokens for t in tokens): + matches.append((key, ENTITY_SEED_STRENGTH)) + + matches.sort(key=lambda pair: (-pair[1], pair[0])) + return matches[:max_seeds] + + def bfs_item_scores( + self, seeds: List[Tuple[str, float]], include_superseded: bool = False + ) -> Dict[str, float]: + """Score items reachable from seed entities within 2 hops. + + Hop 1 (items of a seed entity) scores the seed strength; hop 2 + (items of entities co-mentioned with a seed) decays. When several + seeds reach the same item, the best score wins. + """ + scores: Dict[str, float] = {} + for entity_key, strength in seeds: + entity = self.entities.get(entity_key) + if entity is None: + continue + second_hop_entities: Set[str] = set() + for item_id in entity.item_ids: + item = self.items.get(item_id) + if item is None or (item.superseded and not include_superseded): + continue + scores[item_id] = max(scores.get(item_id, 0.0), strength) + second_hop_entities.update(item.entities) + second_hop_entities.discard(entity_key) + for other_key in second_hop_entities: + other = self.entities.get(other_key) + if other is None: + continue + for item_id in other.item_ids: + item = self.items.get(item_id) + if item is None or (item.superseded and not include_superseded): + continue + hop_score = strength * SECOND_HOP_DECAY + scores[item_id] = max(scores.get(item_id, 0.0), hop_score) + return scores + + # ─────────────────────────── Introspection ─────────────────────────── + + def entity_overview(self, name: str) -> Optional[Dict[str, Any]]: + """Everything the graph knows about one entity.""" + key = (name or "").strip().lower() + entity = self.entities.get(key) + if entity is None: + return None + + items = [] + related: Counter = Counter() + for item_id in sorted(entity.item_ids): + item = self.items.get(item_id) + if item is None: + continue + items.append( + { + "item_id": item.item_id, + "timestamp": item.timestamp, + "category": item.category, + "content": item.content, + "superseded": item.superseded, + "source": item.source, + "file": item.file_path, + "section": item.section, + } + ) + for other in item.entities: + if other != key: + related[other] += 1 + + items.sort(key=lambda i: i["timestamp"], reverse=True) + return { + "entity": entity.name, + "mention_count": entity.mention_count, + "items": items, + "related_entities": [ + {"name": self.entities[k].name, "shared_items": count} + for k, count in related.most_common(10) + if k in self.entities + ], + "files": sorted(entity.file_paths), + } + + def shortest_path(self, name_a: str, name_b: str) -> List[Dict[str, Any]]: + """Shortest connection between two entities (BFS over all nodes). + + Returns the node sequence (entities, items, files) or [] when no + path exists / an endpoint is unknown. + """ + start = f"e:{(name_a or '').strip().lower()}" + goal = f"e:{(name_b or '').strip().lower()}" + if start not in self._adjacency or goal not in self._adjacency: + return [] + if start == goal: + return [self._node_payload(start)] + + parents: Dict[str, str] = {start: ""} + frontier = [start] + while frontier and goal not in parents: + next_frontier: List[str] = [] + for node in frontier: + for neighbour in sorted(self._adjacency.get(node, ())): + if neighbour not in parents: + parents[neighbour] = node + next_frontier.append(neighbour) + frontier = next_frontier + + if goal not in parents: + return [] + + path: List[str] = [] + cursor = goal + while cursor: + path.append(cursor) + cursor = parents[cursor] + path.reverse() + return [self._node_payload(key) for key in path] + + def _node_payload(self, node_key: str) -> Dict[str, Any]: + kind, _, ref = node_key.partition(":") + if kind == "e": + entity = self.entities.get(ref) + return { + "kind": "entity", + "id": node_key, + "label": entity.name if entity else ref, + } + if kind == "i": + item = self.items.get(ref) + return { + "kind": "item", + "id": node_key, + "label": (item.content[:80] if item else ref), + "category": item.category if item else "", + "superseded": item.superseded if item else False, + } + return {"kind": "file", "id": node_key, "label": ref} + + def snapshot(self) -> Dict[str, Any]: + """Full graph serialisation for the Memory panel.""" + nodes: List[Dict[str, Any]] = [] + edges: List[Dict[str, str]] = [] + + for key in sorted(self.entities): + entity = self.entities[key] + node_key = f"e:{key}" + nodes.append( + { + "id": node_key, + "kind": "entity", + "label": entity.name, + "size": entity.mention_count, + "community": self.community_of(node_key), + } + ) + + for item_id in sorted(self.items): + item = self.items[item_id] + node_key = f"i:{item_id}" + nodes.append( + { + "id": node_key, + "kind": "item", + "label": item.content, + "category": item.category, + "timestamp": item.timestamp, + "superseded": item.superseded, + "source": item.source, + "file": item.file_path, + "section": item.section, + "community": self.community_of(node_key), + } + ) + for entity_key in item.entities: + edges.append( + { + "source": node_key, + "target": f"e:{entity_key}", + "status": "confirmed", + } + ) + for entity_key in item.pending_entities: + edges.append( + { + "source": node_key, + "target": f"e:{entity_key}", + "status": "pending", + } + ) + + for file_path in sorted(self.files): + file_node = self.files[file_path] + node_key = f"f:{file_path}" + nodes.append( + { + "id": node_key, + "kind": "file", + "label": file_path, + "size": file_node.chunk_count, + "community": self.community_of(node_key), + } + ) + # Files group their chunk memories. + for chunk_id in file_node.chunk_ids: + edges.append({"source": node_key, "target": f"i:{chunk_id}"}) + + memory_items = [i for i in self.items.values() if i.source == "memory"] + return { + "nodes": nodes, + "edges": edges, + "stats": { + "entity_count": len(self.entities), + "item_count": len(memory_items), + "file_memory_count": sum( + 1 for i in self.items.values() if i.source == "file" + ), + "file_count": len(self.files), + "edge_count": len(edges), + "pending_link_count": sum( + len(i.pending_entities) for i in self.items.values() + ), + "community_count": self.community_count, + "superseded_count": sum(1 for i in memory_items if i.superseded), + }, + } diff --git a/agent_core/core/impl/memory/injector.py b/agent_core/core/impl/memory/injector.py index e6bf3b64..cc6a0653 100644 --- a/agent_core/core/impl/memory/injector.py +++ b/agent_core/core/impl/memory/injector.py @@ -8,7 +8,7 @@ event that prompted the retrieval. Behaviour: -- Runs `MemoryManager.retrieve()` with min_relevance=0.5. +- Runs `MemoryManager.retrieve()` with the tuning.INJECT_* bounds. - If nothing passes the threshold, nothing is logged. - Otherwise emits one event with kind="relevant_memories" into the caller's event stream (per-task when session_id is provided, otherwise @@ -24,12 +24,11 @@ from agent_core.core.registry.memory import get_memory_manager_or_none from agent_core.core.registry.event_stream import get_event_stream_manager_or_none from agent_core.core.event_stream.event import EventType +from agent_core.core.impl.memory.tuning import INJECT_MIN_RELEVANCE, INJECT_TOP_K from agent_core.utils.logger import logger _MEMORY_EVENT_KIND = "relevant_memories" -_MIN_RELEVANCE = 0.5 -_TOP_K = 5 def _is_memory_enabled() -> bool: @@ -66,7 +65,7 @@ def inject_memory_event(query: str, session_id: Optional[str] = None) -> None: try: pointers = memory_manager.retrieve( - query, top_k=_TOP_K, min_relevance=_MIN_RELEVANCE + query, top_k=INJECT_TOP_K, min_relevance=INJECT_MIN_RELEVANCE ) except Exception as e: logger.warning(f"[MEMORY] inject_memory_event retrieval failed: {e}") @@ -75,13 +74,25 @@ def inject_memory_event(query: str, session_id: Optional[str] = None) -> None: if not pointers: return + # These are TRUNCATED previews (pointers), not full memories: each line is + # a snippet centred on the query match, and a leading/trailing "..." marks + # omitted text. The header says so explicitly because "..." alone is an + # ambiguous cut-off signal — the agent must know to expand a relevant-but- + # clipped preview (memory_search / grep_files / read the source file) + # before relying on it. + header = ( + "Relevant memory previews (TRUNCATED pointers, not full records; " + '"..." marks omitted text). If a preview is relevant but clipped, ' + "read the source file or memory_search/grep for the full memory " + "before relying on it:" + ) lines = [] for ptr in pointers: lines.append( f"- [{ptr.file_path}] {ptr.section_path}: {ptr.summary} " f"(relevance: {ptr.relevance_score:.2f})" ) - message = "\n".join(lines) + message = header + "\n" + "\n".join(lines) # session_id=None means "no task context" — log directly to the main # stream rather than going through .log(task_id=None), which would fall diff --git a/agent_core/core/impl/memory/manager.py b/agent_core/core/impl/memory/manager.py index 9385d766..8be06c49 100644 --- a/agent_core/core/impl/memory/manager.py +++ b/agent_core/core/impl/memory/manager.py @@ -18,17 +18,46 @@ import hashlib import re import os as _os -import uuid from dataclasses import dataclass, field from datetime import datetime from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional, Tuple import chromadb from agent_core.utils.logger import logger from agent_core.core.impl.memory.bm25_index import BM25Index -from agent_core.core.impl.memory.entity_extractor import extract_entities +from agent_core.core.impl.memory.graph import ( + CONNECTION_LINE_RE, + ENTITY_REGISTRY_FILE, + MemoryGraph, + compute_item_id, + parse_entity_registry, + split_item_fields, +) +from agent_core.core.impl.memory.text_extract import extract_text, is_indexable_file + +# All numeric behavior constants live in tuning.py — the single typed home +# of the memory system's magic numbers. +from agent_core.core.impl.memory.tuning import ( + CANDIDATE_POOL_FLOOR, + CANDIDATE_POOL_MULTIPLIER, + CHUNK_OVERLAP, + CHUNK_SIZE_LIMIT, + ENTITY_MATCH_MIN_SCORE, + GRAPH_ELIGIBILITY_SCORE, + HYBRID_WEIGHTS, + LOG_QUERY_MAX_CHARS, + LOG_SUMMARY_MAX_CHARS, + MERGED_SEEDS_MAX, + PREVIEW_LEAD, + PREVIEW_MAX_CHARS, + RECENCY_HALF_LIFE_DAYS, + RECENCY_MAX_BONUS, + RETRIEVE_MIN_RELEVANCE, + RETRIEVE_TOP_K, + SEMANTIC_SEEDS_MAX, +) # Files that are flat lists of "[timestamp] [category] content" items. @@ -36,26 +65,19 @@ # the whole list collapsing into a single section chunk under "## Memory". PER_ITEM_FILES = frozenset({"MEMORY.md", "EVENT_UNPROCESSED.md"}) -# Matches a memory item line. Tolerates both "/" and "-" date separators and -# either "[YYYY-MM-DD HH:MM:SS]" (MEMORY.md) or "[YYYY/MM/DD HH:MM:SS]" -# (EVENT_UNPROCESSED.md). Captures: timestamp, category, content. +# Matches a memory item line: "[stamp] [category] content". The stamp slot +# accepts any bracketed token — stamp validity is METADATA, never a gate on +# whether the memory exists. A canonical "YYYY-MM-DD HH:MM:SS" stamp (the +# only recognized format, validated downstream by _normalize_timestamp) +# yields timestamp metadata for identity and recency; any other stamp +# content indexes the memory all the same with no timestamp metadata. +# The optional colon after the category bracket is the EVENT_UNPROCESSED.md +# event-line separator ("[kind]: message"). +# Captures: stamp, category, content. MEMORY_ITEM_LINE_RE = re.compile( - r"^\s*\[(\d{4}[-/]\d{2}[-/]\d{2}[ T]\d{2}:\d{2}:\d{2})\]\s+\[([\w\-]+)\]\s*:?\s*(.+?)\s*$" + r"^\s*\[([^\]]+)\]\s+\[([\w\-]+)\]\s*:?\s*(.+?)\s*$" ) -# Hybrid-retrieval weights. Vector is the primary signal, BM25 backstops -# proper nouns and dates. -HYBRID_WEIGHTS = { - "vector": 0.65, - "bm25": 0.35, -} - -# Log-line preview limits. Keep multi-line queries and long summaries from -# bleeding across log entries. -_LOG_QUERY_MAX_CHARS = 300 -_LOG_SUMMARY_MAX_CHARS = 120 - - def _log_preview(text: str, max_chars: int) -> str: """Collapse whitespace and truncate text for safe logging.""" flat = " ".join((text or "").split()) @@ -204,20 +226,23 @@ class MemoryManager: manager.update() """ - # v2 collections use cosine distance and per-item chunking. The "_v2" - # suffix forces a clean rebuild on first run with the new code — old - # "agent_memory" collections are left intact but unused (so a downgrade - # is non-destructive). Drop the old collections manually if disk is - # tight; the manager never reads them. - COLLECTION_NAME = "agent_memory_v2" - FILE_INDEX_COLLECTION = "agent_memory_file_index_v2" + # The chunk collection and its companion file-index. The index is a + # derived cache of the markdown files, so it is always rebuildable from + # disk; if the chunking shape changes, clear it and re-index. + COLLECTION_NAME = "agent_memory" + FILE_INDEX_COLLECTION = "agent_memory_file_index" + # Entity-name embeddings for the graph channel's semantic entity match. + # A separate collection so entity vectors never mix with chunk vectors; + # a derived cache, reseeded from the graph on every rebuild. + ENTITY_COLLECTION = "agent_memory_entities" def __init__( self, agent_file_system_path: str = "./agent_file_system", chroma_path: str = "./chroma_db_memory", - chunk_size_limit: int = 1500, # Max chars per chunk - chunk_overlap: int = 100, # Overlap between chunks when splitting large sections + chunk_size_limit: int = CHUNK_SIZE_LIMIT, + chunk_overlap: int = CHUNK_OVERLAP, + extra_files_provider: Optional[Callable[[], List[str]]] = None, ): """ Initialize the Memory Manager. @@ -227,11 +252,16 @@ def __init__( chroma_path: Path for ChromaDB persistence chunk_size_limit: Maximum characters per chunk before splitting chunk_overlap: Character overlap when splitting large chunks + extra_files_provider: Callable returning user-selected extra + files to index (relative paths under the agent file + system, e.g. "workspace/notes.md"). Read on every index + pass so panel changes apply without restart. """ self.agent_fs_path = Path(agent_file_system_path).resolve() self.chroma_path = chroma_path self.chunk_size_limit = chunk_size_limit self.chunk_overlap = chunk_overlap + self._extra_files_provider = extra_files_provider # Initialize ChromaDB. # hnsw:space=cosine — cosine similarity gives well-scaled scores in @@ -241,16 +271,20 @@ def __init__( # Build the embedding function. Default ChromaDB uses MiniLM-L6-v2 # (weak — ~0.65 verbatim self-similarity). MEMORY_EMBEDDING_MODEL - # points to a stronger sentence-transformers model by default. - # Silent fallback to ChromaDB's bundled MiniLM if sentence-transformers - # isn't installed, so the system keeps working on minimal installs. - embedding_fn = self._build_embedding_function() + # points to a stronger sentence-transformers model by default; if it + # can't load, construction fails — retrieval thresholds are calibrated + # for the configured model, so running with a substitute is worse than + # not starting. + # Stored so _clear_index can rebuild every collection with the SAME + # embedding function — a force rebuild must not silently downgrade the + # model (e.g. bge-small back to ChromaDB's default MiniLM). + self._embedding_fn = embedding_fn = self._build_embedding_function() self.collection = self._open_collection( name=self.COLLECTION_NAME, embedding_fn=embedding_fn, metadata={ - "description": "Agent file system memory chunks (v2)", + "description": "Agent file system memory chunks", "hnsw:space": "cosine", "embedding_model": MEMORY_EMBEDDING_MODEL, }, @@ -260,7 +294,19 @@ def __init__( self.file_index_collection = self._open_collection( name=self.FILE_INDEX_COLLECTION, embedding_fn=embedding_fn, - metadata={"description": "File index for incremental updates (v2)"}, + metadata={"description": "File index for incremental updates"}, + ) + + # Entity-name embeddings for the graph channel's semantic entity match. + # Same embedding function as the chunks; cosine space for [0,1] scores. + self.entity_collection = self._open_collection( + name=self.ENTITY_COLLECTION, + embedding_fn=embedding_fn, + metadata={ + "description": "Entity name embeddings for graph-channel matching", + "hnsw:space": "cosine", + "embedding_model": MEMORY_EMBEDDING_MODEL, + }, ) # In-memory cache of file indices @@ -272,6 +318,11 @@ def __init__( self._bm25 = BM25Index() self._bm25_dirty = True + # Memory graph — the semantic layer (entities/items/files) derived + # from the same chunk corpus. Same lazy-rebuild lifecycle as BM25. + self._graph: Optional[MemoryGraph] = None + self._graph_dirty = True + logger.info( f"MemoryManager initialized. Agent FS: {self.agent_fs_path}, " f"ChromaDB: {chroma_path}, embedding model: {MEMORY_EMBEDDING_MODEL}" @@ -314,11 +365,9 @@ def _open_collection(self, name: str, embedding_fn, metadata: Dict[str, Any]): def _build_embedding_function(): """Construct ChromaDB's embedding function. - Honours the MEMORY_EMBEDDING_MODEL constant. Falls back to - ChromaDB's bundled default (ONNX all-MiniLM-L6-v2) silently when - sentence-transformers is missing or the model can't load — so - the agent never fails to start because of an embedding-model - installation issue. + Honours the MEMORY_EMBEDDING_MODEL constant. Every retrieval + threshold is calibrated for the configured model, so a load + failure raises instead of degrading to a different model. """ if MEMORY_EMBEDDING_MODEL == "default": return None # ChromaDB applies its bundled default @@ -326,54 +375,50 @@ def _build_embedding_function(): from chromadb.utils.embedding_functions import ( SentenceTransformerEmbeddingFunction, ) + except ImportError as e: + raise RuntimeError( + "[MEMORY] sentence-transformers is required for the configured " + f"embedding model '{MEMORY_EMBEDDING_MODEL}'. Install with: " + "conda install -c conda-forge sentence-transformers" + ) from e - return SentenceTransformerEmbeddingFunction( - model_name=MEMORY_EMBEDDING_MODEL - ) - except ImportError: - logger.warning( - "[MEMORY] sentence-transformers not installed — falling back " - "to ChromaDB's default MiniLM embeddings. Retrieval quality " - "will be poor. Install with: conda install -c conda-forge " - "sentence-transformers" - ) - return None - except Exception as e: - logger.warning( - f"[MEMORY] Failed to load embedding model " - f"'{MEMORY_EMBEDDING_MODEL}' ({e}); falling back to ChromaDB " - f"default." - ) - return None + return SentenceTransformerEmbeddingFunction(model_name=MEMORY_EMBEDDING_MODEL) # ───────────────────────────── Public API ───────────────────────────── def retrieve( self, query: str, - top_k: int = 5, - min_relevance: float = 0.55, + top_k: int = RETRIEVE_TOP_K, + min_relevance: float = RETRIEVE_MIN_RELEVANCE, file_filter: Optional[List[str]] = None, + include_superseded: bool = False, ) -> List[MemoryPointer]: """ Retrieve memory pointers relevant to the query. - Uses a hybrid score: vector cosine similarity + BM25 keyword match. - Candidate pool is the union of top-K from each channel - (Reciprocal-Rank-Fusion style); final ranking is the weighted sum - defined by ``HYBRID_WEIGHTS``. + Uses a hybrid score across three channels: vector cosine + similarity, BM25 keyword match, and graph proximity (items + connected to entities the query mentions, up to 2 hops). Candidate + pool is the union of top-K from each channel (Reciprocal-Rank- + Fusion style); final ranking is the weighted sum defined by + ``HYBRID_WEIGHTS`` plus a small recency bonus. + + Superseded memory items (facts invalidated by newer information) + are excluded unless ``include_superseded`` is set — pass True for + queries about the past. Args: query: The search query top_k: Maximum number of results to return min_relevance: Minimum hybrid score (0-1) to include. - Default 0.55 matches cosine-scaled scores; BM25 lifts - keyword-strong matches above the cut. + Strongly graph-connected items are eligible below this + cut (see GRAPH_ELIGIBILITY_SCORE). file_filter: Optional list of file paths to search within + include_superseded: Include invalidated memory items. Returns: List of MemoryPointer objects, sorted by relevance (highest first). - Result shape is unchanged from v1 — only the ranking improves. """ if not query or not query.strip(): logger.warning("Empty query provided to retrieve()") @@ -388,7 +433,7 @@ def retrieve( # Cast a wider net than top_k so the hybrid re-rank has signal to work # with. ChromaDB and BM25 each return up to candidate_pool items. - candidate_pool = max(top_k * 4, 20) + candidate_pool = max(top_k * CANDIDATE_POOL_MULTIPLIER, CANDIDATE_POOL_FLOOR) where_filter = None if file_filter: @@ -396,31 +441,30 @@ def retrieve( # Render single-line so multi-line queries don't bleed into the next # log entry. Full query is still passed to the retriever. - logger.info(f"[MEMORY QUERY] {_log_preview(query, _LOG_QUERY_MAX_CHARS)}") + logger.info(f"[MEMORY QUERY] {_log_preview(query, LOG_QUERY_MAX_CHARS)}") # ── Channel 1: vector similarity ── vector_hits: Dict[str, Dict[str, Any]] = {} - try: - results = self.collection.query( - query_texts=[query], - n_results=min(candidate_pool, collection_count), - where=where_filter, - include=["metadatas", "distances", "documents"], - ) - ids = (results.get("ids") or [[]])[0] - metadatas = (results.get("metadatas") or [[]])[0] - distances = (results.get("distances") or [[]])[0] - for i, chunk_id in enumerate(ids): - meta = metadatas[i] if i < len(metadatas) else {} - distance = distances[i] if i < len(distances) else 1.0 - vector_hits[chunk_id] = { - "score": _cosine_distance_to_similarity(distance), - "metadata": meta, - "rank": i, - } - except Exception as e: - logger.error(f"Error querying ChromaDB: {e}") - # Continue — BM25 alone may still return useful results. + results = self.collection.query( + query_texts=[query], + n_results=min(candidate_pool, collection_count), + where=where_filter, + include=["metadatas", "distances", "documents"], + ) + ids = (results.get("ids") or [[]])[0] + metadatas = (results.get("metadatas") or [[]])[0] + distances = (results.get("distances") or [[]])[0] + documents = (results.get("documents") or [[]])[0] + for i, chunk_id in enumerate(ids): + meta = metadatas[i] if i < len(metadatas) else {} + distance = distances[i] if i < len(distances) else 1.0 + vector_hits[chunk_id] = { + "score": _cosine_distance_to_similarity(distance), + "metadata": meta, + # Kept for the query-aware preview snippet (built below). + "document": documents[i] if i < len(documents) else "", + "rank": i, + } # ── Channel 2: BM25 keyword search ── self._ensure_bm25_built() @@ -434,8 +478,29 @@ def retrieve( "rank": rank, } - # Union the candidate ids from both channels (RRF-style fusion). - candidate_ids = set(vector_hits) | set(bm25_hits) + # ── Channel 3: graph proximity ── + # Entities mentioned in the query seed a 2-hop walk over the memory + # graph; connected items get a proximity score in [0,1]. + graph_hits: Dict[str, float] = {} + try: + self._ensure_graph_built() + if self._graph is not None: + # String seeds (exact / all-token) at full strength, unioned + # with semantic seeds (entity-name embedding ≥ threshold) for + # partial names. Union keeps the strongest strength per entity. + seeds = self._merge_entity_seeds( + self._graph.match_entities(query), + self._match_entities_semantic(query), + ) + if seeds: + graph_hits = self._graph.bfs_item_scores( + seeds, include_superseded=include_superseded + ) + except Exception as e: + logger.warning(f"[MEMORY] Graph channel failed: {e}") + + # Union the candidate ids from all channels (RRF-style fusion). + candidate_ids = set(vector_hits) | set(bm25_hits) | set(graph_hits) if not candidate_ids: return [] @@ -455,13 +520,15 @@ def retrieve( in set(file_filter) } - # Pull metadata for any BM25-only hits so we can build pointers + age. + # Pull metadata + documents for any non-vector hits so we can build + # pointers, age them, and window a query-aware preview. missing_ids = [cid for cid in candidate_ids if cid not in vector_hits] - extra_meta = self._fetch_metadata(missing_ids) if missing_ids else {} + extra_meta, extra_docs = ( + self._fetch_meta_and_docs(missing_ids) if missing_ids else ({}, {}) + ) pointers: List[MemoryPointer] = [] - w = HYBRID_WEIGHTS for chunk_id in candidate_ids: meta = ( vector_hits[chunk_id]["metadata"] @@ -471,21 +538,47 @@ def retrieve( if not meta: continue + # Invalidated facts stay in the index (history is preserved) + # but never surface in normal retrieval. + if not include_superseded and meta.get("superseded"): + continue + vector_score = vector_hits.get(chunk_id, {}).get("score", 0.0) bm25_score = bm25_hits.get(chunk_id, {}).get("score", 0.0) + graph_score = graph_hits.get(chunk_id, 0.0) - final = w["vector"] * vector_score + w["bm25"] * bm25_score + final = ( + HYBRID_WEIGHTS.vector * vector_score + + HYBRID_WEIGHTS.bm25 * bm25_score + + HYBRID_WEIGHTS.graph * graph_score + + _recency_bonus(meta.get("timestamp", "")) + ) - if final < min_relevance: + # Eligibility: pass the relevance cut, or be strongly connected + # in the graph to an entity the query names. + if final < min_relevance and graph_score < GRAPH_ELIGIBILITY_SCORE: continue + # Query-aware preview: window the snippet around the query match + # rather than the chunk head. Prefer the item's clean content + # (MEMORY.md items), else the raw document (file chunks), else the + # stored summary as a last resort. + full_text = ( + meta.get("item_content") + or ( + vector_hits[chunk_id].get("document") + if chunk_id in vector_hits + else extra_docs.get(chunk_id, "") + ) + or meta.get("summary", "") + ) pointers.append( MemoryPointer( chunk_id=chunk_id, file_path=meta.get("file_path", ""), section_path=meta.get("section_path", ""), title=meta.get("title", ""), - summary=meta.get("summary", ""), + summary=self._preview_snippet(query, full_text), relevance_score=final, metadata={ k: v @@ -501,7 +594,7 @@ def retrieve( logger.info( f"[MEMORY RESULT] {len(pointers)} pointer(s) returned " f"(vector candidates={len(vector_hits)}, bm25 candidates={len(bm25_hits)}, " - f"min_relevance={min_relevance})" + f"graph candidates={len(graph_hits)}, min_relevance={min_relevance})" ) if not pointers: logger.info("[MEMORY RESULT] (no pointers above min_relevance)") @@ -509,10 +602,216 @@ def retrieve( logger.info( f"[MEMORY RESULT] #{i} score={p.relevance_score:.3f} " f"file={p.file_path} section={p.section_path} " - f":: {_log_preview(p.summary, _LOG_SUMMARY_MAX_CHARS)}" + f":: {_log_preview(p.summary, LOG_SUMMARY_MAX_CHARS)}" ) return pointers + # ───────────────────────── Memory graph API ───────────────────────── + + def _ensure_graph_built(self) -> None: + """Rebuild the memory graph if the index changed since last build.""" + if not self._graph_dirty and self._graph is not None: + return + try: + # The registry supplies the entity list and each memory's + # connection marks. Missing file means an empty registry. + registry: Dict[str, Any] = {} + registry_path = self.agent_fs_path / ENTITY_REGISTRY_FILE + if registry_path.exists(): + registry = parse_entity_registry( + registry_path.read_text(encoding="utf-8") + ) + self._graph = MemoryGraph.build(self._load_full_corpus(), registry) + self._graph_dirty = False + # Keep the entity embedding collection in lock-step with the graph + # so the semantic entity match sees the current entity set. + self._rebuild_entity_index() + # Persist this build's established connections back into the + # ## Connections section (write only on change). + self._sync_connection_records(registry_path) + logger.debug( + f"[MEMORY] Graph rebuilt: {len(self._graph.entities)} entities, " + f"{len(self._graph.items)} items, {len(self._graph.files)} files" + ) + except Exception as e: + logger.warning(f"[MEMORY] Failed to rebuild memory graph: {e}") + # Leave dirty so the next call retries. + + def _sync_connection_records(self, registry_path: Path) -> None: + """Re-sync the connection record lines in ENTITIES.md. + + Ownership is line-scoped, not section-scoped: the system may touch + ONLY lines matching the connection-record grammar + (CONNECTION_LINE_RE) — it removes them and regenerates them from + this build. Every other line — headers, prose, and above all the + ``## Entities`` names — is preserved verbatim, wherever it is and + however mangled the file may be, so no sync can ever damage the + entity list. The regenerated records are placed after the + ``## Connections`` header line (matched as a whole line, never as a + substring; appended at the end if the file lacks one). The file is + written only when the result differs, so the watcher's reindex of + this write converges instead of looping. + """ + if self._graph is None: + return + current = ( + registry_path.read_text(encoding="utf-8") + if registry_path.exists() + else "" + ) + header = "## Connections" + + kept: List[str] = [] + for line in current.splitlines(): + if CONNECTION_LINE_RE.match(line.strip()): + continue # system-owned record line; regenerated below + kept.append(line) + while kept and not kept[-1].strip(): + kept.pop() + + header_index = next( + (i for i, line in enumerate(kept) if line.strip() == header), None + ) + if header_index is None: + if kept: + kept.append("") + kept.append(header) + header_index = len(kept) - 1 + else: + # Blank lines directly under the header are re-added below. + while ( + header_index + 1 < len(kept) and not kept[header_index + 1].strip() + ): + kept.pop(header_index + 1) + + records = self._graph.connection_lines() + rebuilt = ( + kept[: header_index + 1] + [""] + records + kept[header_index + 1 :] + ) + rendered = "\n".join(rebuilt).rstrip("\n") + "\n" + if rendered != current: + registry_path.write_text(rendered, encoding="utf-8") + logger.debug("[MEMORY] Connection records synced to ENTITIES.md") + + def _load_full_corpus(self) -> List[Dict[str, Any]]: + """Pull every chunk (id, document, metadata) from ChromaDB.""" + result = self.collection.get(include=["documents", "metadatas"]) + ids = result.get("ids") or [] + docs = result.get("documents") or [] + metas = result.get("metadatas") or [] + return [ + { + "chunk_id": ids[i], + "document": docs[i] if i < len(docs) else "", + "metadata": metas[i] if i < len(metas) else {}, + } + for i in range(len(ids)) + ] + + def _rebuild_entity_index(self) -> None: + """Sync the entity embedding collection with the current graph. + + One record per entity (id = entity key, document = display name), + embedded with the same function as the chunks so the graph channel + can resolve entities by name similarity. Incremental: only new + entities are embedded and dropped ones removed. It is a derived cache + rebuilt from the graph, never migrated. + """ + if self._graph is None: + return + try: + current = { + key: (node.name or key) + for key, node in self._graph.entities.items() + if key + } + existing = set(self.entity_collection.get().get("ids") or []) + current_ids = set(current.keys()) + + to_remove = list(existing - current_ids) + if to_remove: + self.entity_collection.delete(ids=to_remove) + + to_add = [k for k in current_ids if k not in existing] + if to_add: + self.entity_collection.add( + ids=to_add, + documents=[current[k] for k in to_add], + metadatas=[{"name": current[k], "key": k} for k in to_add], + ) + except Exception as e: + logger.warning(f"[MEMORY] Failed to rebuild entity index: {e}") + + def _match_entities_semantic( + self, + query: str, + max_seeds: int = SEMANTIC_SEEDS_MAX, + min_score: float = ENTITY_MATCH_MIN_SCORE, + ) -> List[Tuple[str, float]]: + """Resolve query → entities by NAME embedding similarity. + + Returns (entity_key, similarity) pairs at or above ``min_score``. + This is the fuzzy/partial channel — "Tobias" resolves to the + "Tobias Garcia" node here where the string matcher cannot. + """ + if not query or not query.strip(): + return [] + try: + count = self.entity_collection.count() + if count == 0: + return [] + result = self.entity_collection.query( + query_texts=[query], + n_results=min(max_seeds, count), + include=["distances"], + ) + ids = (result.get("ids") or [[]])[0] + distances = (result.get("distances") or [[]])[0] + seeds: List[Tuple[str, float]] = [] + for i, key in enumerate(ids): + sim = _cosine_distance_to_similarity( + distances[i] if i < len(distances) else 1.0 + ) + if sim >= min_score: + seeds.append((key, sim)) + return seeds + except Exception as e: + logger.warning(f"[MEMORY] Semantic entity match failed: {e}") + return [] + + @staticmethod + def _merge_entity_seeds( + *seed_lists: List[Tuple[str, float]], max_seeds: int = MERGED_SEEDS_MAX + ) -> List[Tuple[str, float]]: + """Union entity seeds keeping the strongest strength per entity.""" + best: Dict[str, float] = {} + for seeds in seed_lists: + for key, strength in seeds: + if strength > best.get(key, 0.0): + best[key] = strength + return sorted(best.items(), key=lambda kv: (-kv[1], kv[0]))[:max_seeds] + + def graph_snapshot(self) -> Dict[str, Any]: + """Full graph serialisation for the Memory panel (nodes/edges/stats).""" + self._ensure_graph_built() + if self._graph is None: + return {"nodes": [], "edges": [], "stats": {}} + return self._graph.snapshot() + + def entity_overview(self, name: str) -> Optional[Dict[str, Any]]: + """Everything the memory graph knows about one entity, or None.""" + self._ensure_graph_built() + if self._graph is None: + return None + return self._graph.entity_overview(name) + + def related_path(self, name_a: str, name_b: str) -> List[Dict[str, Any]]: + """Shortest connection between two entities through items/files.""" + self._ensure_graph_built() + if self._graph is None: + return [] + return self._graph.shortest_path(name_a, name_b) + # ───────────────────────── Hybrid retrieval helpers ───────────────────────── def _ensure_bm25_built(self) -> None: @@ -531,9 +830,8 @@ def _ensure_bm25_built(self) -> None: def _load_bm25_corpus(self) -> Dict[str, str]: """Pull every chunk's searchable text from ChromaDB. - We concatenate the document body, summary, and extracted_entities so - BM25 has the strongest possible keyword signal — especially proper - nouns that vector embeddings often miss. + We concatenate the document body and summary so BM25 has the full + keyword signal of each chunk. """ try: result = self.collection.get( @@ -552,8 +850,7 @@ def _load_bm25_corpus(self) -> Dict[str, str]: body = docs[i] if i < len(docs) else "" meta = metas[i] if i < len(metas) else {} summary = meta.get("summary", "") - entities = meta.get("extracted_entities", "") - corpus[chunk_id] = f"{body}\n{summary}\n{entities}" + corpus[chunk_id] = f"{body}\n{summary}" return corpus def _fetch_metadata(self, chunk_ids: List[str]) -> Dict[str, Dict[str, Any]]: @@ -569,6 +866,30 @@ def _fetch_metadata(self, chunk_ids: List[str]) -> Dict[str, Dict[str, Any]]: logger.warning(f"[MEMORY] Metadata fetch failed: {e}") return {} + def _fetch_meta_and_docs( + self, chunk_ids: List[str] + ) -> tuple[Dict[str, Dict[str, Any]], Dict[str, str]]: + """Fetch metadata AND documents for a set of chunk ids in one call. + + Used for non-vector candidates so the query-aware preview can window + the full chunk text (the vector channel already carries its own docs). + """ + if not chunk_ids: + return {}, {} + try: + result = self.collection.get( + ids=chunk_ids, include=["metadatas", "documents"] + ) + ids = result.get("ids") or [] + metas = result.get("metadatas") or [] + docs = result.get("documents") or [] + meta_map = {ids[i]: metas[i] for i in range(len(ids))} + doc_map = {ids[i]: (docs[i] if i < len(docs) else "") for i in range(len(ids))} + return meta_map, doc_map + except Exception as e: + logger.warning(f"[MEMORY] Metadata/document fetch failed: {e}") + return {}, {} + def retrieve_full_content(self, chunk_id: str) -> Optional[str]: """ Retrieve the full content of a specific chunk by its ID. @@ -616,9 +937,7 @@ def update(self) -> Dict[str, Any]: # Get current files in agent file system current_files = self._get_all_markdown_files() - current_file_paths = { - str(f.relative_to(self.agent_fs_path)) for f in current_files - } + current_file_paths = {self._rel_path(f) for f in current_files} indexed_file_paths = set(self._file_index_cache.keys()) # Find new, modified, and removed files @@ -638,7 +957,10 @@ def update(self) -> Dict[str, Any]: current_hash = self._compute_file_hash(full_path) cached_index = self._file_index_cache.get(file_path) - if cached_index and cached_index.content_hash != current_hash: + if cached_index and ( + cached_index.content_hash != current_hash + or self._expected_chunk_ids(full_path) != cached_index.chunk_ids + ): modified_files.append(file_path) # Index new files @@ -689,12 +1011,16 @@ def index_all(self, force: bool = False) -> Dict[str, Any]: markdown_files = self._get_all_markdown_files() for file_path in markdown_files: - rel_path = str(file_path.relative_to(self.agent_fs_path)) + rel_path = self._rel_path(file_path) # Skip if already indexed (and not forcing) if not force and rel_path in self._file_index_cache: + cached = self._file_index_cache[rel_path] current_hash = self._compute_file_hash(file_path) - if self._file_index_cache[rel_path].content_hash == current_hash: + if ( + cached.content_hash == current_hash + and self._expected_chunk_ids(file_path) == cached.chunk_ids + ): stats["files_skipped"] += 1 continue @@ -764,12 +1090,15 @@ def _chunk_memory_log(self, content: str, file_path: str) -> List[MemoryChunk]: whole is still in INDEX_TARGET_FILES so its preamble is captured by the section chunker on other indexed files where appropriate. - Per-chunk metadata carries timestamp, category, extracted_entities - (list of capitalised tokens / quoted strings) and an indexed_at - stamp. Timestamp is stored for display / debugging only. + Per-chunk metadata carries timestamp, category, entities (wikilinks + when present, heuristic extraction otherwise), the superseded flag, + and an indexed_at stamp. MEMORY.md chunks get deterministic ids + derived from (timestamp, content), so the graph node, the Chroma + chunk, and the UI item share one identity across rebuilds. """ chunks: List[MemoryChunk] = [] now = datetime.utcnow().isoformat() + seen_ids: Dict[str, int] = {} for raw_line in content.splitlines(): line = raw_line.strip() @@ -783,13 +1112,23 @@ def _chunk_memory_log(self, content: str, file_path: str) -> List[MemoryChunk]: timestamp_iso = _normalize_timestamp(timestamp_str) category = category.lower() - # Body = the item content. Summary = first ~150 chars cleaned. - entities = extract_entities(item_text) - summary = self._create_summary(item_text) + clean_text, _, superseded = split_item_fields(item_text) + summary = self._create_summary(clean_text) + + # Deterministic id for every per-item chunk: same line → same id + # across rebuilds (graph node, Chroma chunk, and UI item share + # one identity, and cached index entries can be validated by + # re-deriving). Identical duplicate lines get a stable ordinal + # suffix. + chunk_id = compute_item_id(timestamp_iso or timestamp_str, clean_text) + dup = seen_ids.get(chunk_id, 0) + seen_ids[chunk_id] = dup + 1 + if dup: + chunk_id = f"{chunk_id}-{dup + 1}" chunks.append( MemoryChunk( - chunk_id=str(uuid.uuid4()), + chunk_id=chunk_id, file_path=file_path, section_path=f"item:{category}", title=category, @@ -801,10 +1140,8 @@ def _chunk_memory_log(self, content: str, file_path: str) -> List[MemoryChunk]: metadata={ "timestamp": timestamp_iso, "category": category, - # ChromaDB metadata values must be primitives; serialise - # the entity list as a comma-joined string. The BM25 - # corpus and retrieval consumers parse it back. - "extracted_entities": ", ".join(entities), + "item_content": clean_text, + "superseded": superseded, "item_kind": "memory_log", }, ) @@ -813,10 +1150,25 @@ def _chunk_memory_log(self, content: str, file_path: str) -> List[MemoryChunk]: return chunks def _chunk_by_sections(self, content: str, file_path: str) -> List[MemoryChunk]: - """Original header-based chunker. Preserves existing behaviour for - non-list markdown (AGENT.md, USER.md, PROACTIVE.md, ...). + """Header-based chunker for non-list markdown (AGENT.md, USER.md, + workspace docs, ...). + + Chunk ids are deterministic hashes of (file, section, content): + file chunks ARE memories, so the graph node, the Chroma chunk, and + the ENTITIES.md connection records must share one identity across + rebuilds — same rule as MEMORY.md items. """ chunks: List[MemoryChunk] = [] + seen_ids: Dict[str, int] = {} + + def chunk_id_for(section_path: str, chunk_content: str) -> str: + digest = hashlib.md5( + f"{file_path}|{section_path}|{chunk_content}".encode("utf-8") + ).hexdigest() + cid = f"c{digest[:12]}" + dup = seen_ids.get(cid, 0) + seen_ids[cid] = dup + 1 + return cid if not dup else f"{cid}-{dup + 1}" # Parse headers and their content sections = self._parse_markdown_sections(content) @@ -840,7 +1192,9 @@ def _chunk_by_sections(self, content: str, file_path: str) -> List[MemoryChunk]: ) for i, sub_content in enumerate(sub_chunks): chunk = MemoryChunk( - chunk_id=str(uuid.uuid4()), + chunk_id=chunk_id_for( + f"{section['path']} (part {i + 1})", sub_content + ), file_path=file_path, section_path=f"{section['path']} (part {i + 1})", title=section["title"], @@ -858,7 +1212,7 @@ def _chunk_by_sections(self, content: str, file_path: str) -> List[MemoryChunk]: chunks.append(chunk) else: chunk = MemoryChunk( - chunk_id=str(uuid.uuid4()), + chunk_id=chunk_id_for(section["path"], section_content), file_path=file_path, section_path=section["path"], title=section["title"], @@ -1028,28 +1382,79 @@ def _split_by_sentences(self, text: str) -> List[str]: return chunks - def _create_summary(self, content: str, max_length: int = 150) -> str: - """ - Create a brief summary of content for the memory pointer. + def _clean_for_preview(self, content: str) -> str: + """Strip markdown SYNTAX positionally for a readable preview. - Takes the first meaningful text, cleans it up, and truncates. + Never removes characters inside words, or snake_case identifiers like + list_available_integrations collapse into unreadable mush. """ - # Remove markdown formatting - clean = re.sub(r"\[([^\]]+)\]\([^\)]+\)", r"\1", content) # Links - clean = re.sub(r"[*_`#]+", "", clean) # Formatting + clean = re.sub(r"\[([^\]]+)\]\([^\)]+\)", r"\1", content or "") # Links + clean = re.sub(r"^#{1,6}\s+", "", clean, flags=re.MULTILINE) # Headings + clean = clean.replace("`", "") # Inline-code markers + clean = re.sub(r"\*+", "", clean) # Bold/italic markers clean = re.sub(r"\s+", " ", clean).strip() # Whitespace + return clean - # Take first max_length chars, break at word boundary + @staticmethod + def _truncate_preview(clean: str, max_length: int) -> str: + """Head-of-text truncation at a word boundary, with trailing '...'.""" if len(clean) <= max_length: return clean - truncated = clean[:max_length] last_space = truncated.rfind(" ") if last_space > max_length * 0.7: truncated = truncated[:last_space] - return truncated + "..." + def _create_summary(self, content: str, max_length: int = 150) -> str: + """Brief from-the-head summary of content for the stored pointer.""" + return self._truncate_preview(self._clean_for_preview(content), max_length) + + def _preview_snippet(self, query: str, content: str) -> str: + """A query-CENTRED preview of a chunk (keyword-in-context). + + Cleans markdown like the stored summary, then returns a window + centred on the first query match that covers the most query terms, + with leading/trailing ellipses marking omitted text. Degrades to the + head-of-content summary when no query term appears, so non-matching + previews look exactly as before. Built at retrieval time because the + stored summary is query-independent. + """ + clean = self._clean_for_preview(content) + if len(clean) <= PREVIEW_MAX_CHARS: + return clean + + terms = [ + t for t in re.findall(r"[a-z0-9]+", (query or "").lower()) if len(t) > 2 + ] + low = clean.lower() + # Anchor on the term occurrence whose window covers the most distinct + # query terms, so multi-word matches stay together. + anchor = -1 + best_hits = 0 + for term in terms: + i = low.find(term) + while i != -1: + hits = sum(1 for u in terms if u in low[i : i + PREVIEW_MAX_CHARS]) + if hits > best_hits: + best_hits = hits + anchor = i + i = low.find(term, i + len(term)) + + if anchor < 0: + # No query term in the chunk — fall back to the head snippet. + return self._truncate_preview(clean, PREVIEW_MAX_CHARS) + + start = max(0, anchor - PREVIEW_LEAD) + end = min(len(clean), start + PREVIEW_MAX_CHARS) + start = max(0, end - PREVIEW_MAX_CHARS) # re-widen left near the tail + snippet = clean[start:end].strip() + if start > 0: + snippet = "..." + snippet + if end < len(clean): + snippet = snippet + "..." + return snippet + # ───────────────────────────── Indexing Helpers ───────────────────────────── def _index_file(self, file_path: Path) -> int: @@ -1059,12 +1464,12 @@ def _index_file(self, file_path: Path) -> int: Returns the number of chunks created. """ try: - content = file_path.read_text(encoding="utf-8") + content = extract_text(file_path) except Exception as e: logger.error(f"Error reading file {file_path}: {e}") return 0 - rel_path = str(file_path.relative_to(self.agent_fs_path)) + rel_path = self._rel_path(file_path) file_hash = self._compute_file_hash(file_path) file_modified = datetime.fromtimestamp(file_path.stat().st_mtime).isoformat() @@ -1110,6 +1515,7 @@ def _index_file(self, file_path: Path) -> int: return 0 self._bm25_dirty = True + self._graph_dirty = True # Update file index cache file_index = FileIndex( @@ -1125,6 +1531,23 @@ def _index_file(self, file_path: Path) -> int: logger.debug(f"Indexed {len(chunks)} chunks from {rel_path}") return len(chunks) + def _expected_chunk_ids(self, file_path: Path) -> List[str]: + """Chunk ids the CURRENT chunker derives from the file's content. + + Pure text derivation, no embedding. Chunk ids are deterministic + functions of content, so a cached index entry is valid only if its + stored ids equal this derivation — an entry produced by different + chunking code simply fails the comparison and the file reseeds. + Nothing about past code is stored or detected. + """ + try: + content = extract_text(file_path) + except Exception as e: + logger.error(f"Error reading file {file_path}: {e}") + return [] + rel_path = self._rel_path(file_path) + return [chunk.chunk_id for chunk in self._chunk_markdown(content, rel_path)] + def _remove_file_from_index(self, file_path: str) -> None: """Remove all chunks for a file from the index.""" file_index = self._file_index_cache.get(file_path) @@ -1147,36 +1570,57 @@ def _remove_file_from_index(self, file_path: str) -> None: # Remove from cache del self._file_index_cache[file_path] self._bm25_dirty = True + self._graph_dirty = True logger.debug(f"Removed {len(file_index.chunk_ids)} chunks for {file_path}") def _clear_index(self) -> None: - """Clear all data from the memory index.""" - # Delete and recreate collections - try: - self.chroma_client.delete_collection(self.COLLECTION_NAME) - except Exception: - pass + """Drop and recreate every derived collection from scratch. - try: - self.chroma_client.delete_collection(self.FILE_INDEX_COLLECTION) - except Exception: - pass + Chunks, the file index, AND the entity embedding collection are all + wiped and reopened with the SAME embedding function, so a force + rebuild reseeds cleanly from the markdown without downgrading the + model. The graph is dropped too; it rebuilds (and reseeds the entity + vectors) on next access. + """ + for name in ( + self.COLLECTION_NAME, + self.FILE_INDEX_COLLECTION, + self.ENTITY_COLLECTION, + ): + try: + self.chroma_client.delete_collection(name) + except Exception: + pass - self.collection = self.chroma_client.get_or_create_collection( + self.collection = self._open_collection( name=self.COLLECTION_NAME, + embedding_fn=self._embedding_fn, metadata={ - "description": "Agent file system memory chunks (v2)", + "description": "Agent file system memory chunks", "hnsw:space": "cosine", + "embedding_model": MEMORY_EMBEDDING_MODEL, }, ) - self.file_index_collection = self.chroma_client.get_or_create_collection( + self.file_index_collection = self._open_collection( name=self.FILE_INDEX_COLLECTION, - metadata={"description": "File index for incremental updates (v2)"}, + embedding_fn=self._embedding_fn, + metadata={"description": "File index for incremental updates"}, + ) + self.entity_collection = self._open_collection( + name=self.ENTITY_COLLECTION, + embedding_fn=self._embedding_fn, + metadata={ + "description": "Entity name embeddings for graph-channel matching", + "hnsw:space": "cosine", + "embedding_model": MEMORY_EMBEDDING_MODEL, + }, ) self._file_index_cache.clear() self._bm25_dirty = True + self._graph = None + self._graph_dirty = True # ───────────────────────────── File Index Persistence ───────────────────────────── @@ -1229,15 +1673,61 @@ def _save_file_index(self, file_index: FileIndex) -> None: # ───────────────────────────── Utilities ───────────────────────────── - # Files to index for memory retrieval + # Files always indexed for memory retrieval. User-selected extras come + # from the extra_files_provider (settings-backed, managed in the Memory + # panel) and are merged in by get_index_target_files(). INDEX_TARGET_FILES = [ "AGENT.md", "PROACTIVE.md", "MEMORY.md", "USER.md", "EVENT_UNPROCESSED.md", + # Entity registry (entity-indexer skill output). Indexed so the + # file watcher picks up registry edits and dirties the graph. + "ENTITIES.md", ] + def get_index_target_files(self) -> List[str]: + """Core files plus validated user-selected extras (relative paths).""" + targets = list(self.INDEX_TARGET_FILES) + if self._extra_files_provider is None: + return targets + + try: + extras = self._extra_files_provider() or [] + except Exception as e: + logger.warning(f"[MEMORY] extra_files_provider failed: {e}") + return targets + + seen = set(targets) + for raw in extras: + rel = str(raw).replace("\\", "/").strip().lstrip("/") + if not rel or rel in seen or not is_indexable_file(rel): + continue + # Confine to the agent file system — reject traversal attempts. + try: + resolved = (self.agent_fs_path / rel).resolve() + resolved.relative_to(self.agent_fs_path) + except (ValueError, OSError): + logger.warning(f"[MEMORY] Ignoring indexed file outside FS: {raw}") + continue + seen.add(rel) + targets.append(rel) + return targets + + def is_index_target(self, path: str) -> bool: + """Whether an absolute or relative path is currently indexed.""" + try: + p = Path(path) + rel = ( + str(p.resolve().relative_to(self.agent_fs_path)) + if p.is_absolute() + else str(p) + ).replace("\\", "/") + except (ValueError, OSError): + return False + return rel in set(self.get_index_target_files()) + def _get_all_markdown_files(self) -> List[Path]: """Get the target markdown files in the agent file system.""" if not self.agent_fs_path.exists(): @@ -1247,18 +1737,43 @@ def _get_all_markdown_files(self) -> List[Path]: return [] files = [] - for filename in self.INDEX_TARGET_FILES: + for filename in self.get_index_target_files(): file_path = self.agent_fs_path / filename if file_path.exists(): files.append(file_path) return files + def _rel_path(self, file_path: Path) -> str: + """Path relative to the FS root, always forward-slashed. + + One canonical separator keeps chunk metadata, the file-index cache, + the settings list, and the panel display consistent across platforms. + """ + return str(file_path.relative_to(self.agent_fs_path)).replace("\\", "/") + + def get_index_files_info(self) -> List[Dict[str, Any]]: + """Per-file index status for the Memory panel.""" + core = set(self.INDEX_TARGET_FILES) + info: List[Dict[str, Any]] = [] + for rel in self.get_index_target_files(): + file_path = self.agent_fs_path / rel + index = self._file_index_cache.get(rel) + info.append( + { + "path": rel, + "core": rel in core, + "exists": file_path.exists(), + "chunk_count": len(index.chunk_ids) if index else 0, + "indexed_at": index.indexed_at if index else "", + } + ) + return info + @staticmethod def _compute_file_hash(file_path: Path) -> str: - """Compute MD5 hash of file content.""" + """MD5 of file content — the incremental updater's change signal.""" try: - content = file_path.read_bytes() - return hashlib.md5(content).hexdigest() + return hashlib.md5(file_path.read_bytes()).hexdigest() except Exception: return "" @@ -1288,20 +1803,31 @@ def _cosine_distance_to_similarity(distance: float) -> float: return sim -def _normalize_timestamp(ts: str) -> str: - """Coerce '/' or 'T'-separated timestamps to canonical 'YYYY-MM-DD HH:MM:SS'. +def _recency_bonus(timestamp: str) -> float: + """Small additive bonus for recent memory items. - Returns an empty string when parsing fails — stored as metadata only; - not currently used in ranking. + Decays exponentially with RECENCY_HALF_LIFE_DAYS; chunks without a + parseable timestamp (section chunks, legacy items) get no bonus. """ - if not ts: - return "" - cleaned = ts.replace("/", "-").replace("T", " ") + if not timestamp: + return 0.0 try: - dt = datetime.strptime(cleaned, "%Y-%m-%d %H:%M:%S") - return dt.strftime("%Y-%m-%d %H:%M:%S") + dt = datetime.strptime(timestamp, "%Y-%m-%d %H:%M:%S") except ValueError: - return "" + return 0.0 + age_days = max(0.0, (datetime.now() - dt).total_seconds() / 86400.0) + return RECENCY_MAX_BONUS * (0.5 ** (age_days / RECENCY_HALF_LIFE_DAYS)) + + +def _normalize_timestamp(ts: str) -> str: + """Validate against the canonical 'YYYY-MM-DD HH:MM:SS' stamp format. + Delegates to the shared graph helper so item ids are derived from the + identical canonical form everywhere. Returns '' when the stamp is + invalid; the timestamp feeds the recency bonus in retrieval. + """ + from agent_core.core.impl.memory.graph import normalize_timestamp + + return normalize_timestamp(ts) # ───────────────────────────── Testing / Demo ───────────────────────────── diff --git a/agent_core/core/impl/memory/memory_file_watcher.py b/agent_core/core/impl/memory/memory_file_watcher.py index 24361109..fa3b900f 100644 --- a/agent_core/core/impl/memory/memory_file_watcher.py +++ b/agent_core/core/impl/memory/memory_file_watcher.py @@ -17,7 +17,6 @@ import threading import time -from pathlib import Path from typing import Optional, Set from watchdog.events import FileSystemEvent, FileSystemEventHandler @@ -85,14 +84,16 @@ def start(self) -> None: self._observer = Observer() event_handler = _TargetFileEventHandler( self._on_file_change, - self.watch_path, - MemoryManager.INDEX_TARGET_FILES, + self.memory_manager, ) self._observer.schedule( event_handler, str(self.watch_path), - recursive=False, # Target files are in root directory + # Recursive: user-selected extra files (e.g. workspace/notes.md) + # can live in subdirectories. The handler filters by the + # manager's current target set, so unrelated churn is ignored. + recursive=True, ) self._observer.start() @@ -185,27 +186,35 @@ def is_running(self) -> bool: class _TargetFileEventHandler(FileSystemEventHandler): """ - Event handler that filters for specific target files and forwards events. + Event handler that filters for the manager's current index targets. + + Membership is checked against the manager on every event (not a frozen + list), so files added or removed in the Memory panel take effect + immediately without restarting the watcher. """ - def __init__(self, callback, watch_path: Path, target_files: list): + def __init__(self, callback, memory_manager: MemoryManager): """ Initialize the handler. Args: callback: Function to call with (file_path, event_type) on changes - watch_path: The base directory being watched - target_files: List of filenames to watch (e.g., ["AGENT.md", "MEMORY.md"]) + memory_manager: Source of truth for which files are indexed """ super().__init__() self._callback = callback - self._watch_path = watch_path - self._target_files = set(target_files) + self._memory_manager = memory_manager def _is_target_file(self, path: str) -> bool: - """Check if the path is one of the target files.""" - filename = Path(path).name - return filename in self._target_files + """Check if the path is currently an index target.""" + from agent_core.core.impl.memory.text_extract import is_indexable_file + + if not is_indexable_file(str(path)): + return False + try: + return self._memory_manager.is_index_target(str(path)) + except Exception: + return False def on_created(self, event: FileSystemEvent) -> None: if not event.is_directory and self._is_target_file(event.src_path): diff --git a/agent_core/core/impl/memory/text_extract.py b/agent_core/core/impl/memory/text_extract.py new file mode 100644 index 00000000..3b1eff45 --- /dev/null +++ b/agent_core/core/impl/memory/text_extract.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +""" +Text extraction for indexable files. + +One shared preprocessing point for everything that reads an indexed file's +content (the memory indexer, section listing, and the read_file action), so +every consumer sees the identical text for the identical file. + +Supported types: +- .md / .txt — read as-is (UTF-8, undecodable bytes replaced) +- .pdf — TEXT LAYER ONLY via pypdf. Images, drawings, and any other + non-text content are ignored. Each page becomes a + ``## Page N`` section so the markdown section chunker (and + therefore the entity-indexer's section keys) get a stable, + meaningful structure. +""" + +from __future__ import annotations + +from pathlib import Path + +# The closed set of file types the memory system can index. +INDEXABLE_SUFFIXES = (".md", ".txt", ".pdf") + + +def is_indexable_file(path: str) -> bool: + """Whether a path's type can be indexed into memory.""" + return path.lower().endswith(INDEXABLE_SUFFIXES) + + +def extract_text(file_path: Path) -> str: + """Return the text content of an indexable file. + + Raises on unreadable files — callers treat extraction failure like a + read failure (the file is skipped and logged, never half-indexed). + """ + suffix = file_path.suffix.lower() + if suffix == ".pdf": + from pypdf import PdfReader + + reader = PdfReader(str(file_path)) + pages = [] + for number, page in enumerate(reader.pages, start=1): + text = (page.extract_text() or "").strip() + pages.append(f"## Page {number}\n\n{text}") + return "\n\n".join(pages) + return file_path.read_text(encoding="utf-8", errors="replace") diff --git a/agent_core/core/impl/memory/tuning.py b/agent_core/core/impl/memory/tuning.py new file mode 100644 index 00000000..2834f4e9 --- /dev/null +++ b/agent_core/core/impl/memory/tuning.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- +"""Every tuning number of the memory system, in one typed place. + +Retrieval weights, thresholds, seed caps, chunking sizes, processing +defaults, and scan bounds all live here — no other memory-system module +defines a numeric behavior constant. Change a value here and every +consumer (manager, graph, BM25, injector, settings, adapter) follows. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final + + +# ───────────────────────── Hybrid retrieval ───────────────────────── + +@dataclass(frozen=True) +class HybridWeights: + """Channel weights of the hybrid score. Vector is the primary signal, + BM25 backstops proper nouns and dates, the graph channel boosts items + connected to entities mentioned in the query (including 2-hop + neighbours the other channels can miss entirely).""" + + vector: float + bm25: float + graph: float + + +HYBRID_WEIGHTS: Final[HybridWeights] = HybridWeights( + vector=0.55, + bm25=0.30, + graph=0.15, +) + +# Default result count and relevance floor of MemoryManager.retrieve(). +RETRIEVE_TOP_K: Final[int] = 5 +RETRIEVE_MIN_RELEVANCE: Final[float] = 0.55 + +# Per-channel candidate net cast before the hybrid re-rank: +# max(top_k * multiplier, floor). +CANDIDATE_POOL_MULTIPLIER: Final[int] = 4 +CANDIDATE_POOL_FLOOR: Final[int] = 20 + +# A strongly graph-connected item is eligible even when its combined score +# sits below min_relevance — this is what lets 2-hop related memories +# surface despite sharing no words with the query. +GRAPH_ELIGIBILITY_SCORE: Final[float] = 0.5 + +# Recency bonus: newest items get up to +RECENCY_MAX_BONUS, halving every +# RECENCY_HALF_LIFE_DAYS. Small on purpose — recency is a tiebreaker, not +# a ranking signal of its own. +RECENCY_MAX_BONUS: Final[float] = 0.05 +RECENCY_HALF_LIFE_DAYS: Final[float] = 30.0 + +# Default result count of BM25Index.search(). +BM25_SEARCH_TOP_K: Final[int] = 20 + + +# ───────────────────────── Graph channel seeds ───────────────────────── + +# Strength assigned to every string-matched entity seed (exact phrase and +# all-tokens-present alike). +ENTITY_SEED_STRENGTH: Final[float] = 1.0 + +# Minimum cosine similarity for the SEMANTIC entity match (graph channel). +# The query is embedded and compared against each entity's name embedding; +# below this a match is treated as noise. This is what resolves partial names +# ("Tobias" → "Tobias Garcia") without hand-rolled token rules. The string +# matcher still catches exact / all-token hits at full strength regardless. +ENTITY_MATCH_MIN_SCORE: Final[float] = 0.6 + +# Seed caps: string-matched seeds, semantic seeds, and the union of both. +STRING_SEEDS_MAX: Final[int] = 5 +SEMANTIC_SEEDS_MAX: Final[int] = 5 +MERGED_SEEDS_MAX: Final[int] = 8 + +# Hub-entity exclusion: an entity confirmed on more than this fraction of +# all memories is ambient context, not information — it is left out of the +# derived graph entirely (no node, no links, no retrieval seeding). The +# annotations themselves are never touched, so exclusion is recomputed on +# every build and reverses itself when the corpus shifts. The absolute +# floor keeps small corpora intact (with 20 memories, 25% would be 5 +# links — normal for any legitimate entity). +ENTITY_HUB_FRACTION: Final[float] = 0.25 +ENTITY_HUB_MIN_LINKS: Final[int] = 10 + +# BFS scoring: items directly attached to a seed entity score full seed +# strength; items reached through one intermediate entity decay by this. +SECOND_HOP_DECAY: Final[float] = 0.45 + +# Community detection rounds. The graph is small (hundreds of nodes); label +# propagation converges in a handful of rounds. +LABEL_PROPAGATION_ROUNDS: Final[int] = 10 + + +# ───────────────────────────── Chunking ───────────────────────────── + +# Max characters per chunk before splitting, and the character overlap +# carried between chunks when a large section is split. +CHUNK_SIZE_LIMIT: Final[int] = 1500 +CHUNK_OVERLAP: Final[int] = 100 + + +# ──────────────────────── Previews and logging ──────────────────────── + +# Query-aware preview window. The injected memory preview is centred on the +# query match instead of the chunk's head, so the fact that made the chunk +# relevant is not truncated away (a from-the-start summary once cut off +# "Tobias Garcia" and the agent had to grep for it). PREVIEW_MAX_CHARS bounds +# the snippet; PREVIEW_LEAD keeps a little context before the match. +PREVIEW_MAX_CHARS: Final[int] = 180 +PREVIEW_LEAD: Final[int] = 40 + +# Log-line preview limits. Keep multi-line queries and long summaries from +# bleeding across log entries. +LOG_QUERY_MAX_CHARS: Final[int] = 300 +LOG_SUMMARY_MAX_CHARS: Final[int] = 120 + +# Text preview appended to each ## Connections record line in ENTITIES.md — +# enough for the entity-indexer to judge a connection from the line alone. +CONNECTION_PREVIEW_MAX_CHARS: Final[int] = 160 + + +# ──────────────────────── Trigger-driven injection ──────────────────────── + +# Relevance floor and max preview count for memories auto-injected into the +# event stream on message arrival / task creation. +INJECT_MIN_RELEVANCE: Final[float] = 0.5 +INJECT_TOP_K: Final[int] = 5 + + +# ──────────────────────── Processing and pruning ──────────────────────── + +# Unprocessed-event count that fires processing immediately (threshold- +# driven) and gates the daily scheduled run; 0 disables the gate. MAX is +# the upper bound the settings slider allows. +PROCESSING_THRESHOLD_DEFAULT: Final[int] = 25 +PROCESSING_THRESHOLD_MAX: Final[int] = 100 + +# MEMORY.md size management: item cap that triggers pruning, the count +# pruning shrinks down to, and the per-item word limit. +MEMORY_MAX_ITEMS_DEFAULT: Final[int] = 200 +MEMORY_PRUNE_TARGET_DEFAULT: Final[int] = 135 +MEMORY_ITEM_WORD_LIMIT_DEFAULT: Final[int] = 150 + +# Default daily auto-processing time (24h clock). +SCHEDULE_HOUR_DEFAULT: Final[int] = 3 +SCHEDULE_MINUTE_DEFAULT: Final[int] = 0 + + +# ──────────────────────── Indexed-file candidate scan ──────────────────────── + +# Bounds of the workspace scan that offers files in the index picker — +# keeps the picker responsive on large workspaces. +CANDIDATE_MAX_DEPTH: Final[int] = 10 +CANDIDATE_MAX_RESULTS: Final[int] = 500 diff --git a/agent_core/core/prompts/context.py b/agent_core/core/prompts/context.py index 1bd8afd0..62b78406 100644 --- a/agent_core/core/prompts/context.py +++ b/agent_core/core/prompts/context.py @@ -83,7 +83,9 @@ - The agent file system and MEMORY.md serves as your persistent memory across sessions. Information stored here persists and can be retrieved in future conversations. Use it to recall important facts about users, projects, and the organization. -- You can run the 'memory_search' action and read related information from the agent file system and MEMORY.md to retrieve memory related to the task, users, related resources and instruction. +- Memory is organized as a graph: each memory item carries an {entities: ...} field naming the people/projects/tools it is about (written during memory processing), and indexed files map to entities through the ENTITIES.md registry (maintained by the entity-indexer skill). +- Retrieval actions: 'memory_search' (semantic search over everything indexed), 'memory_entity' (all facts about one named entity plus its related entities and files), 'memory_related' (how two entities are connected). Prefer memory_entity when the subject is a specific named thing. +- Memory items marked {superseded} are outdated facts kept as history; they are excluded from retrieval automatically. @@ -186,7 +188,8 @@ - **{agent_file_system_path}/AGENT.md**: Your identity file containing agent configuration, operating model, task execution guidelines, communication rules, error handling strategies, documentation standards, and organization context including org chart. Use this to understand how yourself work when user is asking about your feature/mechanism that you have no context of. - **{agent_file_system_path}/USER.md**: User profile containing identity, communication preferences, interaction settings, and personality information. Reference this to personalize interactions. - **{agent_file_system_path}/SOUL.md**: Your personality, tone, and behavioral traits. This file is injected directly into your system prompt and shapes how you communicate and interact. Users can edit it to customize your personality. You can read and update SOUL.md to adjust your personality when instructed by the user. -- **{agent_file_system_path}/MEMORY.md**: Persistent memory log storing distilled facts, preferences, and events from past interactions. Format: `[timestamp] [type] content`. Agent should NOT edit directly - use memory processing actions. +- **{agent_file_system_path}/MEMORY.md**: Persistent memory log storing distilled facts, preferences, and events from past interactions. Format: `[timestamp] [category] content {{entities: Name1, Name2}}`, optionally ending in `{{superseded}}` for invalidated facts. Agent should NOT edit directly - use memory processing actions. +- **{agent_file_system_path}/ENTITIES.md**: Registry mapping indexed files to their entities, maintained by the entity-indexer skill during memory processing. Agent should NOT edit directly. - **{agent_file_system_path}/EVENT.md**: Comprehensive event log tracking all system activities including task execution, action results, and agent messages. Older events are summarized automatically. - **{agent_file_system_path}/EVENT_UNPROCESSED.md**: Temporary buffer for recent events awaiting memory processing. Events here are periodically evaluated and important ones are distilled into MEMORY.md. - **{agent_file_system_path}/PROACTIVE.md**: Configuration for scheduled proactive tasks (hourly/daily/weekly/monthly), including task instructions, conditions, priorities, deadlines, and execution history. diff --git a/agent_core/core/protocols/memory.py b/agent_core/core/protocols/memory.py index c9082f48..b40ce456 100644 --- a/agent_core/core/protocols/memory.py +++ b/agent_core/core/protocols/memory.py @@ -62,6 +62,36 @@ def retrieve_full_content(self, chunk_id: str) -> Optional[str]: """ ... + def graph_snapshot(self) -> Dict[str, Any]: + """ + Full memory-graph serialisation (nodes, edges, stats) for UIs. + """ + ... + + def entity_overview(self, name: str) -> Optional[Dict[str, Any]]: + """ + Everything the memory graph knows about one entity, or None. + """ + ... + + def related_path(self, name_a: str, name_b: str) -> List[Dict[str, Any]]: + """ + Shortest connection between two entities through items/files. + """ + ... + + def get_index_target_files(self) -> List[str]: + """ + Core index files plus validated user-selected extras. + """ + ... + + def get_index_files_info(self) -> List[Dict[str, Any]]: + """ + Per-file index status (path, core, exists, chunk_count, indexed_at). + """ + ... + def update(self) -> Dict[str, Any]: """ Incrementally update the memory index. diff --git a/agent_file_system/AGENT.md b/agent_file_system/AGENT.md index cdb3b9d5..12e24619 100644 --- a/agent_file_system/AGENT.md +++ b/agent_file_system/AGENT.md @@ -866,7 +866,7 @@ Editing any of these triggers re-indexing via [agent_core/core/impl/memory/memor - Purpose: complete chronological event log. Append-only. - Write access: EventStreamManager. Hard rule: DO NOT edit. - Read pattern: `read_file` / `grep_files` for self-troubleshooting. See `## Errors` for log workflow. -- Format: `[YYYY/MM/DD HH:MM:SS] [event_type]: payload`. Multi-line payloads continue on subsequent lines. +- Format: `[YYYY-MM-DD HH:MM:SS] [event_type]: payload`. Multi-line payloads continue on subsequent lines. - Auto-rotated when size threshold is exceeded. ### EVENT_UNPROCESSED.md @@ -3090,7 +3090,7 @@ This list is opinion, not authoritative. The user has the final say. Memory is your long-term recall. It is RAG-backed (relevance search over MEMORY.md and a few other files), not text-grep. Items reach MEMORY.md only after the daily memory-processing pipeline distills them from the event stream. You do NOT write MEMORY.md directly. Two ways memory reaches you: -- **Automatic injection (passive).** On every user message, the most relevant memories (top 5, relevance ≥ 0.5) are retrieved and dropped into your context as a `relevant_memories` event — one line per pointer: `- [file_path] section_path: summary (relevance: 0.XX)`. If nothing clears the threshold, no event is emitted. You do NOT need to call `memory_search` just to see what you already know. +- **Automatic injection (passive).** On every user message, the most relevant memories (top 5, relevance ≥ 0.5) are retrieved and dropped into your context as a `relevant_memories` event — one line per pointer: `- [file_path] section_path: summary (relevance: 0.XX)`. If nothing clears the threshold, no event is emitted. You do NOT need to call `memory_search` just to see what you already know. Each `summary` is a TRUNCATED preview (a pointer), not the full memory: it is a snippet centred on the words that matched your query, and a leading/trailing `...` marks text that was cut. Treat these as leads, not complete records — if a preview is on-topic but clipped where it matters, expand it with `memory_search` or by reading the source file before you rely on it. - **`memory_search` action (active).** Use it when you need to dig deeper on a specific question mid-run, beyond what got auto-injected. Code: [agent_core/core/impl/memory/manager.py](agent_core/core/impl/memory/manager.py) (`MemoryManager`), [agent_core/core/impl/memory/memory_file_watcher.py](agent_core/core/impl/memory/memory_file_watcher.py) (incremental re-indexing), [app/data/action/memory_search.py](app/data/action/memory_search.py) (action). diff --git a/agent_file_system/ENTITIES.md b/agent_file_system/ENTITIES.md new file mode 100644 index 00000000..45dc9837 --- /dev/null +++ b/agent_file_system/ENTITIES.md @@ -0,0 +1,13 @@ +# Entity Registry + +Agent DO NOT edit this file outside the entity-indexer skill. + +## Overview + +Entities the agent knows about, and the connection records between memories and entities. +Under ## Entities: one entity name per line — the graph's entire entity set, created by the entity-indexer skill. +Under ## Connections: one system-written record line per memory: [chunk-id] [pending|judged] names :: text preview. Name marks: plain = confirmed, ! = rejected, ? = awaiting the entity-indexer's judgment. + +## Entities + +## Connections diff --git a/app/agent_base.py b/app/agent_base.py index 8e8068b4..842d81a0 100644 --- a/app/agent_base.py +++ b/app/agent_base.py @@ -154,6 +154,7 @@ class TriggerData: TriggerSource.SCHEDULED_ONCE.value, TriggerSource.SCHEDULED_IMMEDIATE.value, TriggerSource.MEMORY.value, + TriggerSource.ENTITY_INDEX.value, TriggerSource.PROACTIVE_HEARTBEAT.value, TriggerSource.PROACTIVE_PLANNER.value, TriggerSource.ONBOARDING.value, @@ -189,6 +190,7 @@ class TriggerData: TriggerSource.SCHEDULED_ONCE.value: ("⏰", "Scheduled task"), TriggerSource.SCHEDULED_IMMEDIATE.value: ("⏰", "Scheduled task"), TriggerSource.MEMORY.value: ("⚙️", "Memory processing workflow"), + TriggerSource.ENTITY_INDEX.value: ("⚙️", "Entity indexing workflow"), TriggerSource.PROACTIVE_HEARTBEAT.value: ("⚙️", "Proactive check"), TriggerSource.PROACTIVE_PLANNER.value: ("⚙️", "Proactive planning"), TriggerSource.ONBOARDING.value: ("⚙️", "Onboarding workflow"), @@ -360,9 +362,15 @@ def __init__( self.session_manager.ensure_main() # ── memory manager for proactive agent ── + # extra_files_provider: user-selected files from the Memory panel + # (settings.json memory.indexed_files), read live on every index + # pass so panel changes apply without a restart. + from app.ui_layer.settings.memory_settings import get_memory_indexed_files + self.memory_manager = MemoryManager( agent_file_system_path=str(AGENT_FILE_SYSTEM_PATH), chroma_path=str(AGENT_MEMORY_CHROMA_PATH), + extra_files_provider=get_memory_indexed_files, ) # Connect memory manager to context engine for memory-aware prompts self.context_engine.set_memory_manager(self.memory_manager) @@ -577,6 +585,22 @@ async def react(self, trigger: Trigger) -> None: trigger.next_action_description = desc trigger.payload.update(workflow) self._update_aggregated_description(trigger, desc) + elif trigger.source == TriggerSource.ENTITY_INDEX.value: + prepared = self._prepare_entity_index_run() + if prepared is None: + if not is_aggregated_batch: + return + self._drop_aggregated_source(trigger, trigger.source) + else: + desc, workflow = prepared + if is_aggregated_batch: + trigger.next_action_description += ( + f"\n\nAlso part of this turn ({trigger.source}): {desc}" + ) + else: + trigger.next_action_description = desc + trigger.payload.update(workflow) + self._update_aggregated_description(trigger, desc) elif trigger.source in ( TriggerSource.PROACTIVE_HEARTBEAT.value, TriggerSource.PROACTIVE_PLANNER.value, @@ -687,23 +711,21 @@ def _prepare_memory_run(self) -> Optional[tuple[str, dict]]: return None unprocessed_file = AGENT_FILE_SYSTEM_PATH / "EVENT_UNPROCESSED.md" - if not unprocessed_file.exists(): - return None - try: - content = unprocessed_file.read_text(encoding="utf-8") - except Exception as e: - logger.warning(f"[MEMORY] Failed to read EVENT_UNPROCESSED.md: {e}") - return None - event_lines = [ - line - for line in content.strip().split("\n") - if line.strip() and line.strip().startswith("[") - ] - if not event_lines: - logger.info("[MEMORY] No unprocessed events to process") - return None + event_lines: list[str] = [] + if unprocessed_file.exists(): + try: + content = unprocessed_file.read_text(encoding="utf-8") + event_lines = [ + line + for line in content.strip().split("\n") + if line.strip() and line.strip().startswith("[") + ] + except Exception as e: + logger.warning(f"[MEMORY] Failed to read EVENT_UNPROCESSED.md: {e}") - # Decide whether the pruning phase should run alongside processing. + # Inspect MEMORY.md purely for the pruning need (item cap). Entity + # work is NOT the memory-processor's job — the entity-indexer owns + # all entity linkage and runs, chained, after this run ends. needs_pruning = False max_items = get_memory_max_items() memory_file = AGENT_FILE_SYSTEM_PATH / "MEMORY.md" @@ -715,17 +737,24 @@ def _prepare_memory_run(self) -> Optional[tuple[str, dict]]: if len(memory_items) >= max_items: needs_pruning = True except Exception as e: - logger.warning(f"[MEMORY] Failed to count MEMORY.md items: {e}") + logger.warning(f"[MEMORY] Failed to inspect MEMORY.md: {e}") + + if not event_lines and not needs_pruning: + logger.info("[MEMORY] No unprocessed events and no pruning needed") + return None # Freeze the unprocessed buffer so this run's own events don't loop # back into it. Reset when the run ends (_on_run_end). self.event_stream_manager.set_skip_unprocessed_logging(True) - instruction = ( - f"Process the {len(event_lines)} unprocessed event(s) in " - f"EVENT_UNPROCESSED.md into long-term memory. Follow the " - f"memory-processor skill instructions." - ) + parts = [] + if event_lines: + parts.append( + f"Process the {len(event_lines)} unprocessed event(s) in " + f"EVENT_UNPROCESSED.md into long-term memory." + ) + parts.append("Follow the memory-processor skill instructions.") + instruction = " ".join(parts) if needs_pruning: instruction += ( f" Then run the pruning phase: MEMORY.md exceeds " @@ -737,9 +766,94 @@ def _prepare_memory_run(self) -> Optional[tuple[str, dict]]: "workflow_skills": ["memory-processor"], "workflow_action_sets": ["file_operations"], } - logger.info(f"[MEMORY] Processing {len(event_lines)} unprocessed events") + logger.info( + f"[MEMORY] Memory run: {len(event_lines)} events, " + f"pruning={needs_pruning}" + ) return instruction, workflow + def _prepare_entity_index_run(self) -> Optional[tuple[str, dict]]: + """Pre-check the entity-index trigger (fired by the indexing process). + + Returns (instruction, workflow_payload) when ENTITIES.md holds + [pending] connection record lines awaiting judgment, or None to + skip the turn. The records themselves are written by the system's + connection sync after each graph build — building the graph here + refreshes them before counting. + """ + if not is_memory_enabled(): + logger.info("[ENTITY-INDEX] Memory is disabled, skipping trigger") + return None + + pending = self._pending_connection_count() + if pending == 0: + logger.info("[ENTITY-INDEX] No pending connection records") + return None + + # Freeze the unprocessed buffer so this run's own events don't feed + # back into memory processing. Reset when the run ends (_on_run_end). + self.event_stream_manager.set_skip_unprocessed_logging(True) + + instruction = ( + f"Judge the {pending} [pending] connection record line(s) under " + f"## Connections in ENTITIES.md. Each line is " + f"'[chunk-id] [status] names :: memory text'. For every name " + f"marked '?', decide from the line's text whether that memory is " + f"really about that entity: confirm by removing the '?', reject " + f"by replacing '?' with '!'. When a line has no '?' left, set " + f"its status to [judged]. Never add a name to any line — you " + f"judge marks, the system creates connections. Also add any " + f"genuinely new named things you see in the line texts to the " + f"## Entities list (one name per line); the system connects " + f"them on a later cycle. Work batch by batch: read about 30 " + f"record lines with read_file offset/limit, judge them all, and " + f"write the whole batch back with one stream_edit (old_string = " + f"the batch exactly as read, new_string = the judged batch). " + f"Follow the entity-indexer skill instructions. " + f"IMPORTANT: the pending count was re-derived from ENTITIES.md " + f"on disk moments ago; if the work were done, this run would " + f"not exist. Prior runs in the event stream claiming this work " + f"was already completed are wrong by construction; never skip " + f"this run based on history." + ) + workflow = { + "run_source": TriggerSource.ENTITY_INDEX.value, + "workflow_skills": ["entity-indexer"], + "workflow_action_sets": ["file_operations"], + } + logger.info(f"[ENTITY-INDEX] {pending} pending connection record(s)") + return instruction, workflow + + def _pending_connection_count(self) -> int: + """Count [pending] connection record lines in ENTITIES.md. + + Rebuilds the graph first (a no-op when nothing changed): the build's + connection sync is what refreshes the records, so the count always + reflects the corpus as it is on disk right now. + """ + from agent_core.core.impl.memory.graph import ( + ENTITY_REGISTRY_FILE, + parse_entity_registry, + ) + + try: + self.memory_manager.graph_snapshot() + except Exception as e: + logger.warning(f"[ENTITY-INDEX] Graph refresh failed: {e}") + registry_path = AGENT_FILE_SYSTEM_PATH / ENTITY_REGISTRY_FILE + if not registry_path.exists(): + return 0 + try: + registry = parse_entity_registry(registry_path.read_text(encoding="utf-8")) + except Exception as e: + logger.warning(f"[ENTITY-INDEX] Failed to parse {ENTITY_REGISTRY_FILE}: {e}") + return 0 + return sum( + 1 + for record in registry.get("connections", {}).values() + if record.get("status") == "pending" + ) + def _prepare_proactive_run(self, trigger: Trigger) -> Optional[tuple[str, dict]]: """Pre-check a proactive heartbeat/planner trigger. @@ -1358,11 +1472,33 @@ async def _on_run_end(self, session: Session, run_payload: dict) -> None: # Unload temporary workflow skills loaded at run start. self._remove_workflow_capabilities(session, run_payload) - # Memory runs freeze the unprocessed buffer — release it. - if run_source == TriggerSource.MEMORY.value: + # Memory and entity-index runs freeze the unprocessed buffer while + # they work — release it whichever of the two just ended. + if run_source in ( + TriggerSource.MEMORY.value, + TriggerSource.ENTITY_INDEX.value, + ): if hasattr(self.event_stream_manager, "set_skip_unprocessed_logging"): self.event_stream_manager.set_skip_unprocessed_logging(False) + # The entity indexer runs AFTER memory processing: a finished + # memory run chains the ENTITY_INDEX trigger. Its pre-check + # decides whether any indexed file actually needs extraction + # (no LLM cost when none is stale). Entity runs do NOT chain + # anything, so this can never loop. + if run_source == TriggerSource.MEMORY.value: + try: + await self.trigger_service.emit( + TriggerSpec( + source=TriggerSource.ENTITY_INDEX, + description="Extract entities for indexed files (after memory processing)", + priority=60, + session_id=MAIN_SESSION_ID, + ) + ) + except Exception as e: + logger.warning(f"[ENTITY-INDEX] Failed to chain trigger: {e}") + # Skill creation/improvement run finished — reload skills so the new # or edited skill is invocable immediately. skill_workflow = run_payload.get("skill_workflow") or {} diff --git a/app/data/action/memory_entity.py b/app/data/action/memory_entity.py new file mode 100644 index 00000000..b406ba90 --- /dev/null +++ b/app/data/action/memory_entity.py @@ -0,0 +1,116 @@ +from agent_core import action + +# Input schema for memory entity lookup +_INPUT_SCHEMA = { + "name": { + "type": "string", + "example": "Living UI", + "description": "The entity to look up (a person, project, tool, or concept). Case-insensitive.", + }, +} + +# Output schema for memory entity lookup +_OUTPUT_SCHEMA = { + "status": { + "type": "string", + "example": "ok", + "description": "Indicates the action completed successfully.", + }, + "found": { + "type": "boolean", + "example": True, + "description": "Whether the entity exists in the memory graph.", + }, + "entity": { + "type": "object", + "description": "Entity overview: name, mention_count, items (facts about it), related_entities, and files that mention it.", + "example": { + "entity": "Living UI", + "mention_count": 4, + "items": [ + { + "item_id": "m1a2b3c4d5e6", + "timestamp": "2026-08-11 03:00:00", + "category": "project", + "content": "Living UI projects are managed from the sidebar", + "superseded": False, + } + ], + "related_entities": [{"name": "CraftBot", "shared_items": 2}], + "files": ["AGENT.md"], + }, + }, +} + + +@action( + name="memory_entity", + description="Look up everything the agent's memory knows about one entity (a person, project, tool, or concept): all facts mentioning it, related entities, and indexed files that reference it. Use this instead of memory_search when the subject is a specific named thing.", + mode="ALL", + platforms=["linux", "windows", "darwin"], + action_sets=["core"], + input_schema=_INPUT_SCHEMA, + output_schema=_OUTPUT_SCHEMA, + test_payload={"name": "CraftBot", "simulated_mode": True}, +) +def memory_entity(input_data: dict) -> dict: + """ + Look up an entity in the agent's memory graph. + + Returns the entity's facts, related entities, and source files. + """ + simulated_mode = input_data.get("simulated_mode", False) + + if simulated_mode: + return { + "status": "ok", + "found": True, + "entity": { + "entity": "CraftBot", + "mention_count": 3, + "items": [ + { + "item_id": "m1a2b3c4d5e6", + "timestamp": "2026-08-11 03:00:00", + "category": "fact", + "content": "CraftBot is the local agent application", + "superseded": False, + } + ], + "related_entities": [{"name": "Living UI", "shared_items": 1}], + "files": ["AGENT.md"], + }, + } + + try: + from app.ui_layer.settings.memory_settings import is_memory_enabled + + if not is_memory_enabled(): + return { + "status": "ok", + "found": False, + "entity": None, + "message": "Memory is disabled", + } + + name = (input_data.get("name") or "").strip() + if not name: + return {"status": "error", "found": False, "error": "name is required"} + + from app.internal_action_interface import InternalActionInterface + + overview = InternalActionInterface.memory_entity(name) + if overview is None: + return { + "status": "ok", + "found": False, + "entity": None, + "message": f"No entity named '{name}' in memory. Try memory_search for a broader semantic lookup.", + } + + return {"status": "ok", "found": True, "entity": overview} + + except RuntimeError as e: + return {"status": "error", "found": False, "error": str(e)} + except Exception as e: + return {"status": "error", "found": False, "error": str(e)} diff --git a/app/data/action/memory_related.py b/app/data/action/memory_related.py new file mode 100644 index 00000000..0fbd1b87 --- /dev/null +++ b/app/data/action/memory_related.py @@ -0,0 +1,120 @@ +from agent_core import action + +# Input schema for memory relation lookup +_INPUT_SCHEMA = { + "name_a": { + "type": "string", + "example": "John", + "description": "First entity name. Case-insensitive.", + }, + "name_b": { + "type": "string", + "example": "Living UI", + "description": "Second entity name. Case-insensitive.", + }, +} + +# Output schema for memory relation lookup +_OUTPUT_SCHEMA = { + "status": { + "type": "string", + "example": "ok", + "description": "Indicates the action completed successfully.", + }, + "connected": { + "type": "boolean", + "example": True, + "description": "Whether the two entities are connected in the memory graph.", + }, + "path": { + "type": "array", + "description": "Node sequence connecting the two entities: alternating entities and the memory items / files that link them.", + "example": [ + {"kind": "entity", "id": "e:john", "label": "John"}, + { + "kind": "item", + "id": "i:m1a2b3c4d5e6", + "label": "John built the Living UI dashboard", + "category": "event", + "superseded": False, + }, + {"kind": "entity", "id": "e:living ui", "label": "Living UI"}, + ], + }, +} + + +@action( + name="memory_related", + description="Find how two entities (people, projects, tools, concepts) are connected in the agent's memory: returns the shortest chain of facts and files linking them. Use this to answer questions like 'what does X have to do with Y'.", + mode="ALL", + platforms=["linux", "windows", "darwin"], + action_sets=["core"], + input_schema=_INPUT_SCHEMA, + output_schema=_OUTPUT_SCHEMA, + test_payload={"name_a": "John", "name_b": "CraftBot", "simulated_mode": True}, +) +def memory_related(input_data: dict) -> dict: + """ + Find the shortest connection between two entities in memory. + """ + simulated_mode = input_data.get("simulated_mode", False) + + if simulated_mode: + return { + "status": "ok", + "connected": True, + "path": [ + {"kind": "entity", "id": "e:john", "label": "John"}, + { + "kind": "item", + "id": "i:m1a2b3c4d5e6", + "label": "John is testing CraftBot", + "category": "fact", + "superseded": False, + }, + {"kind": "entity", "id": "e:craftbot", "label": "CraftBot"}, + ], + } + + try: + from app.ui_layer.settings.memory_settings import is_memory_enabled + + if not is_memory_enabled(): + return { + "status": "ok", + "connected": False, + "path": [], + "message": "Memory is disabled", + } + + name_a = (input_data.get("name_a") or "").strip() + name_b = (input_data.get("name_b") or "").strip() + if not name_a or not name_b: + return { + "status": "error", + "connected": False, + "path": [], + "error": "name_a and name_b are required", + } + + from app.internal_action_interface import InternalActionInterface + + path = InternalActionInterface.memory_related(name_a, name_b) + if not path: + return { + "status": "ok", + "connected": False, + "path": [], + "message": ( + f"No connection between '{name_a}' and '{name_b}' in memory. " + "One of them may not exist as an entity — try memory_entity to check." + ), + } + + return {"status": "ok", "connected": True, "path": path} + + except RuntimeError as e: + return {"status": "error", "connected": False, "path": [], "error": str(e)} + except Exception as e: + return {"status": "error", "connected": False, "path": [], "error": str(e)} diff --git a/app/data/action/read_file.py b/app/data/action/read_file.py index 3d3d1709..190a3a81 100644 --- a/app/data/action/read_file.py +++ b/app/data/action/read_file.py @@ -3,7 +3,7 @@ @action( name="read_file", - description="Reads a file and returns its contents with line numbers. By default reads up to 2000 lines from the beginning. Use offset and limit parameters to read specific sections of large files. For searching within files, use grep_files instead.", + description="Reads a file and returns its contents with line numbers. PDF files are returned as their extracted text (text layer only, one '## Page N' heading per page). By default reads up to 2000 lines from the beginning. Use offset and limit parameters to read specific sections of large files. For searching within files, use grep_files instead.", mode="CLI", action_sets=["core"], input_schema={ @@ -143,8 +143,18 @@ def read_file(input_data: dict) -> dict: } try: - with open(file_path, "r", encoding=encoding, errors="replace") as f: - all_lines = f.readlines() + if file_path.lower().endswith(".pdf"): + # PDFs: extracted text layer only (images ignored) — the same + # preprocessing the memory indexer uses, so what this action + # returns matches what got indexed. + from pathlib import Path + + from agent_core.core.impl.memory.text_extract import extract_text + + all_lines = [line + "\n" for line in extract_text(Path(file_path)).splitlines()] + else: + with open(file_path, "r", encoding=encoding, errors="replace") as f: + all_lines = f.readlines() total_lines = len(all_lines) diff --git a/app/data/agent_file_system_template/AGENT.md b/app/data/agent_file_system_template/AGENT.md index 28675368..2ce18233 100644 --- a/app/data/agent_file_system_template/AGENT.md +++ b/app/data/agent_file_system_template/AGENT.md @@ -866,7 +866,7 @@ Editing any of these triggers re-indexing via [agent_core/core/impl/memory/memor - Purpose: complete chronological event log. Append-only. - Write access: EventStreamManager. Hard rule: DO NOT edit. - Read pattern: `read_file` / `grep_files` for self-troubleshooting. See `## Errors` for log workflow. -- Format: `[YYYY/MM/DD HH:MM:SS] [event_type]: payload`. Multi-line payloads continue on subsequent lines. +- Format: `[YYYY-MM-DD HH:MM:SS] [event_type]: payload`. Multi-line payloads continue on subsequent lines. - Auto-rotated when size threshold is exceeded. ### EVENT_UNPROCESSED.md @@ -3090,7 +3090,7 @@ This list is opinion, not authoritative. The user has the final say. Memory is your long-term recall. It is RAG-backed (relevance search over MEMORY.md and a few other files), not text-grep. Items reach MEMORY.md only after the daily memory-processing pipeline distills them from the event stream. You do NOT write MEMORY.md directly. Two ways memory reaches you: -- **Automatic injection (passive).** On every user message, the most relevant memories (top 5, relevance ≥ 0.5) are retrieved and dropped into your context as a `relevant_memories` event — one line per pointer: `- [file_path] section_path: summary (relevance: 0.XX)`. If nothing clears the threshold, no event is emitted. You do NOT need to call `memory_search` just to see what you already know. +- **Automatic injection (passive).** On every user message, the most relevant memories (top 5, relevance ≥ 0.5) are retrieved and dropped into your context as a `relevant_memories` event — one line per pointer: `- [file_path] section_path: summary (relevance: 0.XX)`. If nothing clears the threshold, no event is emitted. You do NOT need to call `memory_search` just to see what you already know. Each `summary` is a TRUNCATED preview (a pointer), not the full memory: it is a snippet centred on the words that matched your query, and a leading/trailing `...` marks text that was cut. Treat these as leads, not complete records — if a preview is on-topic but clipped where it matters, expand it with `memory_search` or by reading the source file before you rely on it. - **`memory_search` action (active).** Use it when you need to dig deeper on a specific question mid-run, beyond what got auto-injected. Code: [agent_core/core/impl/memory/manager.py](agent_core/core/impl/memory/manager.py) (`MemoryManager`), [agent_core/core/impl/memory/memory_file_watcher.py](agent_core/core/impl/memory/memory_file_watcher.py) (incremental re-indexing), [app/data/action/memory_search.py](app/data/action/memory_search.py) (action). diff --git a/app/data/agent_file_system_template/ENTITIES.md b/app/data/agent_file_system_template/ENTITIES.md new file mode 100644 index 00000000..45dc9837 --- /dev/null +++ b/app/data/agent_file_system_template/ENTITIES.md @@ -0,0 +1,13 @@ +# Entity Registry + +Agent DO NOT edit this file outside the entity-indexer skill. + +## Overview + +Entities the agent knows about, and the connection records between memories and entities. +Under ## Entities: one entity name per line — the graph's entire entity set, created by the entity-indexer skill. +Under ## Connections: one system-written record line per memory: [chunk-id] [pending|judged] names :: text preview. Name marks: plain = confirmed, ! = rejected, ? = awaiting the entity-indexer's judgment. + +## Entities + +## Connections diff --git a/app/data/agent_file_system_template/MEMORY.md b/app/data/agent_file_system_template/MEMORY.md index 96be4143..c73b2af0 100644 --- a/app/data/agent_file_system_template/MEMORY.md +++ b/app/data/agent_file_system_template/MEMORY.md @@ -4,8 +4,8 @@ Agent DO NOT edit this file. ## Overview -Agent memory system. This memory file stores memory that will be useful for the future. -DO NOT copy and paste events here: This memory file only stores distilled memory items. +Agent memory system storing distilled memory items only - never copy raw events here. +Format: [timestamp] [category] content {entities: Name1, Name2}; outdated facts end with {superseded} instead of being deleted. ## Memory diff --git a/app/internal_action_interface.py b/app/internal_action_interface.py index 92f1c28a..ada3fa08 100644 --- a/app/internal_action_interface.py +++ b/app/internal_action_interface.py @@ -290,6 +290,43 @@ def memory_search(cls, query: str, top_k: int = 5) -> List[Dict[str, Any]]: for ptr in pointers ] + @classmethod + def memory_entity(cls, name: str) -> Optional[Dict[str, Any]]: + """ + Everything the memory graph knows about one entity. + + Args: + name: Entity name (case-insensitive) + + Returns: + Dict with entity, mention_count, items, related_entities and + files, or None when the entity is unknown. + """ + if cls.memory_manager is None: + raise RuntimeError( + "InternalActionInterface not initialized with MemoryManager." + ) + return cls.memory_manager.entity_overview(name) + + @classmethod + def memory_related(cls, name_a: str, name_b: str) -> List[Dict[str, Any]]: + """ + Shortest connection between two entities in the memory graph. + + Args: + name_a: First entity name + name_b: Second entity name + + Returns: + Node sequence (entities, memory items, files) connecting the + two, or an empty list when no connection exists. + """ + if cls.memory_manager is None: + raise RuntimeError( + "InternalActionInterface not initialized with MemoryManager." + ) + return cls.memory_manager.related_path(name_a, name_b) + # ─────────────────────── GUI Actions ─────────────────────── @classmethod diff --git a/app/scheduler/manager.py b/app/scheduler/manager.py index c3127534..19fec74d 100644 --- a/app/scheduler/manager.py +++ b/app/scheduler/manager.py @@ -527,6 +527,22 @@ async def _schedule_loop(self, schedule_id: str) -> None: logger.info(f"[SCHEDULER] Loop exited for: {schedule_id}") + def _should_fire(self, schedule: ScheduledTask) -> bool: + """Whether firing this schedule should proceed. + + Gates the built-in memory-processing schedule by the event threshold: + the daily run fires only when enough unprocessed events have piled up + (or pruning is due), so quiet days skip instead of processing nothing + at the set time. Every other schedule always fires. + """ + if schedule.payload.get("type") == "memory_processing": + from app.ui_layer.settings.memory_settings import ( + memory_scheduled_run_due, + ) + + return memory_scheduled_run_due() + return True + async def _fire_schedule(self, schedule: ScheduledTask) -> None: """ Fire a scheduled task trigger into the MAIN session. @@ -537,6 +553,15 @@ async def _fire_schedule(self, schedule: ScheduledTask) -> None: ) return + # Emit-gate: a built-in workflow may have nothing to do on most fires + # (memory processing on an idle day). Skip firing entirely so no empty + # run or task ever surfaces; the loop still advances next_run. + if not self._should_fire(schedule): + logger.debug( + f"[SCHEDULER] {schedule.id} has no work pending, skipping fire" + ) + return + now = time.time() # Update runtime state diff --git a/app/triggers/sources.py b/app/triggers/sources.py index 6efecdf0..16ddbb0f 100644 --- a/app/triggers/sources.py +++ b/app/triggers/sources.py @@ -29,6 +29,11 @@ class TriggerSource(str, Enum): LIMIT_REACHED = "limit_reached" # Background workflows (all land in the main session) MEMORY = "memory" + # File indexing produced/refreshed indexed files whose entities need + # LLM extraction (entity-indexer skill). Emitted by the indexing + # process itself: startup index pass, file-watcher re-index, and + # panel indexed-files changes. + ENTITY_INDEX = "entity_index" PROACTIVE_HEARTBEAT = "proactive_heartbeat" PROACTIVE_PLANNER = "proactive_planner" ONBOARDING = "onboarding" diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py index 094e55c2..e8a91a6f 100644 --- a/app/ui_layer/adapters/browser_adapter.py +++ b/app/ui_layer/adapters/browser_adapter.py @@ -17,6 +17,11 @@ from aiohttp.client_exceptions import ClientConnectionResetError +from agent_core.core.impl.memory.tuning import ( + PROCESSING_THRESHOLD_DEFAULT, + SCHEDULE_HOUR_DEFAULT, + SCHEDULE_MINUTE_DEFAULT, +) from agent_core.utils.logger import logger from app.config import AGENT_WORKSPACE_ROOT, APP_DATA_PATH from app.ui_layer.adapters.base import InterfaceAdapter @@ -49,8 +54,16 @@ update_memory_item, remove_memory_item, reset_memory, + reset_entity_registry, clear_unprocessed_events, get_memory_stats, + get_memory_processing_threshold, + get_memory_processing_threshold_max, + set_memory_processing_threshold, + get_unprocessed_event_count, + memory_schedule_expression, + set_memory_indexed_files, + list_indexable_candidates, # Model settings get_available_providers, get_model_settings, @@ -1415,7 +1428,10 @@ async def _handle_ws_message(self, data: Dict[str, Any], ws=None) -> None: item_id = data.get("itemId", "") category = data.get("category") content = data.get("content") - await self._handle_memory_item_update(item_id, category, content) + superseded = data.get("superseded") + await self._handle_memory_item_update( + item_id, category, content, superseded + ) elif msg_type == "memory_item_remove": item_id = data.get("itemId", "") @@ -1424,12 +1440,29 @@ async def _handle_ws_message(self, data: Dict[str, Any], ws=None) -> None: elif msg_type == "memory_reset": await self._handle_memory_reset() + elif msg_type == "memory_stats_get": await self._handle_memory_stats_get() elif msg_type == "memory_process_trigger": await self._handle_memory_process_trigger() + elif msg_type == "memory_schedule_get": + await self._handle_memory_schedule_get() + + elif msg_type == "memory_schedule_set": + await self._handle_memory_schedule_set(data) + + elif msg_type == "memory_graph_get": + await self._handle_memory_graph_get() + + elif msg_type == "memory_indexed_files_get": + await self._handle_memory_indexed_files_get() + + elif msg_type == "memory_indexed_files_set": + paths = data.get("paths", []) + await self._handle_memory_indexed_files_set(paths) + # Model settings operations elif msg_type == "model_providers_get": await self._handle_model_providers_get() @@ -4139,6 +4172,7 @@ async def _handle_reset(self, data: dict | None = None) -> None: "skill_creation", "skill_improvement", "memory_processing", + "entity_index", } ) @@ -4150,6 +4184,7 @@ async def _handle_reset(self, data: dict | None = None) -> None: "craftbot-skill-creator", "craftbot-skill-improve", "memory-processor", + "entity-indexer", "heartbeat-processor", "user-profile-interview", "day-planner", @@ -4174,6 +4209,7 @@ async def _handle_reset(self, data: dict | None = None) -> None: "craftbot-skill-creator", "craftbot-skill-improve", "memory-processor", + "entity-indexer", "user-profile-interview", "heartbeat-processor", "day-planner", @@ -4934,10 +4970,19 @@ async def _handle_memory_item_add(self, category: str, content: str) -> None: ) async def _handle_memory_item_update( - self, item_id: str, category: str = None, content: str = None + self, + item_id: str, + category: str = None, + content: str = None, + superseded: bool = None, ) -> None: """Update an existing memory item.""" - result = update_memory_item(item_id=item_id, category=category, content=content) + result = update_memory_item( + item_id=item_id, + category=category, + content=content, + superseded=superseded, + ) if result.get("success"): # Update memory index after updating @@ -4998,17 +5043,23 @@ async def _handle_memory_item_remove(self, item_id: str) -> None: ) async def _handle_memory_reset(self) -> None: - """Reset memory by restoring MEMORY.md from template.""" + """Reset memory: restore MEMORY.md + ENTITIES.md from template, clear + unprocessed events, then FORCE-rebuild the index. + + Force rebuild (not incremental update) so every derived cache — the + ChromaDB chunks, the graph, and the entity embedding collection — is + reseeded from the reset markdown. An incremental update() would leave + stale chunks and entity vectors behind. + """ result = reset_memory() if result.get("success"): - # Also clear unprocessed events clear_unprocessed_events() + reset_entity_registry() - # Update memory index after reset agent = self._controller.agent if hasattr(agent, "memory_manager"): - agent.memory_manager.update() + agent.memory_manager.index_all(force=True) await self._broadcast( { @@ -5063,6 +5114,23 @@ async def _handle_memory_process_trigger(self) -> None: ) return + # Same emptiness condition as the MEMORY run pre-check + # (_prepare_memory_run): with nothing to process the trigger + # would be silently dropped there — surface that here instead. + from app.ui_layer.settings.memory_settings import memory_needs_pruning + + if get_unprocessed_event_count() == 0 and not memory_needs_pruning(): + await self._broadcast( + { + "type": "memory_process_trigger", + "data": { + "success": False, + "error": "No unprocessed events to process.", + }, + } + ) + return + # Queue a memory-processing run in the main session. The agent's # MEMORY pre-check decides whether there is actually work to do. from app.triggers import TriggerSource, TriggerSpec @@ -5095,6 +5163,169 @@ async def _handle_memory_process_trigger(self) -> None: } ) + async def _handle_memory_schedule_get(self) -> None: + """Send the auto-processing schedule + threshold to the panel.""" + try: + agent = self._controller.agent + task = agent.scheduler.get_schedule("memory-processing") + if task is None: + await self._broadcast( + { + "type": "memory_schedule_get", + "data": {"success": False, "error": "Schedule not found"}, + } + ) + return + + sched = task.schedule + await self._broadcast( + { + "type": "memory_schedule_get", + "data": { + "success": True, + "schedule": { + "hour": ( + sched.hour + if sched.hour is not None + else SCHEDULE_HOUR_DEFAULT + ), + "minute": sched.minute or SCHEDULE_MINUTE_DEFAULT, + }, + "threshold": get_memory_processing_threshold(), + "threshold_max": get_memory_processing_threshold_max(), + "unprocessed": get_unprocessed_event_count(), + }, + } + ) + except Exception as e: + await self._broadcast( + { + "type": "memory_schedule_get", + "data": {"success": False, "error": str(e)}, + } + ) + + async def _handle_memory_schedule_set(self, data: dict) -> None: + """Apply the daily auto-processing time + threshold from the panel. + + Auto-processing is daily by design; only the time of day and the + threshold are configurable. Applied live via update_schedule + (persists + reschedules next run). + """ + try: + agent = self._controller.agent + set_memory_processing_threshold( + int(data.get("threshold", PROCESSING_THRESHOLD_DEFAULT)) + ) + expr = memory_schedule_expression( + hour=int(data.get("hour", SCHEDULE_HOUR_DEFAULT)), + minute=int(data.get("minute", SCHEDULE_MINUTE_DEFAULT)), + ) + agent.scheduler.update_schedule( + "memory-processing", schedule=expr, enabled=True + ) + await self._broadcast( + {"type": "memory_schedule_set", "data": {"success": True}} + ) + except Exception as e: + await self._broadcast( + { + "type": "memory_schedule_set", + "data": {"success": False, "error": str(e)}, + } + ) + + async def _handle_memory_graph_get(self) -> None: + """Send the memory graph snapshot (nodes/edges/stats) to the panel.""" + try: + agent = self._controller.agent + snapshot = await asyncio.to_thread(agent.memory_manager.graph_snapshot) + + # Fold in pipeline stats the panel shows alongside the graph. + stats = snapshot.get("stats", {}) + memory_stats = get_memory_stats() + if memory_stats.get("success"): + stats["unprocessed_events"] = memory_stats.get("unprocessed_events", 0) + stats["memory_item_count"] = memory_stats.get("total_items", 0) + snapshot["stats"] = stats + + await self._broadcast( + { + "type": "memory_graph_get", + "data": {"success": True, "graph": snapshot}, + } + ) + except Exception as e: + await self._broadcast( + { + "type": "memory_graph_get", + "data": {"success": False, "error": str(e)}, + } + ) + + async def _handle_memory_indexed_files_get(self) -> None: + """Send the indexed-files list and addable candidates.""" + try: + agent = self._controller.agent + files = agent.memory_manager.get_index_files_info() + candidates_result = list_indexable_candidates() + await self._broadcast( + { + "type": "memory_indexed_files_get", + "data": { + "success": True, + "files": files, + "candidates": candidates_result.get("candidates", []), + }, + } + ) + except Exception as e: + await self._broadcast( + { + "type": "memory_indexed_files_get", + "data": {"success": False, "error": str(e)}, + } + ) + + async def _handle_memory_indexed_files_set(self, paths: list) -> None: + """Replace the extra indexed-files list and re-index.""" + try: + result = set_memory_indexed_files(paths) + if not result.get("success"): + await self._broadcast( + { + "type": "memory_indexed_files_set", + "data": { + "success": False, + "error": result.get("error", "Unknown error"), + }, + } + ) + return + + # Re-index so added files appear (and removed files drop out) + # immediately rather than waiting for the file watcher. + agent = self._controller.agent + await asyncio.to_thread(agent.memory_manager.update) + + await self._broadcast( + { + "type": "memory_indexed_files_set", + "data": { + "success": True, + "files": agent.memory_manager.get_index_files_info(), + "rejected": result.get("rejected", []), + }, + } + ) + except Exception as e: + await self._broadcast( + { + "type": "memory_indexed_files_set", + "data": {"success": False, "error": str(e)}, + } + ) + # ───────────────────────────────────────────────────────────────────── # Model Settings Handlers # ───────────────────────────────────────────────────────────────────── diff --git a/app/ui_layer/browser/frontend/src/App.tsx b/app/ui_layer/browser/frontend/src/App.tsx index e09ee5aa..e91c3cd4 100644 --- a/app/ui_layer/browser/frontend/src/App.tsx +++ b/app/ui_layer/browser/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { Routes, Route, Navigate, useParams } from 'react-router-dom' import { Layout } from './components/layout' import { ChatPage } from './pages/Chat' import { DashboardPage } from './pages/Dashboard' +import { MemoryPage } from './pages/Memory' import { ScreenPage } from './pages/Screen' import { WorkspacePage } from './pages/Workspace' import { SettingsPage } from './pages/Settings' @@ -80,6 +81,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/app/ui_layer/browser/frontend/src/components/layout/NavBar.tsx b/app/ui_layer/browser/frontend/src/components/layout/NavBar.tsx index ac335022..18dc3c34 100644 --- a/app/ui_layer/browser/frontend/src/components/layout/NavBar.tsx +++ b/app/ui_layer/browser/frontend/src/components/layout/NavBar.tsx @@ -7,6 +7,7 @@ import { LayoutDashboard, FolderOpen, Settings, + Waypoints, Box, Loader2, PanelLeftClose, @@ -85,6 +86,7 @@ function AnimatedSessionTitle({ title }: { title: string }) { const utilityNavItems: NavItem[] = [ { id: 'dashboard', label: 'Dashboard', icon: , path: '/dashboard' }, + { id: 'memory', label: 'Memory', icon: , path: '/memory' }, { id: 'workspace', label: 'Workspace', icon: , path: '/workspace' }, ] diff --git a/app/ui_layer/browser/frontend/src/pages/Memory/MemoryGraphCanvas.tsx b/app/ui_layer/browser/frontend/src/pages/Memory/MemoryGraphCanvas.tsx new file mode 100644 index 00000000..da194489 --- /dev/null +++ b/app/ui_layer/browser/frontend/src/pages/Memory/MemoryGraphCanvas.tsx @@ -0,0 +1,1194 @@ +import { useEffect, useRef } from 'react' +import type { MemoryGraph, MemoryGraphNode } from '../../store/slices/memorySettingsSlice' + +// Community palette — full-spectrum, ordered for MAX adjacent contrast, and +// tuned to the brand's intensity. Colour groups are numbered 0,1,2,… so the +// colours that must differ are CONSECUTIVE entries; stepping ~137.5° (the +// golden angle) around the hue wheel puts every next colour on the far side +// of the wheel, so no two consecutive groups look alike. Every colour is +// pushed to roughly the saturation/lightness of the brand orange #FF4F18 +// (~S100 / L55), so they read as equal-energy siblings of the logo rather +// than a mix of vivid and washed-out. Reads on both #191919 and #FFFFFF. +const COMMUNITY_COLORS = [ + '#FF3B3B', // red + '#16D65C', // green + '#A64BFF', // violet + '#FFC21A', // gold + '#12C9E0', // cyan + '#FF3D9E', // magenta-pink + '#93E01A', // lime + '#3D6BFF', // indigo-blue + '#FF7A1A', // orange + '#0DCBB6', // teal + '#B84DFF', // purple + '#FFDE1A', // yellow +] + +// ── Physics tuning ────────────────────────────────────────────────────── +// d3-style cooling: alpha decays toward ZERO and the simulation performs a +// hard stop (velocities zeroed) once it crosses ALPHA_MIN. There is no +// simmering floor — a floor keeps injecting force every frame and the +// layout jitters forever at the spring/repulsion equilibrium. +const FRICTION = 0.8 +const REPULSION = 2400 +const REPULSION_CUTOFF = 320 +// A node's repulsion scales with how connected it is: a well-connected hub +// pushes other nodes away harder, so its dense cloud of memories spreads out +// instead of piling on top of neighbouring hubs. Sub-linear (log of degree) +// so a 60-link hub is strongly repulsive without detonating the layout; +// degree-1 leaves keep the base repulsion (multiplier 1). +const HUB_REPULSION = 2.0 +// Extra clearance every pair of nodes keeps beyond their radii. +const NODE_CLEARANCE = 22 +// Hard no-overlap guarantee: pairs closer than radii + this gap are +// separated by direct position correction (not forces), so overlap cannot +// survive — not even in the frozen settled state. +const COLLIDE_GAP = 3 +const SPRING = 0.04 +const CENTER_GRAVITY = 0.01 +const CLUSTER_GRAVITY = 0.015 +const MAX_SPEED = 14 +const ALPHA_DECAY = 0.96 +const ALPHA_MIN = 0.004 +// Early stop: once cool-ish and essentially still, freeze immediately — +// waiting out the full decay leaves seconds of visible micro-drift. +const REST_ALPHA = 0.08 +const REST_SPEED = 0.06 +const ENTRANCE_MS = 550 +const BREATHE_RAMP_MS = 1500 +const FOLLOW_LERP = 0.08 + +// ── Radial fan-out for file chunks ────────────────────────────────────── +// A file's chunks fan out around it in ALL directions as a radial tree +// (reference: a center with branches radiating outward). Branch structure +// comes from the section hierarchy ("# A > ## B > ### C"): top-level +// sections sit on the inner ring, subsections chain outward, leaves reach +// the rim. Each branch owns its angular wedge, allocated by leaf count so +// branches never cross. Nothing is pinned: every chunk is simulated and +// pulled toward its radial slot by spring forces — fully elastic. Lines +// are straight parent→child segments along each branch. +const RADIAL_INNER = 72 // first ring's distance from the file +const RADIAL_RING_GAP = 58 // distance between depth rings +const RADIAL_PULL = 0.09 // spring strength toward the radial slot +// Minimum arc distance between neighbouring leaves on a fan's rim. Trees +// with more leaves than their natural rim can hold scale their rings up +// so slots never force chunks into each other. +const MIN_LEAF_SPACING = 13 + +interface SimNode { + data: MemoryGraphNode + x: number + y: number + vx: number + vy: number + radius: number + color: string + // Repulsion multiplier from this node's edge degree — hubs shove harder. + repel: number + // Celestial rendering: precomputed "r,g,b" strings so the per-frame + // rgba() concatenation stays allocation-cheap. + starHalo: string // desaturated community tint for the outer glow + starCore: string // near-white core with a hint of the tint (dark theme) + starInk: string // near-black core variant for light theme (ink chart) + birth: number + phase: number // idle-breathing + twinkle phase (render-only) +} + +interface TreeMember { + node: SimNode + // Radial slot as a fixed offset from the file's position. + ox: number + oy: number +} + +interface TreeLink { + a: SimNode // parent (file or another chunk) + b: SimNode // child chunk +} + +interface RadialTree { + center: SimNode + members: TreeMember[] + links: TreeLink[] +} + +interface SimEdge { + a: SimNode + b: SimNode + rest: number + // Flat file ↔ own-chunk edges: superseded by the radial-tree forces and + // the branch links, so both the spring pass and the edge renderer skip + // them. + rib: boolean +} + +interface MemoryGraphCanvasProps { + graph: MemoryGraph | null + selectedId: string | null + onSelect: (node: MemoryGraphNode | null) => void + /** Increment to trigger a zoom-to-fit. */ + fitNonce?: number + /** Increment to force a re-layout (reheat + camera follow), even when + * the refetched graph is topologically identical. */ + refreshNonce?: number + /** Render-only link visibility. Memory↔entity lines and memory↔file + * (radial branch) lines toggle independently; positions are unaffected. */ + showEntityLinks?: boolean + showFileLinks?: boolean +} + +function nodeRadius(node: MemoryGraphNode): number { + if (node.kind === 'entity') { + // Slightly bigger than a memory dot, growing slowly (log-scaled) with + // connections: 1 mention ≈ 5.4, 10 ≈ 7.7, 100 ≈ 10 (hard cap). + return 5.4 + Math.min(4.6, Math.log2((node.size || 1) + 1) * 1.4) + } + if (node.kind === 'file') return 7.5 + return 4.6 +} + +function nodeColor(node: MemoryGraphNode): string { + return COMMUNITY_COLORS[(node.community ?? 0) % COMMUNITY_COLORS.length] +} + +/** Mix a hex colour toward a target channel value by t∈[0,1] → "r,g,b". */ +function mixChannels(hex: string, target: number, t: number): string { + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + const mix = (c: number) => Math.round(c + (target - c) * t) + return `${mix(r)},${mix(g)},${mix(b)}` +} + +/** Star colour set for a community colour: subtle tint, not flat paint. */ +function starColors(hex: string): { halo: string; core: string; ink: string } { + return { + halo: mixChannels(hex, 255, 0.35), // gently desaturated glow + core: mixChannels(hex, 255, 0.82), // near-white, a whisper of hue + ink: mixChannels(hex, 24, 0.6), // near-black for light theme + } +} + +interface SkySpeck { + x: number + y: number + r: number + a: number + phase: number +} + +/** Resolve a CSS variable on the canvas element (theme-aware colors). */ +function cssVar(el: HTMLElement, name: string, fallback: string): string { + const value = getComputedStyle(el).getPropertyValue(name).trim() + return value || fallback +} + +/** Cheap topology signature — reheating is skipped when it is unchanged. */ +function graphSignature(graph: MemoryGraph | null): string { + if (!graph) return '' + let sig = `${graph.nodes.length}:${graph.edges.length}` + for (const n of graph.nodes) sig += `|${n.id}` + return sig +} + +/** + * Force-directed memory graph on a raw canvas. + * + * All simulation state lives in refs and one rAF loop — React renders the + * element once. When `graph` changes, existing nodes keep their positions; + * new nodes spawn next to an already-placed neighbour (or on their + * community's ring) and ease in. A refetch with identical topology does + * NOT reheat the layout, so panel actions don't shake the graph. While the + * initial layout settles, the camera smoothly follows a zoom-to-fit frame + * until the user takes over (pan/zoom/drag). After settling the physics + * pass stops dead — only a gentle render-side breathing remains. + */ +export function MemoryGraphCanvas({ graph, selectedId, onSelect, fitNonce = 0, refreshNonce = 0, showEntityLinks = true, showFileLinks = true }: MemoryGraphCanvasProps) { + const canvasRef = useRef(null) + const nodesRef = useRef>(new Map()) + const treesRef = useRef([]) + const specksRef = useRef([]) + const edgesRef = useRef([]) + const alphaRef = useRef(1) + const settledAtRef = useRef(null) + const signatureRef = useRef('') + const transformRef = useRef({ x: 0, y: 0, k: 1 }) + const followRef = useRef(false) + const fitRequestRef = useRef(false) + const lastFitNonce = useRef(fitNonce) + const hoverRef = useRef(null) + const selectedRef = useRef(null) + const dragRef = useRef<{ node: SimNode | null; panning: boolean; lastX: number; lastY: number; moved: boolean }>({ + node: null, panning: false, lastX: 0, lastY: 0, moved: false, + }) + const onSelectRef = useRef(onSelect) + onSelectRef.current = onSelect + + // Link-visibility flags read inside the rAF loop (which closes over refs, + // not props), so live toggles take effect without restarting the loop. + const showEntityLinksRef = useRef(showEntityLinks) + showEntityLinksRef.current = showEntityLinks + const showFileLinksRef = useRef(showFileLinks) + showFileLinksRef.current = showFileLinks + + selectedRef.current = selectedId + + if (fitNonce !== lastFitNonce.current) { + lastFitNonce.current = fitNonce + fitRequestRef.current = true + } + + // Manual refresh: the user explicitly asked for a re-layout, so bypass + // the identical-topology guard — reheat and let the camera follow. + const lastRefreshNonce = useRef(refreshNonce) + if (refreshNonce !== lastRefreshNonce.current) { + lastRefreshNonce.current = refreshNonce + alphaRef.current = 1 + settledAtRef.current = null + followRef.current = true + } + + // ── Graph → simulation sync ──────────────────────────────────────────── + useEffect(() => { + const nodes = nodesRef.current + const now = performance.now() + const incoming = new Set() + + const canvas = canvasRef.current + const width = canvas?.clientWidth || 800 + const height = canvas?.clientHeight || 600 + const cx = width / 2 + const cy = height / 2 + + // Degree = how connected each node is; drives the hub-repulsion boost. + const degreeOf = new Map() + for (const e of graph?.edges || []) { + degreeOf.set(e.source, (degreeOf.get(e.source) || 0) + 1) + degreeOf.set(e.target, (degreeOf.get(e.target) || 0) + 1) + } + const repelOf = (id: string) => + 1 + HUB_REPULSION * Math.log2(Math.max(1, degreeOf.get(id) || 1)) + + const fresh: MemoryGraphNode[] = [] + for (const data of graph?.nodes || []) { + incoming.add(data.id) + const existing = nodes.get(data.id) + if (existing) { + existing.data = data + existing.radius = nodeRadius(data) + existing.repel = repelOf(data.id) + existing.color = nodeColor(data) + const star = starColors(existing.color) + existing.starHalo = star.halo + existing.starCore = star.core + existing.starInk = star.ink + } else { + fresh.push(data) + } + } + for (const id of Array.from(nodes.keys())) { + if (!incoming.has(id)) nodes.delete(id) + } + + // Adjacency of the incoming graph, used to seed new nodes next to an + // already-placed neighbour so clusters assemble instead of untangling. + const adjacency = new Map() + for (const e of graph?.edges || []) { + let a = adjacency.get(e.source) + if (!a) adjacency.set(e.source, a = []) + a.push(e.target) + let b = adjacency.get(e.target) + if (!b) adjacency.set(e.target, b = []) + b.push(e.source) + } + + // Entities first (items/files then attach next to them). Each community + // gets its own angular sector on a ring, so clusters start separated. + fresh.sort((a, b) => (a.kind === 'entity' ? 0 : 1) - (b.kind === 'entity' ? 0 : 1)) + const ringRadius = Math.min(width, height) * 0.28 + for (const data of fresh) { + let x: number | undefined + let y: number | undefined + for (const nb of adjacency.get(data.id) || []) { + const placed = nodes.get(nb) + if (placed) { + x = placed.x + (Math.random() - 0.5) * 26 + y = placed.y + (Math.random() - 0.5) * 26 + break + } + } + if (x === undefined || y === undefined) { + const angle = ((data.community ?? 0) * 2.399963) + (Math.random() - 0.5) * 0.8 + const dist = ringRadius * (0.55 + Math.random() * 0.65) + x = cx + Math.cos(angle) * dist + y = cy + Math.sin(angle) * dist + } + const color = nodeColor(data) + const star = starColors(color) + nodes.set(data.id, { + data, + x, y, + vx: 0, vy: 0, + radius: nodeRadius(data), + repel: repelOf(data.id), + color, + starHalo: star.halo, + starCore: star.core, + starInk: star.ink, + birth: now, + phase: Math.random() * Math.PI * 2, + }) + } + + // ── Build radial trees: each file's chunks fan out around it ── + // Branch structure from the section hierarchy; angular wedges are + // allocated by leaf count (classic radial tidy-tree), so every branch + // owns a slice and branches never cross. + const trees: RadialTree[] = [] + const memberIds = new Set() + // Any item that names its file joins that file's fan — file chunks AND + // conversation memories (MEMORY.md behaves like every other file). + const chunksByFile = new Map() + for (const data of graph?.nodes || []) { + if (data.kind === 'item' && data.file) { + const sim = nodes.get(data.id) + if (!sim) continue + let list = chunksByFile.get(data.file) + if (!list) chunksByFile.set(data.file, list = []) + list.push(sim) + } + } + + const stripPart = (s: string) => s.replace(/ \(part \d+\)$/, '') + const partNum = (s: string) => { + const m = s.match(/ \(part (\d+)\)$/) + return m ? parseInt(m[1], 10) : 1 + } + + for (const data of graph?.nodes || []) { + if (data.kind !== 'file') continue + const center = nodes.get(data.id) + if (!center) continue + const chunkList = chunksByFile.get(data.label) || [] + if (chunkList.length === 0) continue + + // Group split sections ("(part n)") under one base path; parts chain + // outward so long sections become a branch, not siblings. Items + // without a section (conversation memories) get a unique base so + // they become independent leaves off the file, never a chain. + const bySection = new Map() + for (const chunk of chunkList) { + const base = stripPart(chunk.data.section || '') || `#${chunk.data.id}` + let list = bySection.get(base) + if (!list) bySection.set(base, list = []) + list.push(chunk) + } + for (const list of bySection.values()) { + list.sort((a, b) => partNum(a.data.section || '') - partNum(b.data.section || '')) + } + + // Parent resolution: part n hangs off part n-1; a section's first + // part hangs off its nearest ancestor section that has a chunk, or + // the file itself. + const parentOf = new Map() + for (const [base, parts] of bySection) { + for (let i = 1; i < parts.length; i++) parentOf.set(parts[i], parts[i - 1]) + const segs = base.split(' > ') + let parent: SimNode | null = null + for (let cut = segs.length - 1; cut >= 1; cut--) { + const ancestor = bySection.get(segs.slice(0, cut).join(' > ')) + if (ancestor && ancestor.length > 0) { parent = ancestor[0]; break } + } + parentOf.set(parts[0], parent) + } + + // Children lists (null key = the file root), stable order. + const children = new Map() + for (const chunk of chunkList) { + const parent = parentOf.get(chunk) ?? null + let list = children.get(parent) + if (!list) children.set(parent, list = []) + list.push(chunk) + } + for (const list of children.values()) { + list.sort((a, b) => (a.data.section || '').localeCompare(b.data.section || '')) + } + + // Radial tidy-tree: DFS assigns each leaf a sequential angular slot; + // an inner node sits at the mean angle of its subtree. Depth maps to + // ring radius. + let leafTotal = 0 + const countLeaves = (parent: SimNode | null): number => { + const kids = children.get(parent) || [] + if (kids.length === 0) return 1 + let sum = 0 + for (const k of kids) sum += countLeaves(k) + return sum + } + for (const k of children.get(null) || []) leafTotal += countLeaves(k) + if (leafTotal === 0) continue + + const members: TreeMember[] = [] + const links: TreeLink[] = [] + let slot = 0 + let maxDepth = 1 + const layout = (chunk: SimNode, parent: SimNode | null, depth: number): number => { + if (depth > maxDepth) maxDepth = depth + const kids = children.get(chunk) || [] + let angle: number + if (kids.length === 0) { + angle = ((slot + 0.5) / leafTotal) * Math.PI * 2 + slot += 1 + } else { + let sum = 0 + for (const k of kids) sum += layout(k, chunk, depth + 1) + angle = sum / kids.length + } + const r = RADIAL_INNER + (depth - 1) * RADIAL_RING_GAP + members.push({ node: chunk, ox: Math.cos(angle) * r, oy: Math.sin(angle) * r }) + links.push({ a: parent ?? center, b: chunk }) + memberIds.add(chunk.data.id) + return angle + } + for (const k of children.get(null) || []) layout(k, null, 1) + + // Leafy trees widen: if the rim can't give every leaf its minimum + // arc spacing, scale all rings up proportionally so slots never + // force chunks into each other. + const naturalRim = RADIAL_INNER + (maxDepth - 1) * RADIAL_RING_GAP + const requiredRim = (leafTotal * MIN_LEAF_SPACING) / (Math.PI * 2) + const scale = Math.max(1, requiredRim / naturalRim) + if (scale > 1) { + for (const m of members) { + m.ox *= scale + m.oy *= scale + } + } + + trees.push({ center, members, links }) + } + treesRef.current = trees + + edgesRef.current = (graph?.edges || []) + .map(e => { + const a = nodes.get(e.source) + const b = nodes.get(e.target) + if (!a || !b) return null + const rib = + (a.data.kind === 'file' && memberIds.has(b.data.id) && b.data.file === a.data.label) || + (b.data.kind === 'file' && memberIds.has(a.data.id) && a.data.file === b.data.label) + const rest = a.data.kind === 'file' || b.data.kind === 'file' ? 115 : 70 + return { a, b, rest, rib } + }) + .filter((e): e is SimEdge => e !== null) + + // Reheat ONLY when the topology actually changed. Refetches after panel + // actions (edit, process, refresh) usually return the identical graph — + // shaking the layout for those reads as instability. + const signature = graphSignature(graph) + if (signature !== signatureRef.current) { + signatureRef.current = signature + alphaRef.current = 1 + settledAtRef.current = null + if ((graph?.nodes?.length ?? 0) > 0) followRef.current = true + + // Background starfield: tiny static specks scattered across (and a + // little beyond) the content area, so panning feels like drifting + // through space. Regenerated only on topology change. + const all = Array.from(nodes.values()) + if (all.length > 0) { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity + for (const n of all) { + minX = Math.min(minX, n.x); maxX = Math.max(maxX, n.x) + minY = Math.min(minY, n.y); maxY = Math.max(maxY, n.y) + } + const padX = (maxX - minX) * 0.5 + 300 + const padY = (maxY - minY) * 0.5 + 300 + const specks: SkySpeck[] = [] + for (let i = 0; i < 220; i++) { + specks.push({ + x: minX - padX + Math.random() * (maxX - minX + padX * 2), + y: minY - padY + Math.random() * (maxY - minY + padY * 2), + r: 0.4 + Math.random() * 0.8, + a: 0.06 + Math.random() * 0.22, + phase: Math.random() * Math.PI * 2, + }) + } + specksRef.current = specks + } else { + specksRef.current = [] + } + } + }, [graph]) + + // ── Simulation + render loop ─────────────────────────────────────────── + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + + let raf = 0 + let disposed = false + + const resize = () => { + const dpr = Math.min(2, window.devicePixelRatio || 1) + const { clientWidth, clientHeight } = canvas + if (canvas.width !== clientWidth * dpr || canvas.height !== clientHeight * dpr) { + canvas.width = clientWidth * dpr + canvas.height = clientHeight * dpr + } + } + const ro = new ResizeObserver(resize) + ro.observe(canvas) + resize() + + // Transform that frames all nodes with padding, or null when empty. + const fitTransform = (): { x: number; y: number; k: number } | null => { + const nodes = Array.from(nodesRef.current.values()) + if (nodes.length === 0) return null + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity + for (const n of nodes) { + minX = Math.min(minX, n.x - n.radius) + minY = Math.min(minY, n.y - n.radius) + maxX = Math.max(maxX, n.x + n.radius) + maxY = Math.max(maxY, n.y + n.radius) + } + const width = canvas.clientWidth + const height = canvas.clientHeight + const pad = 56 + const spanX = Math.max(60, maxX - minX) + const spanY = Math.max(60, maxY - minY) + const k = Math.min(1.5, Math.max(0.3, + Math.min((width - pad * 2) / spanX, (height - pad * 2) / spanY))) + return { + k, + x: width / 2 - ((minX + maxX) / 2) * k, + y: height / 2 - ((minY + maxY) / 2) * k, + } + } + + // Spatial-hash repulsion: nodes only interact within their 3×3 cell + // neighbourhood (cell size = cutoff), keeping the pass near-linear. + // The close-range boost is a CONTINUOUS ramp — a stepped multiplier + // makes nodes at the threshold flip between force regimes every frame + // and oscillate visibly. + const applyRepulsion = (nodes: SimNode[], alpha: number) => { + const cell = REPULSION_CUTOFF + const grid = new Map() + const hash = (gx: number, gy: number) => (gx * 73856093) ^ (gy * 19349663) + for (const n of nodes) { + const key = hash(Math.floor(n.x / cell), Math.floor(n.y / cell)) + let bucket = grid.get(key) + if (!bucket) grid.set(key, bucket = []) + bucket.push(n) + } + const cutoff2 = REPULSION_CUTOFF * REPULSION_CUTOFF + for (const n1 of nodes) { + const gx0 = Math.floor(n1.x / cell) + const gy0 = Math.floor(n1.y / cell) + for (let gx = gx0 - 1; gx <= gx0 + 1; gx++) { + for (let gy = gy0 - 1; gy <= gy0 + 1; gy++) { + const bucket = grid.get(hash(gx, gy)) + if (!bucket) continue + for (const n2 of bucket) { + if (n2 === n1) continue + let dx = n1.x - n2.x + let dy = n1.y - n2.y + let d2 = dx * dx + dy * dy + if (d2 > cutoff2) continue + if (d2 < 1) { dx = Math.random() - 0.5; dy = Math.random() - 0.5; d2 = 1 } + const d = Math.sqrt(d2) + const sizeBoost = 1 + (n1.radius + n2.radius) * 0.04 + const minDist = n1.radius + n2.radius + NODE_CLEARANCE + const overlapRamp = d < minDist ? 1 + 2 * (minDist - d) / minDist : 1 + // n2 repels n1; scale by n2's connectedness so hubs shove hardest. + const force = (REPULSION * sizeBoost * overlapRamp * n2.repel / d2) * alpha + n1.vx += (dx / d) * force + n1.vy += (dy / d) * force + } + } + } + } + } + + // Position-based collision resolution (the game way): overlapping + // pairs are separated directly, half the overlap each, iterated a few + // times over a spatial grid. Because it edits positions rather than + // applying forces, it keeps working when the simulation is nearly + // cold — overlap physically cannot survive into the settled state. + const collidePass = (nodes: SimNode[], iterations: number) => { + const cell = 80 + const hash = (gx: number, gy: number) => (gx * 73856093) ^ (gy * 19349663) + const dragged = () => dragRef.current.node + for (let it = 0; it < iterations; it++) { + const grid = new Map() + for (const n of nodes) { + const key = hash(Math.floor(n.x / cell), Math.floor(n.y / cell)) + let bucket = grid.get(key) + if (!bucket) grid.set(key, bucket = []) + bucket.push(n) + } + for (const n1 of nodes) { + const gx0 = Math.floor(n1.x / cell) + const gy0 = Math.floor(n1.y / cell) + for (let gx = gx0 - 1; gx <= gx0 + 1; gx++) { + for (let gy = gy0 - 1; gy <= gy0 + 1; gy++) { + const bucket = grid.get(hash(gx, gy)) + if (!bucket) continue + for (const n2 of bucket) { + if (n1.data.id >= n2.data.id) continue // each pair once + let dx = n2.x - n1.x + let dy = n2.y - n1.y + let d2 = dx * dx + dy * dy + const minDist = n1.radius + n2.radius + COLLIDE_GAP + if (d2 >= minDist * minDist) continue + if (d2 < 0.01) { dx = Math.random() - 0.5; dy = Math.random() - 0.5; d2 = dx * dx + dy * dy } + const d = Math.sqrt(d2) + // Soft positional push (full-strength corrections fight the + // springs and read as jitter)... + const push = (minDist - d) * 0.3 + const nx = dx / d + const ny = dy / d + const drag = dragged() + if (n1 !== drag) { n1.x -= nx * push; n1.y -= ny * push } + if (n2 !== drag) { n2.x += nx * push; n2.y += ny * push } + // ...plus an INELASTIC contact: cancel the pair's + // approaching momentum and apply contact friction. Without + // this, preserved velocity makes touching nodes slide + // tangentially and orbit each other forever. + const rvx = n2.vx - n1.vx + const rvy = n2.vy - n1.vy + const vn = rvx * nx + rvy * ny + if (vn < 0) { + const half = vn / 2 + n1.vx += nx * half; n1.vy += ny * half + n2.vx -= nx * half; n2.vy -= ny * half + } + n1.vx *= 0.7; n1.vy *= 0.7 + n2.vx *= 0.7; n2.vy *= 0.7 + } + } + } + } + } + } + + const tickPhysics = (nodes: SimNode[], width: number, height: number, time: number) => { + const alpha = alphaRef.current + applyRepulsion(nodes, alpha) + + for (const e of edgesRef.current) { + // Rib edges (file ↔ its own chunk) are handled by the org-grid + // forces below. + if (e.rib) continue + const dx = e.b.x - e.a.x + const dy = e.b.y - e.a.y + const d = Math.sqrt(dx * dx + dy * dy) || 1 + const force = (d - e.rest) * SPRING * alpha + const fx = (dx / d) * force + const fy = (dy / d) * force + e.a.vx += fx; e.a.vy += fy + e.b.vx -= fx; e.b.vy -= fy + } + + // Radial-tree forces: every chunk is pulled toward its slot on its + // depth ring around the file — a spring to a moving target, not a + // pin, so the whole fan stays elastic (drag a chunk and it snaps + // back; drag the file and its fan follows with a springy lag). + for (const tree of treesRef.current) { + const fx0 = tree.center.x + const fy0 = tree.center.y + for (const m of tree.members) { + const targetX = fx0 + m.ox + const targetY = fy0 + m.oy + m.node.vx += (targetX - m.node.x) * RADIAL_PULL * alpha + m.node.vy += (targetY - m.node.y) * RADIAL_PULL * alpha + // Equal and opposite reaction on the file keeps the pair honest + // (dragging a chunk tugs its file a little — elastic, organic). + tree.center.vx -= (targetX - m.node.x) * RADIAL_PULL * alpha * 0.04 + tree.center.vy -= (targetY - m.node.y) * RADIAL_PULL * alpha * 0.04 + } + } + + // Community centroids pull members together; the global centre pull + // keeps disconnected clusters from drifting apart forever. + const centroids = new Map() + for (const n of nodes) { + const c = n.data.community ?? 0 + const acc = centroids.get(c) + if (acc) { acc.x += n.x; acc.y += n.y; acc.n += 1 } + else centroids.set(c, { x: n.x, y: n.y, n: 1 }) + } + const cx = width / 2 + const cy = height / 2 + let maxSpeed = 0 + for (const n of nodes) { + const acc = centroids.get(n.data.community ?? 0) + if (acc && acc.n > 1) { + n.vx += (acc.x / acc.n - n.x) * CLUSTER_GRAVITY * alpha + n.vy += (acc.y / acc.n - n.y) * CLUSTER_GRAVITY * alpha + } + n.vx += (cx - n.x) * CENTER_GRAVITY * alpha + n.vy += (cy - n.y) * CENTER_GRAVITY * alpha + + if (dragRef.current.node === n) { n.vx = 0; n.vy = 0; continue } + n.vx *= FRICTION + n.vy *= FRICTION + const speed = Math.sqrt(n.vx * n.vx + n.vy * n.vy) + if (speed > MAX_SPEED) { + n.vx = (n.vx / speed) * MAX_SPEED + n.vy = (n.vy / speed) * MAX_SPEED + } + n.x += n.vx + n.y += n.vy + if (speed > maxSpeed) maxSpeed = speed + } + + // Hard separation every tick — springs can overpower repulsion for + // heavily shared neighbours, so soft forces alone let big hubs sink + // into each other. + collidePass(nodes, 2) + + // Cool toward zero — and freeze EARLY once the layout is basically + // still; letting the decay run its full tail leaves seconds of + // visible micro-drift. A final heavier collide pass guarantees the + // frozen layout is clean; velocities are zeroed so no momentum + // leaks into the next wake-up. + alphaRef.current = alpha * ALPHA_DECAY + const atRest = alphaRef.current < ALPHA_MIN || + (alphaRef.current < REST_ALPHA && maxSpeed < REST_SPEED) + if (atRest) { + alphaRef.current = 0 + settledAtRef.current = time + collidePass(nodes, 5) + for (const n of nodes) { n.vx = 0; n.vy = 0 } + } + } + + const step = (time: number) => { + if (disposed) return + const nodes = Array.from(nodesRef.current.values()) + const width = canvas.clientWidth + const height = canvas.clientHeight + + const settling = alphaRef.current > 0 + if (nodes.length > 0 && settling) { + tickPhysics(nodes, width, height, time) + } + + // Camera: explicit fit request snaps; during the initial settle the + // camera glides after the fit frame until the user takes over. + if (fitRequestRef.current) { + fitRequestRef.current = false + followRef.current = false + const fit = fitTransform() + if (fit) transformRef.current = fit + } else if (followRef.current) { + const fit = fitTransform() + if (fit) { + const t = transformRef.current + t.x += (fit.x - t.x) * FOLLOW_LERP + t.y += (fit.y - t.y) * FOLLOW_LERP + t.k += (fit.k - t.k) * FOLLOW_LERP + } + if (!settling) followRef.current = false + } + + // ── render ── + const dpr = Math.min(2, window.devicePixelRatio || 1) + const t = transformRef.current + const hovered = hoverRef.current + const selected = selectedRef.current + + const bg = cssVar(canvas, '--bg-primary', '#191919') + const textColor = cssVar(canvas, '--text-secondary', '#9a9a9a') + const isDark = parseInt(bg.slice(1, 3) || 'ff', 16) < 128 + + const focusId = hovered?.data.id ?? selected + let neighbours: Set | null = null + if (focusId) { + neighbours = new Set([focusId]) + for (const e of edgesRef.current) { + if (e.a.data.id === focusId) neighbours.add(e.b.data.id) + if (e.b.data.id === focusId) neighbours.add(e.a.data.id) + } + } + + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.fillStyle = bg + ctx.fillRect(0, 0, width, height) + ctx.translate(t.x, t.y) + ctx.scale(t.k, t.k) + + // Visible world-rect for culling (with slack for labels). + const viewMinX = (-t.x) / t.k - 80 + const viewMinY = (-t.y) / t.k - 80 + const viewMaxX = (width - t.x) / t.k + 80 + const viewMaxY = (height - t.y) / t.k + 80 + const inView = (x: number, y: number) => + x >= viewMinX && x <= viewMaxX && y >= viewMinY && y <= viewMaxY + + // Idle breathing (render-only; the physics positions stay frozen). + // Amplitude ramps in after settling so the freeze→drift transition + // is seamless instead of popping. + const settledAt = settledAtRef.current + const amp = settledAt !== null + ? Math.min(1, (time - settledAt) / BREATHE_RAMP_MS) * 1.2 + : 0 + const breathe = (n: SimNode) => ({ + x: n.x + Math.sin(time / 1600 + n.phase) * amp, + y: n.y + Math.cos(time / 1900 + n.phase) * amp, + }) + + // ── Background starfield: distant static specks, faint slow twinkle ── + if (isDark) { + for (const s of specksRef.current) { + if (!inView(s.x, s.y)) continue + const tw = 0.7 + 0.3 * Math.sin(time / 1400 + s.phase) + ctx.fillStyle = `rgba(240,224,205,${s.a * tw})` + ctx.beginPath() + ctx.arc(s.x, s.y, s.r / Math.max(0.7, t.k), 0, Math.PI * 2) + ctx.fill() + } + } + + // ── Branch lines: straight parent→child segments along each branch ── + // Replaces the flat file→chunk ribs. Each chunk connects to its + // parent in the section hierarchy (top-level sections connect to the + // file), so the tree fans outward without a starburst core. + ctx.lineWidth = 1 / t.k + if (showFileLinksRef.current) for (const tree of treesRef.current) { + const focusInTree = + focusId !== null && focusId !== undefined && + (tree.center.data.id === focusId || + tree.members.some(m => m.node.data.id === focusId)) + const dimmedTree = neighbours !== null && !focusInTree + const alpha = dimmedTree ? 0.04 : 0.12 + // Constellation lines: cool silver, barely there. + ctx.strokeStyle = isDark + ? `rgba(236,220,205,${alpha})` + : `rgba(74,58,48,${alpha})` + for (const link of tree.links) { + if (!inView(link.a.x, link.a.y) && !inView(link.b.x, link.b.y)) continue + const pa = breathe(link.a) + const pb = breathe(link.b) + ctx.beginPath() + ctx.moveTo(pa.x, pa.y) + ctx.lineTo(pb.x, pb.y) + ctx.stroke() + } + } + + // Edges (entity links; ribs are drawn as org connectors above). + if (showEntityLinksRef.current) for (const e of edgesRef.current) { + if (e.rib) continue + if (!inView(e.a.x, e.a.y) && !inView(e.b.x, e.b.y)) continue + const pa = breathe(e.a) + const pb = breathe(e.b) + const inFocus = !neighbours || + (neighbours.has(e.a.data.id) && neighbours.has(e.b.data.id) && + (e.a.data.id === focusId || e.b.data.id === focusId)) + ctx.strokeStyle = isDark + ? `rgba(236,220,205,${inFocus ? 0.28 : 0.05})` + : `rgba(74,58,48,${inFocus ? 0.22 : 0.04})` + ctx.beginPath() + ctx.moveTo(pa.x, pa.y) + ctx.lineTo(pb.x, pb.y) + ctx.stroke() + } + + // ── Nodes: solid, workspace-friendly ── + // entity — solid core + thin detached ring, sized by mention count + // memory — smaller solid dot + // file — short filled document glyph (FileText icon) + // No gradients: flat colour reads cleanly on dark AND light themes. + for (const n of nodes) { + if (!inView(n.x, n.y)) continue + const p = breathe(n) + const age = Math.min(1, (time - n.birth) / ENTRANCE_MS) + const entrance = 1 - Math.pow(1 - age, 3) + const dimmed = neighbours ? !neighbours.has(n.data.id) : false + const faded = n.data.superseded === true + const isSelected = n.data.id === selected + + let vis = entrance * (dimmed ? 0.12 : faded ? 0.35 : 1) + if (vis <= 0.01) continue + + const kind = n.data.kind + const r = n.radius * entrance + + // Selected: radar-ping like the sidebar session dot (1.4s loop). + // Rings are born at the core and ONLY travel outward, fading as + // they go; the node's opacity dips smoothly in sync. + if (isSelected) { + const phase = ((time % 1400) / 1400) // 0→1, then reset + const eased = 1 - Math.pow(1 - phase, 2) // ease-out travel + vis *= 1 - 0.35 * Math.sin(phase * Math.PI) // smooth dip + const ringR = r * 0.4 + eased * (r + 14) + ctx.strokeStyle = n.color + ctx.globalAlpha = 0.55 * (1 - eased) + ctx.lineWidth = 1.6 / t.k + ctx.beginPath() + ctx.arc(p.x, p.y, ringR, 0, Math.PI * 2) + ctx.stroke() + ctx.globalAlpha = 1 + ctx.lineWidth = 1 / t.k + } + + ctx.globalAlpha = vis + if (kind === 'file') { + // Files render as a FILLED document glyph so they read as files, + // not as another coloured node. Portrait page with rounded corners + // and a folded top-right corner; text lines carved in the + // background colour. Geometry uses the full radius (not the + // entrance-scaled r) in a 24×24 local space. + const sc = (n.radius * 3.0) / 24 + const L = 6.5, R = 17.5, T = 4, B = 20 // page bounds (portrait) + const RAD = 1.8 // corner radius + const FOLD = 4.5 // dog-ear size + const FX = R - FOLD, FY = T + FOLD // fold start / diagonal end + ctx.save() + ctx.translate(p.x, p.y) + ctx.scale(sc, sc) + ctx.translate(-12, -12) + ctx.lineJoin = 'round' + ctx.lineCap = 'round' + // Page body: top edge → fold diagonal → rounded right/bottom/left. + ctx.fillStyle = n.color + ctx.beginPath() + ctx.moveTo(L + RAD, T) + ctx.lineTo(FX, T) + ctx.lineTo(R, FY) + ctx.arcTo(R, B, R - RAD, B, RAD) + ctx.arcTo(L, B, L, B - RAD, RAD) + ctx.arcTo(L, T, L + RAD, T, RAD) + ctx.closePath() + ctx.fill() + // Folded corner: a darker flap so it reads as turned-down paper. + ctx.fillStyle = 'rgba(0,0,0,0.22)' + ctx.beginPath() + ctx.moveTo(FX, T) + ctx.lineTo(FX, FY) + ctx.lineTo(R, FY) + ctx.closePath() + ctx.fill() + // Two text lines carved in the background colour. + ctx.strokeStyle = bg + ctx.lineWidth = 1.1 / (t.k * sc) + ctx.beginPath() + ctx.moveTo(9, 13); ctx.lineTo(15, 13) + ctx.moveTo(9, 16); ctx.lineTo(15, 16) + ctx.stroke() + ctx.restore() + ctx.lineWidth = 1 / t.k + } else if (kind === 'entity') { + // Entities: solid core + thin detached ring (the old file design). + ctx.fillStyle = n.color + ctx.beginPath() + ctx.arc(p.x, p.y, r * 0.62, 0, Math.PI * 2) + ctx.fill() + ctx.strokeStyle = n.color + ctx.lineWidth = 1.2 / t.k + ctx.beginPath() + ctx.arc(p.x, p.y, r, 0, Math.PI * 2) + ctx.stroke() + ctx.lineWidth = 1 / t.k + } else { + // Memories: plain filled dot. + ctx.fillStyle = n.color + ctx.beginPath() + ctx.arc(p.x, p.y, r, 0, Math.PI * 2) + ctx.fill() + } + ctx.globalAlpha = 1 + } + + // ── Labels: greedy declutter in screen space ── + const fontPx = 11 + ctx.font = `${fontPx / t.k}px ui-sans-serif, system-ui, sans-serif` + ctx.textAlign = 'center' + const drawn: Array<{ x: number; y: number; w: number; h: number }> = [] + const overlaps = (x: number, y: number, w: number, h: number) => + drawn.some(rct => + Math.abs(x - rct.x) < (w + rct.w) / 2 + 4 && Math.abs(y - rct.y) < (h + rct.h) / 2 + 2) + + // Entities and files are ALWAYS label candidates — the greedy + // declutter below decides what actually fits at the current zoom + // (larger nodes win). Items are full sentences, so they only reveal + // on hover/selection or when zoomed in close. + const candidates: SimNode[] = [] + for (const n of nodes) { + if (!inView(n.x, n.y)) continue + const isFocus = n.data.id === focusId || n.data.id === selected + if (isFocus) { candidates.push(n); continue } + if (neighbours && !neighbours.has(n.data.id)) continue + if (n.data.kind === 'entity' || n.data.kind === 'file') candidates.push(n) + else if (t.k >= 1.6) candidates.push(n) + } + candidates.sort((a, b) => { + const fa = a.data.id === focusId || a.data.id === selected ? 1 : 0 + const fb = b.data.id === focusId || b.data.id === selected ? 1 : 0 + if (fa !== fb) return fb - fa + return b.radius - a.radius + }) + + const haloColor = isDark ? 'rgba(15,15,15,0.75)' : 'rgba(255,255,255,0.8)' + for (const n of candidates) { + const isFocus = n.data.id === focusId || n.data.id === selected + const p = breathe(n) + let label = n.data.label + if (n.data.kind === 'item') { + label = label.length > 48 ? `${label.slice(0, 48)}…` : label + } else if (label.length > 28 && !isFocus) { + label = `${label.slice(0, 28)}…` + } + const w = ctx.measureText(label).width + const sx = p.x * t.k + t.x + const sy = (p.y - n.radius - 5 / t.k) * t.k + t.y + if (!isFocus && overlaps(sx, sy, w * t.k, fontPx + 2)) continue + drawn.push({ x: sx, y: sy, w: w * t.k, h: fontPx + 2 }) + + ctx.globalAlpha = isFocus ? 1 : 0.85 + // Text halo so labels stay legible over edges and nodes. + ctx.lineWidth = 3 / t.k + ctx.strokeStyle = haloColor + ctx.lineJoin = 'round' + ctx.strokeText(label, p.x, p.y - n.radius - 5 / t.k) + ctx.fillStyle = isFocus + ? cssVar(canvas, '--text-primary', '#eaeaea') + : textColor + ctx.fillText(label, p.x, p.y - n.radius - 5 / t.k) + ctx.globalAlpha = 1 + } + ctx.lineWidth = 1 / t.k + + raf = requestAnimationFrame(step) + } + raf = requestAnimationFrame(step) + + return () => { + disposed = true + cancelAnimationFrame(raf) + ro.disconnect() + } + }, []) + + // ── Interaction ──────────────────────────────────────────────────────── + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + + const toWorld = (clientX: number, clientY: number) => { + const rect = canvas.getBoundingClientRect() + const t = transformRef.current + return { + x: (clientX - rect.left - t.x) / t.k, + y: (clientY - rect.top - t.y) / t.k, + } + } + + const hitTest = (wx: number, wy: number): SimNode | null => { + let best: SimNode | null = null + let bestD = Infinity + for (const n of nodesRef.current.values()) { + const dx = n.x - wx + const dy = n.y - wy + const d = Math.sqrt(dx * dx + dy * dy) + const hitR = Math.max(n.radius + 4, 8) + if (d < hitR && d < bestD) { best = n; bestD = d } + } + return best + } + + const wake = (heat: number) => { + alphaRef.current = Math.max(alphaRef.current, heat) + settledAtRef.current = null + } + + const onPointerDown = (e: PointerEvent) => { + canvas.setPointerCapture(e.pointerId) + followRef.current = false // user takes the camera + const w = toWorld(e.clientX, e.clientY) + const node = hitTest(w.x, w.y) + dragRef.current = { + node, + panning: !node, + lastX: e.clientX, + lastY: e.clientY, + moved: false, + } + } + + const onPointerMove = (e: PointerEvent) => { + const drag = dragRef.current + const w = toWorld(e.clientX, e.clientY) + + if (drag.node) { + drag.node.x = w.x + drag.node.y = w.y + drag.node.vx = 0 + drag.node.vy = 0 + wake(0.3) + drag.moved = true + return + } + if (drag.panning) { + const t = transformRef.current + t.x += e.clientX - drag.lastX + t.y += e.clientY - drag.lastY + drag.lastX = e.clientX + drag.lastY = e.clientY + if (Math.abs(e.movementX) + Math.abs(e.movementY) > 1) drag.moved = true + return + } + const hovered = hitTest(w.x, w.y) + hoverRef.current = hovered + canvas.style.cursor = hovered ? 'pointer' : 'grab' + } + + const onPointerUp = (e: PointerEvent) => { + const drag = dragRef.current + if (!drag.moved) { + const w = toWorld(e.clientX, e.clientY) + const node = hitTest(w.x, w.y) + onSelectRef.current(node ? node.data : null) + } + dragRef.current = { node: null, panning: false, lastX: 0, lastY: 0, moved: false } + } + + const onWheel = (e: WheelEvent) => { + e.preventDefault() + followRef.current = false // user takes the camera + const rect = canvas.getBoundingClientRect() + const t = transformRef.current + const factor = Math.exp(-e.deltaY * 0.0012) + const k = Math.min(4, Math.max(0.2, t.k * factor)) + const px = e.clientX - rect.left + const py = e.clientY - rect.top + // Zoom towards the cursor. + t.x = px - ((px - t.x) / t.k) * k + t.y = py - ((py - t.y) / t.k) * k + t.k = k + } + + const onLeave = () => { + hoverRef.current = null + } + + canvas.addEventListener('pointerdown', onPointerDown) + canvas.addEventListener('pointermove', onPointerMove) + canvas.addEventListener('pointerup', onPointerUp) + canvas.addEventListener('wheel', onWheel, { passive: false }) + canvas.addEventListener('pointerleave', onLeave) + return () => { + canvas.removeEventListener('pointerdown', onPointerDown) + canvas.removeEventListener('pointermove', onPointerMove) + canvas.removeEventListener('pointerup', onPointerUp) + canvas.removeEventListener('wheel', onWheel) + canvas.removeEventListener('pointerleave', onLeave) + } + }, []) + + return +} diff --git a/app/ui_layer/browser/frontend/src/pages/Memory/MemoryPage.module.css b/app/ui_layer/browser/frontend/src/pages/Memory/MemoryPage.module.css new file mode 100644 index 00000000..f71d0ec3 --- /dev/null +++ b/app/ui_layer/browser/frontend/src/pages/Memory/MemoryPage.module.css @@ -0,0 +1,758 @@ +/* MemoryPage — graph canvas left, control panel right. */ + +.page { + height: 100%; + display: flex; + overflow: hidden; +} + +/* ── Graph area ── */ + +.graphArea { + flex: 1; + min-width: 0; + position: relative; + background: var(--bg-primary); +} + +.graphHeader { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 2; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 18px; + pointer-events: none; +} + +.graphHeader > * { + pointer-events: auto; +} + +.titleGroup { + display: flex; + align-items: center; + gap: 8px; + color: var(--text-primary); +} + +.titleGroup h2 { + margin: 0; + font-size: var(--text-lg); + font-weight: 600; +} + +.statChips { + display: flex; + align-items: center; + gap: 6px; +} + +.statChip { + font-size: 11px; + color: var(--text-secondary); + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 6px; + padding: 3px 10px; + white-space: nowrap; +} + +/* Stat chips that double as show/hide toggles for the graph. */ +.statChipToggle { + cursor: pointer; + user-select: none; + transition: color 0.12s ease, border-color 0.12s ease, opacity 0.12s ease; +} + +.statChipToggle:hover { + color: var(--text-primary); + border-color: var(--border-hover); +} + +/* Hidden category: dimmed and struck through so the "off" state is obvious. */ +.statChipOff { + opacity: 0.45; + text-decoration: line-through; + color: var(--text-muted); +} + +.emptyOverlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--text-muted); + text-align: center; + pointer-events: none; + padding: 24px; +} + +.emptyOverlay p { + margin: 0; + font-size: var(--text-base); + color: var(--text-secondary); + font-weight: 500; +} + +.emptyOverlay span { + font-size: var(--text-sm); + max-width: 340px; + line-height: 1.5; +} + +/* ── Right panel ── */ + +.panel { + width: var(--panel-w, 340px); + flex-shrink: 0; + display: flex; + flex-direction: column; + border-left: 1px solid var(--border-primary); + background: var(--bg-secondary); + overflow: hidden; +} + +/* ── Selection detail card ── */ + +@keyframes detailIn { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +.neighbourChip { + font-size: 11px; + color: var(--text-secondary); + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 6px; + padding: 2px 8px; + cursor: pointer; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + transition: color 0.12s ease, border-color 0.12s ease; +} + +.neighbourChip:hover { + color: var(--text-primary); + border-color: var(--border-hover); +} + +/* ── Memory tab ── */ + +.toolbar { + display: flex; + gap: 6px; + flex-shrink: 0; +} + +.searchBox { + flex: 1; + display: flex; + align-items: center; + gap: 6px; + background: var(--bg-primary); + border: 1px solid var(--border-primary); + border-radius: 8px; + padding: 0 8px; + color: var(--text-muted); +} + +.searchBox input { + flex: 1; + min-width: 0; + background: none; + border: none; + outline: none; + color: var(--text-primary); + font-size: var(--text-sm); + padding: 7px 0; +} + +.itemList { + display: flex; + flex-direction: column; +} + +.itemRow { + padding: 8px 6px; + border-radius: 6px; + border-bottom: 1px solid var(--border-primary); + cursor: pointer; + transition: background 0.12s ease; +} + +.itemRow:hover { + background: var(--bg-primary); +} + +.itemRow:last-child { + border-bottom: none; +} + +.itemSuperseded { + opacity: 0.55; +} + +.itemTop { + display: flex; + align-items: center; + gap: 6px; +} + +.supersededTag { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 10px; + color: var(--text-muted); +} + +.itemTime { + font-size: 10px; + color: var(--text-muted); + margin-left: auto; +} + +.itemContent { + margin-top: 4px; + font-size: var(--text-sm); + color: var(--text-primary); + line-height: 1.45; + word-break: break-word; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.emptyList { + font-size: var(--text-sm); + color: var(--text-muted); + padding: 16px 4px; + text-align: center; +} + +/* ── Files tab ── */ + +.sectionLabel { + display: flex; + align-items: center; + gap: 4px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + margin-top: 6px; + flex-shrink: 0; +} + +/* Clipping variant for labels carrying long file paths — kept separate + because overflow:hidden on the base class clips the info tooltip. */ +.sectionLabelClip { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +/* ── Info tab ── */ + +.hint { + font-size: 11px; + color: var(--text-muted); + line-height: 1.45; +} + +/* ── Shared bits ── */ + +.iconButton { + display: inline-flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 4px; + border-radius: 6px; + transition: color 0.12s ease, background 0.12s ease; +} + +.iconButton:hover { + color: var(--text-primary); + background: var(--bg-tertiary); +} + +/* ── Modal ── */ + +.modalOverlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.modal { + width: min(440px, calc(100vw - 32px)); + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 12px; + overflow: hidden; + animation: detailIn 0.16s ease; +} + +.modalHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px 0; +} + +.modalHeader h3 { + margin: 0; + font-size: var(--text-base); +} + +.modalBody { + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.modalFooter { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 0 16px 14px; +} + +.fieldLabel { + font-size: 11px; + color: var(--text-secondary); + margin-top: 6px; +} + +.select, +.textarea { + width: 100%; + background: var(--bg-primary); + border: 1px solid var(--border-primary); + border-radius: 8px; + color: var(--text-primary); + font-size: var(--text-sm); + padding: 8px 10px; + outline: none; + font-family: inherit; +} + +.textarea { + resize: vertical; +} + +.select:focus, +.textarea:focus { + border-color: var(--border-hover); +} + +.checkboxRow { + display: flex; + align-items: center; + gap: 8px; + font-size: var(--text-sm); + color: var(--text-secondary); + margin-top: 8px; + cursor: pointer; +} + +/* Narrow screens: stack the panel under the graph. */ +@media (max-width: 860px) { + .page { + flex-direction: column; + } + + .graphArea { + min-height: 46%; + } + + .panel { + width: 100%; + flex: 1; + border-left: none; + border-top: 1px solid var(--border-primary); + } +} + +/* ── Split panes ── */ + +.topPane { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.bottomPane { + flex: 2; + min-height: 0; + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; + border-top: 1px solid var(--border-primary); +} + +/* ── Resize handle (same pattern as the Living UI chat panel) ── */ + +.resizeHandle { + position: relative; + width: 1px; + background: var(--border-primary); + cursor: col-resize; + flex-shrink: 0; + touch-action: none; +} + +.resizeHandle::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: -2px; + width: 5px; + background: transparent; + transition: background var(--transition-fast); +} + +.resizeHandle:hover::after, +.resizeHandle.resizing::after { + background: var(--color-gray-300); +} + +.resizeOverlay { + position: fixed; + inset: 0; + z-index: 9999; + cursor: col-resize; + background: transparent; +} + +@media (max-width: 860px) { + .resizeHandle { + display: none; + } +} + +/* Idle top pane: no inventory list, just guidance. */ +.paneEmpty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--text-muted); + text-align: center; + font-size: var(--text-sm); + line-height: 1.5; + padding: 12px; +} + +.paneEmpty span { + max-width: 240px; +} + +/* ── Selected-node detail (top pane) ── */ + +.detail { + display: flex; + flex-direction: column; + gap: 10px; + min-height: 100%; + /* Breathing room at the scroll end — the last section must not sit + flush against the pane edge. */ + padding-bottom: 14px; +} + +.detailTop { + display: flex; + align-items: center; + gap: 7px; +} + +.kindDot, +.chipDot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} + +.chipDot { + width: 5px; + height: 5px; +} + +/* Kind legend dots — three high-contrast hues from the node palette, so the + * sidebar reads as part of the same graph. */ +.kind_entity { background: #ff3b3b; } /* red */ +.kind_item { background: #16d65c; } /* green */ +.kind_file { background: #a64bff; } /* violet */ + +.kindLabel { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); +} + +.detailClose { + margin-left: auto; +} + +/* Entity / file names read as titles… */ +.detailName { + font-size: var(--text-base); + font-weight: 600; + color: var(--text-primary); + line-height: 1.4; + word-break: break-word; +} + +/* …memories read as prose. */ +.detailBody { + font-size: var(--text-sm); + color: var(--text-primary); + line-height: 1.55; + word-break: break-word; +} + +.metaList { + display: flex; + flex-direction: column; + gap: 5px; +} + +.metaRow { + display: flex; + align-items: center; + gap: 7px; + font-size: 11px; + color: var(--text-muted); + min-width: 0; +} + +.metaRow svg { + flex-shrink: 0; +} + +.metaRow span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.detailSection { + display: flex; + flex-direction: column; + gap: 6px; + /* Bottom padding lives INSIDE the section: scroll-container padding is + unreliably honoured at the scroll end, in-flow padding always is. */ + padding: 10px 0 16px; + border-top: 1px solid var(--border-primary); + flex: 1 0 auto; +} + +.chipsScroll { + display: flex; + flex-wrap: wrap; + gap: 4px; + align-content: flex-start; +} + +.neighbourChip { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.detailFooter { + display: flex; + gap: 8px; + margin-top: auto; + padding-top: 10px; + border-top: 1px solid var(--border-primary); + flex-shrink: 0; +} + +/* Small-caps kind/category label inside list rows. */ +.itemKind { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +/* Flat rectangular tag — the panel's no-pill replacement for badges. */ +.tag { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 2px 6px; + border-radius: 4px; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + color: var(--text-secondary); + white-space: nowrap; +} + +.tagWarning { + color: var(--color-warning); + border-color: var(--color-warning-light); + background: var(--color-warning-bg); +} + +/* ── Agent file-system tree ── */ + +.tree { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow-y: auto; +} + +.treeRow { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 4px 6px; + border: none; + background: none; + border-radius: 6px; + font-size: var(--text-sm); + color: var(--text-primary); + text-align: left; + cursor: pointer; + transition: background 0.12s ease; + min-width: 0; +} + +.treeRow:hover { + background: var(--bg-primary); +} + +.treeChevron { + flex-shrink: 0; + color: var(--text-muted); + transition: transform 0.15s ease; +} + +.treeChevronOpen { + transform: rotate(90deg); +} + +.treeIcon { + flex-shrink: 0; + color: var(--text-muted); +} + +.treeName { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.treeMeta { + font-size: 10px; + color: var(--text-muted); + flex-shrink: 0; +} + +.treeIndexed { + color: var(--color-success); +} + +.treeSpinner { + color: var(--text-muted); + animation: treeSpin 0.9s linear infinite; +} + +@keyframes treeSpin { + to { transform: rotate(360deg); } +} + +/* ── Section-label info tooltip (NavBar mainInfo pattern) ── */ + +.infoTip { + position: relative; + display: inline-flex; + align-items: center; + color: var(--text-muted); + cursor: help; +} + +.infoTooltip { + display: none; + position: absolute; + top: calc(100% + 4px); + left: -8px; + width: 250px; + z-index: var(--z-tooltip, 50); + padding: 8px 10px; + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); + font-size: var(--text-xs); + font-weight: 400; + line-height: 1.5; + color: var(--text-secondary); + text-align: left; + text-transform: none; + letter-spacing: normal; + white-space: normal; + cursor: default; +} + +.infoTip:hover .infoTooltip { + display: block; +} + +.infoTooltip strong { + display: block; + margin-bottom: 3px; + color: var(--text-primary); + font-weight: 600; +} + +.iconButtonActive { + color: var(--text-primary); + background: var(--bg-tertiary); +} diff --git a/app/ui_layer/browser/frontend/src/pages/Memory/MemoryPage.tsx b/app/ui_layer/browser/frontend/src/pages/Memory/MemoryPage.tsx new file mode 100644 index 00000000..f7d3ba23 --- /dev/null +++ b/app/ui_layer/browser/frontend/src/pages/Memory/MemoryPage.tsx @@ -0,0 +1,907 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { + Waypoints, + RefreshCw, + Maximize2, + Search, + Plus, + Pencil, + Trash2, + Lock, + FileText, + X, + Archive, + Tag, + Clock, + Link2, + Hash, + Layers, + ChevronRight, + Folder, + Check, + Loader2, + Info, + EyeOff, +} from 'lucide-react' +import { Button, ConfirmModal } from '../../components/ui' +import { useToast } from '../../contexts/ToastContext' +import { useConfirmModal } from '../../hooks' +import { useSettingsWebSocket } from '../Settings/useSettingsWebSocket' +import { useAppSelector } from '../../store/hooks' +import { + selectMemoryEnabled, + selectMemoryItems, + selectMemoryGraph, + selectMemoryIndexedFiles, + selectMemoryIndexCandidates, +} from '../../store/selectors/memorySettings' +import type { + MemoryItem, + MemoryGraphNode, +} from '../../store/slices/memorySettingsSlice' +import { MemoryGraphCanvas } from './MemoryGraphCanvas' +import styles from './MemoryPage.module.css' + +const CATEGORY_OPTIONS = [ + 'fact', 'preference', 'event', 'decision', 'learning', 'project', 'contact', +] + +// Sidebar width bounds (resizable like the Living UI chat panel). +const PANEL_MIN_WIDTH = 280 +const PANEL_MAX_WIDTH = 600 + +// ── Item add/edit modal ────────────────────────────────────────────────── + +interface ItemFormModalProps { + item: MemoryItem | null + onClose: () => void + onSave: (data: { category: string; content: string; superseded: boolean }) => void +} + +function ItemFormModal({ item, onClose, onSave }: ItemFormModalProps) { + const [category, setCategory] = useState(item?.category || 'fact') + const [content, setContent] = useState(item?.content || '') + const [superseded, setSuperseded] = useState(item?.superseded || false) + + const submit = (e: React.FormEvent) => { + e.preventDefault() + if (!content.trim()) return + onSave({ category, content: content.trim(), superseded }) + } + + return ( +
+
e.stopPropagation()}> +
+

{item ? 'Edit memory' : 'Add memory'}

+ +
+
+
+ + + + +