From 57acb8c4f49b478e0c77b965850071883bb9e0f6 Mon Sep 17 00:00:00 2001 From: CraftBot Date: Thu, 13 Aug 2026 18:22:05 +0900 Subject: [PATCH 1/9] introduce graph into the memory system --- .../core/impl/memory/entity_extractor.py | 21 + agent_core/core/impl/memory/graph.py | 836 ++++++++++++ agent_core/core/impl/memory/manager.py | 438 ++++++- .../core/impl/memory/memory_file_watcher.py | 35 +- agent_core/core/impl/memory/text_extract.py | 47 + agent_core/core/prompts/context.py | 7 +- agent_core/core/protocols/memory.py | 30 + agent_file_system/ENTITIES.md | 104 ++ app/agent_base.py | 233 +++- app/data/action/memory_entity.py | 116 ++ app/data/action/memory_related.py | 120 ++ app/data/action/read_file.py | 16 +- .../agent_file_system_template/ENTITIES.md | 11 + app/data/agent_file_system_template/MEMORY.md | 4 +- app/internal_action_interface.py | 37 + app/triggers/sources.py | 5 + app/ui_layer/adapters/browser_adapter.py | 124 +- app/ui_layer/browser/frontend/src/App.tsx | 2 + .../frontend/src/components/layout/NavBar.tsx | 2 + .../src/pages/Memory/MemoryGraphCanvas.tsx | 1149 +++++++++++++++++ .../src/pages/Memory/MemoryPage.module.css | 788 +++++++++++ .../frontend/src/pages/Memory/MemoryPage.tsx | 932 +++++++++++++ .../frontend/src/pages/Memory/index.ts | 1 + .../browser/frontend/src/pages/index.ts | 1 + .../src/store/selectors/memorySettings.ts | 5 + .../src/store/slices/memorySettingsSlice.ts | 149 ++- app/ui_layer/settings/__init__.py | 7 + app/ui_layer/settings/memory_settings.py | 283 +++- skills/entity-indexer/SKILL.md | 158 +++ skills/memory-processor/SKILL.md | 40 +- 30 files changed, 5538 insertions(+), 163 deletions(-) create mode 100644 agent_core/core/impl/memory/graph.py create mode 100644 agent_core/core/impl/memory/text_extract.py create mode 100644 agent_file_system/ENTITIES.md create mode 100644 app/data/action/memory_entity.py create mode 100644 app/data/action/memory_related.py create mode 100644 app/data/agent_file_system_template/ENTITIES.md create mode 100644 app/ui_layer/browser/frontend/src/pages/Memory/MemoryGraphCanvas.tsx create mode 100644 app/ui_layer/browser/frontend/src/pages/Memory/MemoryPage.module.css create mode 100644 app/ui_layer/browser/frontend/src/pages/Memory/MemoryPage.tsx create mode 100644 app/ui_layer/browser/frontend/src/pages/Memory/index.ts create mode 100644 skills/entity-indexer/SKILL.md diff --git a/agent_core/core/impl/memory/entity_extractor.py b/agent_core/core/impl/memory/entity_extractor.py index 282d9b69..fcbba5ca 100644 --- a/agent_core/core/impl/memory/entity_extractor.py +++ b/agent_core/core/impl/memory/entity_extractor.py @@ -69,6 +69,22 @@ "that", "these", "those", + "do", + "not", + "no", + "if", + "when", + "then", + "also", + "only", + "never", + "always", + "before", + "after", + "use", + "id", + "url", + "ok", "user", "agent", "task", @@ -128,6 +144,11 @@ def extract_entities(text: str, max_entities: int = 12) -> List[str]: lowered = candidate.lower() if lowered in _STOP: continue + # Reject chains made entirely of stopwords ("Do NOT", "When If"): + # capitalised grammar words at sentence starts, not entities. + words = re.split(r"[ \-_]+", lowered) + if words and all(w in _STOP for w in words): + continue # Drop single-letter or pure-numeric tokens if len(candidate) < 2: continue diff --git a/agent_core/core/impl/memory/graph.py b/agent_core/core/impl/memory/graph.py new file mode 100644 index 00000000..2796d8ab --- /dev/null +++ b/agent_core/core/impl/memory/graph.py @@ -0,0 +1,836 @@ +# -*- 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. + +A memory↔entity link is one of two states: +- CONFIRMED — recorded by the entity-indexer LLM: a MEMORY.md item's + ``{entities: ...}`` field, or the ENTITIES.md registry for an indexed + file whose content still matches the registry hash. +- PENDING — a deterministic provisional link. When a memory has NOT yet + been reviewed by the entity-indexer, it is attached to any ALREADY-KNOWN + entity whose name appears in its text. Pending links are shown distinctly + and are confirmed-or-corrected on the next entity-indexer run. They never + create entities — they only attach to entities the LLM has established. + +ONLY THE ENTITY-INDEXER CREATES/EDITS ENTITIES: +- MEMORY.md items are annotated inline with ``{entities: Name1, Name2}`` by + the entity-indexer (the memory-processor writes plain items and does no + entity work). An item without the field is unreviewed → pending links. +- File chunks map to entities through the ENTITIES.md registry, maintained + by the entity-indexer skill: per-section lines + ``[path] [content-hash] [section key] Name1, Name2`` whose section keys + are the chunker's exact section paths (supplied to the skill verbatim). +Confirmed links only ever PARSE those records; pending links only ever +match against entities those records have already created. + +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 + +# ───────────────────────────── Item grammar ───────────────────────────── + +# Marks an invalidated fact. The memory-processor appends this marker +# instead of deleting contradicted items. +SUPERSEDED_MARKER = "{superseded}" + +# Structured entity field on an item line, written by the memory-processor: +# {entities: Name1, Name2}. An empty field ({entities:}) means "annotated, +# no entities"; an absent field means "not yet annotated". +ENTITIES_FIELD_RE = re.compile(r"\{entities:([^{}]*)\}") + +# The per-file entity registry maintained by the entity-indexer skill. +# Two line shapes per indexed file: +# [path] [content-hash] — processed marker +# [path] [content-hash] [section key] Name1, ... — one per section with entities +# Section keys are the chunker's exact section paths, supplied to the skill +# verbatim in the task instruction so no fuzzy matching is ever needed. +ENTITY_REGISTRY_FILE = "ENTITIES.md" +_REGISTRY_MARKER_RE = re.compile(r"^\[([^\]]+)\]\s+\[([0-9a-fA-F]{6,40})\]\s*$") +_REGISTRY_SECTION_RE = re.compile( + r"^\[([^\]]+)\]\s+\[([0-9a-fA-F]{6,40})\]\s+\[(.*)\]\s*(.*?)\s*$" +) + +# 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 = 0.45 + +# Label propagation rounds. The graph is small (hundreds of nodes); label +# propagation converges in a handful of rounds. +_LABEL_PROPAGATION_ROUNDS = 10 + + +def normalize_timestamp(ts: str) -> str: + """Canonicalise an item timestamp to 'YYYY-MM-DD HH:MM:SS'. + + Accepts '/' or '-' date separators, 'T' or space, and missing seconds + (the memory-processor has historically written both '03:00' and + '03:00:00'). Returns '' when unparseable. Every consumer that derives + an item id MUST go through this so the same line always hashes to the + same identity. + """ + cleaned = (ts or "").replace("/", "-").replace("T", " ").strip() + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"): + try: + return datetime.strptime(cleaned, fmt).strftime("%Y-%m-%d %H:%M:%S") + except ValueError: + continue + return "" + + +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 item_entities(content: str) -> List[str]: + """Entity names for an item: its ``{entities: ...}`` field, nothing else. + + The field is written by the memory-processor LLM. Items without the + field have no entities until its backfill phase annotates them. + """ + entities = split_item_fields(content)[1] + return entities or [] + + +def registry_content_hash(content: bytes) -> str: + """Fingerprint of an indexed file as recorded in ENTITIES.md. + + The entity-index pre-check writes this into the registry and the graph's + confirmed-file check compares against it, so the derivation lives in ONE + place: both sides must hash identically or staleness detection silently + breaks. + """ + return hashlib.md5(content).hexdigest()[:12] + + +def parse_entity_registry(content: str) -> Dict[str, Dict[str, Any]]: + """Parse ENTITIES.md into ``{path: {"hash": str, "sections": {key: [names]}}}``. + + Registry lines are written by the entity-indexer skill. Each processed + file has a marker line ``[path] [hash]`` plus one + ``[path] [hash] [section key] Name1, Name2`` line per section with + entities. The hash is the file's raw-content md5 prefix at extraction + time, supplied to the skill by the trigger pre-check; comparing it + against the current file hash is how staleness is detected. + """ + registry: Dict[str, Dict[str, Any]] = {} + + def entry(path: str, digest: str) -> Dict[str, Any]: + path = path.strip().replace("\\", "/") + record = registry.setdefault(path, {"hash": "", "sections": {}}) + record["hash"] = digest.lower() + return record + + for line in (content or "").splitlines(): + line = line.strip() + if not line or line.startswith("#") or line.startswith(">"): + continue + marker = _REGISTRY_MARKER_RE.match(line) + if marker: + entry(marker.group(1), marker.group(2)) + continue + section = _REGISTRY_SECTION_RE.match(line) + if section: + record = entry(section.group(1), section.group(2)) + names = _dedup_names(section.group(4).split(",")) if section.group(4) else [] + if names: + record["sections"][section.group(3).strip()] = names + return registry + + +# ───────────────────────────── 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 + # 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] = {} + + # ───────────────────────────── Building ───────────────────────────── + + @classmethod + def build( + cls, + chunks: List[Dict[str, Any]], + file_registry: Optional[Dict[str, Dict[str, Any]]] = None, + confirmed_files: Optional[Set[str]] = 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. Confirmed + entities come from LLM-authored records only — each MEMORY.md item's + ``{entities: ...}`` field, and the ENTITIES.md registry's + per-section entries for file chunks whose file is up to date. + Unreviewed memories then get PENDING links against the entity set + those records established (see :meth:`_compute_pending_links`). + + Args: + chunks: dicts with ``chunk_id``, ``document`` and ``metadata`` + (the full ChromaDB collection contents). + file_registry: parse_entity_registry() output. Entries for + files no longer indexed are ignored. + confirmed_files: indexed-file paths whose current content still + matches their ENTITIES.md registry hash. Only these files' + chunks are treated as reviewed (their registry sections are + authoritative, including "reviewed → no entities"); chunks + of a file that is missing/stale in the registry are + unreviewed and fall to pending links. + """ + graph = cls() + registry = file_registry or {} + confirmed = confirmed_files or set() + + 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. + is_reviewed = file_path in confirmed + sections = ( + (registry.get(file_path) or {}).get("sections", {}) + if is_reviewed + else {} + ) + graph._add_file_memory_chunk( + chunk.get("chunk_id", ""), + chunk.get("document", ""), + meta, + sections, + is_reviewed, + ) + + # Deterministic provisional links come AFTER every confirmed record + # is in, so the known-entity set they match against is complete. + graph._compute_pending_links() + 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") + + if "entities" in meta: + entity_names = _dedup_names((meta.get("entities") or "").split(",")) + else: + entity_names = item_entities(document) + + item = _ItemNode( + item_id=chunk_id, + timestamp=meta.get("timestamp", ""), + category=meta.get("category", "fact"), + content=content, + # Reviewed iff the item carries an {entities:} field (written by + # the entity-indexer); the chunker records that as this flag. + reviewed=bool(meta.get("entities_annotated")), + 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}") + + for name in entity_names: + entity = self._ensure_entity(name) + entity.item_ids.add(chunk_id) + item.entities.append(entity.key) + self._link(f"i:{chunk_id}", f"e:{entity.key}") + + def _add_file_memory_chunk( + self, + chunk_id: str, + document: str, + meta: Dict[str, Any], + section_entities: Dict[str, List[str]], + reviewed: bool, + ) -> 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. When the + file is reviewed (its registry hash matches), links it to the + entities the ENTITIES.md registry records for its exact section key + — and a reviewed section with no registry entities is genuinely + entity-free, not pending. An unreviewed file's chunks get no + confirmed entities and fall to pending links. 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=reviewed, + source="file", + file_path=file_path, + section=section, + ) + self.items[chunk_id] = item + self._link(f"f:{file_path}", f"i:{chunk_id}") + + for name in section_entities.get(section, []): + entity = self._ensure_entity(name) + entity.item_ids.add(chunk_id) + entity.file_paths.add(file_path) + item.entities.append(entity.key) + node.entities.add(entity.key) + self._link(f"i:{chunk_id}", f"e:{entity.key}") + + def _compute_pending_links(self) -> None: + """Deterministic provisional memory→entity links. + + Runs once every confirmed record is loaded, so it matches against + the COMPLETE known-entity set. For each unreviewed, non-superseded + memory it attaches the memory to any already-known entity whose + whole (normalised) name appears in the memory text. These links are + marked pending on the item and mirrored into the adjacency (so the + physics pulls the memory toward its provisional entity and the two + colour together), but they never inflate an entity's canonical + mention_count and never create a new entity. + """ + if not self.entities: + return + + # Precompute " normalised name " needles once. + needles: List[Tuple[str, str]] = [] + for key in 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(): + # A reviewed memory (or one that already carries confirmed + # entities) has been decided — never guess over the top of it. + if item.reviewed or item.entities or item.superseded: + continue + haystack = f" {re.sub(r'[^a-z0-9]+', ' ', item.content.lower())} " + for needle, key in needles: + if needle in haystack: + item.pending_entities.append(key) + self.entities[key].pending_item_ids.add(item.item_id) + self._link(f"i:{item.item_id}", f"e:{key}") + + # ─────────────────────────── 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 = 5) -> List[Tuple[str, float]]: + """Match query text against entity names. + + Returns (entity_key, strength) pairs. Exact phrase presence scores + 1.0; all name tokens present somewhere in the query scores 0.8. + """ + 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, 1.0)) + continue + tokens = name_norm.split() + if len(tokens) > 1 and all(t in query_tokens for t in tokens): + matches.append((key, 0.8)) + + 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]] = [] + + # Colour GROUP per node (the panel maps it through its palette). + # Colour by the ENTITY a node is about, NOT by community: community + # detection collapses a connected graph into a single blob, which + # would paint every node one colour. Each entity gets its own group + # (sequential, so adjacent entities land on contrasting palette + # slots); a memory/file inherits its primary entity's group; nodes + # with no entity get a stable hash group so they still vary instead + # of all defaulting to group 0. + entity_color = {key: idx for idx, key in enumerate(sorted(self.entities))} + + def _fallback_group(text: str) -> int: + return int(hashlib.md5(text.encode("utf-8")).hexdigest()[:6], 16) + + def _item_group(item: _ItemNode) -> int: + primary = None + if item.entities: + primary = item.entities[0] + elif item.pending_entities: + primary = item.pending_entities[0] + if primary is not None and primary in entity_color: + return entity_color[primary] + return _fallback_group(item.item_id) + + 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), + "colorGroup": entity_color[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), + "colorGroup": _item_group(item), + } + ) + 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), + "colorGroup": ( + entity_color[sorted(file_node.entities)[0]] + if file_node.entities + else _fallback_group(file_path) + ), + } + ) + # 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/manager.py b/agent_core/core/impl/memory/manager.py index 9385d766..406e5d66 100644 --- a/agent_core/core/impl/memory/manager.py +++ b/agent_core/core/impl/memory/manager.py @@ -22,13 +22,22 @@ 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 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 ( + ENTITY_REGISTRY_FILE, + MemoryGraph, + compute_item_id, + parse_entity_registry, + registry_content_hash, + split_item_fields, +) +from agent_core.core.impl.memory.text_extract import extract_text, is_indexable_file # Files that are flat lists of "[timestamp] [category] content" items. @@ -36,20 +45,36 @@ # 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 +# Matches a memory item line. Tolerates both "/" and "-" date separators, # either "[YYYY-MM-DD HH:MM:SS]" (MEMORY.md) or "[YYYY/MM/DD HH:MM:SS]" -# (EVENT_UNPROCESSED.md). Captures: timestamp, category, content. +# (EVENT_UNPROCESSED.md), and missing seconds — the memory-processor has +# written "[YYYY-MM-DD HH:MM]" stamps too. Captures: timestamp, 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*\[(\d{4}[-/]\d{2}[-/]\d{2}[ T]\d{2}:\d{2}(?::\d{2})?)\]\s+\[([\w\-]+)\]\s*:?\s*(.+?)\s*$" ) # Hybrid-retrieval weights. Vector is the primary signal, BM25 backstops -# proper nouns and dates. +# 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). HYBRID_WEIGHTS = { - "vector": 0.65, - "bm25": 0.35, + "vector": 0.55, + "bm25": 0.30, + "graph": 0.15, } +# 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 = 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 = 0.05 +RECENCY_HALF_LIFE_DAYS = 30.0 + # Log-line preview limits. Keep multi-line queries and long summaries from # bleeding across log entries. _LOG_QUERY_MAX_CHARS = 300 @@ -204,13 +229,11 @@ 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" def __init__( self, @@ -218,6 +241,7 @@ def __init__( 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 + extra_files_provider: Optional[Callable[[], List[str]]] = None, ): """ Initialize the Memory Manager. @@ -227,11 +251,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 @@ -250,7 +279,7 @@ def __init__( 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 +289,7 @@ 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"}, ) # In-memory cache of file indices @@ -272,6 +301,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}" @@ -354,26 +388,33 @@ def retrieve( top_k: int = 5, min_relevance: float = 0.55, 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()") @@ -434,8 +475,23 @@ 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: + seeds = self._graph.match_entities(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 [] @@ -471,12 +527,25 @@ 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 = ( + w["vector"] * vector_score + + w["bm25"] * bm25_score + + w["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 pointers.append( @@ -501,7 +570,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)") @@ -513,6 +582,87 @@ def retrieve( ) 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: + # Per-section chunk→entity mappings come from the ENTITIES.md + # registry (LLM-maintained by the entity-indexer skill). + # Missing file means an empty registry — no entities for file + # chunks yet. + 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") + ) + # A file's chunks are "reviewed" only while its current content + # still hashes to the registry's recorded fingerprint (the SAME + # hash the entity-index pre-check writes — see + # registry_content_hash). A stale/missing entry means the file + # changed since its last extraction, so its chunks fall to + # pending links until the entity-indexer catches up. + confirmed_files = set() + for rel, entry in registry.items(): + fpath = self.agent_fs_path / rel + try: + if fpath.exists(): + raw = registry_content_hash(fpath.read_bytes()) + if raw == (entry.get("hash") or "").lower(): + confirmed_files.add(rel) + except OSError: + continue + self._graph = MemoryGraph.build( + self._load_full_corpus(), registry, confirmed_files + ) + self._graph_dirty = False + 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 _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 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: @@ -616,9 +766,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 @@ -689,7 +837,7 @@ 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: @@ -764,12 +912,16 @@ 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] = {} + is_memory_file = Path(file_path).name == "MEMORY.md" for raw_line in content.splitlines(): line = raw_line.strip() @@ -783,13 +935,27 @@ 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, declared_entities, superseded = split_item_fields(item_text) + # Graph entities come from the LLM-written {entities: ...} field + # only; the BM25 keyword field keeps the loose extraction — + # noisy proper nouns help keyword recall but must never become + # graph entities. + keyword_entities = extract_entities(clean_text) + summary = self._create_summary(clean_text) + + if is_memory_file: + chunk_id = compute_item_id(timestamp_iso or timestamp_str, clean_text) + # Identical duplicate lines get a stable ordinal suffix. + dup = seen_ids.get(chunk_id, 0) + seen_ids[chunk_id] = dup + 1 + if dup: + chunk_id = f"{chunk_id}-{dup + 1}" + else: + chunk_id = str(uuid.uuid4()) chunks.append( MemoryChunk( - chunk_id=str(uuid.uuid4()), + chunk_id=chunk_id, file_path=file_path, section_path=f"item:{category}", title=category, @@ -802,9 +968,15 @@ def _chunk_memory_log(self, content: str, file_path: str) -> List[MemoryChunk]: "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), + # entity lists as comma-joined strings. extracted_entities + # feeds BM25 (loose), entities feeds the graph (curated). + "extracted_entities": ", ".join(keyword_entities), + "entities": ", ".join(declared_entities or []), + # None → no {entities:} field yet → unreviewed by the + # entity-indexer → the graph gives it pending links. + "entities_annotated": declared_entities is not None, + "item_content": clean_text, + "superseded": superseded, "item_kind": "memory_log", }, ) @@ -813,10 +985,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 section entities 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 +1027,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"], @@ -853,12 +1042,15 @@ def _chunk_by_sections(self, content: str, file_path: str) -> List[MemoryChunk]: "header_level": section["level"], "part": i + 1, "total_parts": len(sub_chunks), + "extracted_entities": ", ".join( + extract_entities(sub_content) + ), }, ) 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"], @@ -869,6 +1061,9 @@ def _chunk_by_sections(self, content: str, file_path: str) -> List[MemoryChunk]: indexed_at=now, metadata={ "header_level": section["level"], + "extracted_entities": ", ".join( + extract_entities(section_content) + ), }, ) chunks.append(chunk) @@ -1033,10 +1228,14 @@ def _create_summary(self, content: str, max_length: int = 150) -> str: Create a brief summary of content for the memory pointer. Takes the first meaningful text, cleans it up, and truncates. + Markdown SYNTAX is stripped positionally — never 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"^#{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 # Take first max_length chars, break at word boundary @@ -1059,12 +1258,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 +1309,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( @@ -1147,6 +1347,7 @@ 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}") @@ -1166,17 +1367,18 @@ def _clear_index(self) -> None: self.collection = self.chroma_client.get_or_create_collection( name=self.COLLECTION_NAME, metadata={ - "description": "Agent file system memory chunks (v2)", + "description": "Agent file system memory chunks", "hnsw:space": "cosine", }, ) self.file_index_collection = self.chroma_client.get_or_create_collection( name=self.FILE_INDEX_COLLECTION, - metadata={"description": "File index for incremental updates (v2)"}, + metadata={"description": "File index for incremental updates"}, ) self._file_index_cache.clear() self._bm25_dirty = True + self._graph_dirty = True # ───────────────────────────── File Index Persistence ───────────────────────────── @@ -1229,15 +1431,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 +1495,63 @@ 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_file_sections(self, rel_path: str) -> List[str]: + """The chunker's exact section keys for one indexed file, in order. + + Supplied verbatim to the entity-indexer skill so its ENTITIES.md + section lines match chunk section_paths exactly — no fuzzy + matching anywhere. + """ + file_path = self.agent_fs_path / rel_path + if not file_path.exists(): + return [] + try: + content = extract_text(file_path) + except Exception: + return [] + sections: List[str] = [] + for chunk in self._chunk_markdown(content, rel_path): + if chunk.section_path not in sections: + sections.append(chunk.section_path) + return sections + + 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 +1581,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: + """Canonical 'YYYY-MM-DD HH:MM:SS', tolerant of '/'-dates, 'T', and + missing seconds. Delegates to the shared graph helper so item ids are + derived from the identical canonical form everywhere. Returns '' when + parsing fails; 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/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/ENTITIES.md b/agent_file_system/ENTITIES.md new file mode 100644 index 00000000..edc1779e --- /dev/null +++ b/agent_file_system/ENTITIES.md @@ -0,0 +1,104 @@ +# Entity Registry + +Agent DO NOT edit this file outside the entity-indexer skill. + +## Overview + +Maps sections of indexed files to the entities they are about, decided by the entity-indexer skill. +Format: [path.md] [content-hash] marker line, plus [path.md] [content-hash] [section key] Entity One, Entity Two per section. + +## Entities + +[AGENT.md] [45f44985676a] +[AGENT.md] [45f44985676a] [Introduction] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Index] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Runtime] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Runtime > ### Sessions] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Runtime > ### Trigger anatomy (part 1)] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Runtime > ### Trigger anatomy (part 2)] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Runtime > ### Trigger aggregation] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Runtime > ### react() order] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Runtime > ### Workflow runs (memory / proactive) (part 1)] CraftBot, CraftOS, Memory, Proactive +[AGENT.md] [45f44985676a] [# AGENT.md > ## Runtime > ### Workflow runs (memory / proactive) (part 2)] CraftBot, CraftOS, Memory, Proactive +[AGENT.md] [45f44985676a] [# AGENT.md > ## Runtime > ### Waiting for the user] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Runtime > ### Force-stop] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Runtime > ### Components attached at construction] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Runtime > ### State and context every turn] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Runs] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Runs > ### Quick work] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Runs > ### Substantial work] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Runs > ### The action surface] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Runs > ### `send_message.continue_work`] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Runs > ### Spinning off and deferring work: `schedule_task`] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Runs > ### Lock the deliverable spec: `set_requirement`] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Runs > ### Todo phase prefixes] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Runs > ### Output destinations] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Runs > ### Common mistakes to avoid] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Sub-Agents] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Sub-Agents > ### When to delegate] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Sub-Agents > ### How to write a good `query`] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Sub-Agents > ### Fan out for breadth] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Sub-Agents > ### Reading the result] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Sub-Agents > ### When a sub-agent misbehaves] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Communication Rules (part 1)] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Communication Rules (part 2)] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Errors] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Errors > ### Action result schema (read this first)] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Errors > ### Error event kinds in the event stream] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Errors > ### LLM error classes (from `classify_llm_error`) (part 1)] CraftBot, CraftOS, OpenAI +[AGENT.md] [45f44985676a] [# AGENT.md > ## Errors > ### LLM error classes (from `classify_llm_error`) (part 2)] CraftBot, CraftOS, OpenAI +[AGENT.md] [45f44985676a] [# AGENT.md > ## File System] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## File System > ### GLOBAL_LIVING_UI.md] CraftBot, CraftOS, Living UI +[AGENT.md] [45f44985676a] [# AGENT.md > ## File System > ### Living UI projects (workspace/living_ui/) (part 1)] CraftBot, CraftOS, Living UI +[AGENT.md] [45f44985676a] [# AGENT.md > ## File System > ### Living UI projects (workspace/living_ui/) (part 2)] CraftBot, CraftOS, Living UI +[AGENT.md] [45f44985676a] [# AGENT.md > ## Living UI] CraftBot, CraftOS, Living UI +[AGENT.md] [45f44985676a] [# AGENT.md > ## Living UI > ### Action surface (`living_ui` set) (part 1)] CraftBot, CraftOS, Living UI +[AGENT.md] [45f44985676a] [# AGENT.md > ## Living UI > ### Action surface (`living_ui` set) (part 2)] CraftBot, CraftOS, Living UI +[AGENT.md] [45f44985676a] [# AGENT.md > ## Living UI > ### Action surface (`living_ui` set) (part 3)] CraftBot, CraftOS, Living UI +[AGENT.md] [45f44985676a] [# AGENT.md > ## Living UI > ### Build / delivery lifecycle] CraftBot, CraftOS, Living UI +[AGENT.md] [45f44985676a] [# AGENT.md > ## Living UI > ### Skills] CraftBot, CraftOS, Living UI +[AGENT.md] [45f44985676a] [# AGENT.md > ## Living UI > ### Design rules] CraftBot, CraftOS, Living UI +[AGENT.md] [45f44985676a] [# AGENT.md > ## MCP] CraftBot, CraftOS, MCP +[AGENT.md] [45f44985676a] [# AGENT.md > ## MCP > ### How MCP fits in] CraftBot, CraftOS, MCP +[AGENT.md] [45f44985676a] [# AGENT.md > ## MCP > ### Pre-defined servers in this codebase] CraftBot, CraftOS, MCP +[AGENT.md] [45f44985676a] [# AGENT.md > ## Integrations > ### What's wired in (part 1)] CraftBot, CraftOS, Gmail, GitHub, Google Calendar, Notion, Slack, Discord, Telegram, WhatsApp +[AGENT.md] [45f44985676a] [# AGENT.md > ## Integrations > ### What's wired in (part 2)] CraftBot, CraftOS, Stripe, HubSpot, Jira, Lark, LINE, LinkedIn, Twitter, Outlook +[AGENT.md] [45f44985676a] [# AGENT.md > ## Integrations > ### What's wired in (part 3)] CraftBot, CraftOS, Google Drive, Google Docs, Google YouTube +[AGENT.md] [45f44985676a] [# AGENT.md > ## Models > ### Providers and what they support (part 1)] CraftBot, CraftOS, OpenAI, Anthropic, Google +[AGENT.md] [45f44985676a] [# AGENT.md > ## Models > ### Providers and what they support (part 2)] CraftBot, CraftOS, OpenAI, Anthropic, Google +[AGENT.md] [45f44985676a] [# AGENT.md > ## Models > ### Providers and what they support (part 3)] CraftBot, CraftOS, OpenAI, Anthropic, Google +[AGENT.md] [45f44985676a] [# AGENT.md > ## Models > ### Subscription sign-in (ChatGPT / Grok)] CraftBot, CraftOS, OpenAI, ChatGPT, Grok +[AGENT.md] [45f44985676a] [# AGENT.md > ## Memory] CraftBot, CraftOS, Memory +[AGENT.md] [45f44985676a] [# AGENT.md > ## Proactive] CraftBot, CraftOS, Proactive +[AGENT.md] [45f44985676a] [# AGENT.md > ## Self-Improvement] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Self-Edit] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Glossary (part 1)] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Glossary (part 2)] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Glossary (part 3)] CraftBot, CraftOS +[AGENT.md] [45f44985676a] [# AGENT.md > ## Glossary (part 4)] CraftBot, CraftOS + +[PROACTIVE.md] [74724c00ef31] +[PROACTIVE.md] [74724c00ef31] [Introduction] CraftBot, CraftOS, Proactive +[PROACTIVE.md] [74724c00ef31] [# Proactive Tasks] CraftBot, CraftOS, Proactive +[PROACTIVE.md] [74724c00ef31] [# Proactive Tasks > ## How Proactive Tasks Work] CraftBot, CraftOS, Proactive +[PROACTIVE.md] [74724c00ef31] [# Proactive Tasks > ## Decision Rubric] CraftBot, CraftOS, Proactive +[PROACTIVE.md] [74724c00ef31] [# Proactive Tasks > ## Permission Tiers] CraftBot, CraftOS, Proactive +[PROACTIVE.md] [74724c00ef31] [# Proactive Tasks > ## Task Definitions] CraftBot, CraftOS, Proactive +[PROACTIVE.md] [74724c00ef31] [# Proactive Tasks > ## Task Definitions > ### [FREQUENCY] Task Name (part 1)] CraftBot, CraftOS, Proactive +[PROACTIVE.md] [74724c00ef31] [# Proactive Tasks > ## Task Definitions > ### [FREQUENCY] Task Name (part 2)] CraftBot, CraftOS, Proactive +[PROACTIVE.md] [74724c00ef31] [# Proactive Tasks > ## Task Definitions > ### [FREQUENCY] Task Name (part 3)] CraftBot, CraftOS, Proactive +[PROACTIVE.md] [74724c00ef31] [# Proactive Tasks > ## Goals, Plan, and Status] CraftBot, CraftOS, tham yik foong, GitHub, Notion, Living UI, CraftOS Command Center, Google Sheets, Lucas Ceccon, K K Surendran, Sandra Arias, Startpass, ahmad-ajmal, CraftBot.live +[PROACTIVE.md] [74724c00ef31] [# Proactive Tasks > ## Goals, Plan, and Status > ### Long-Term Goals] CraftBot, CraftOS +[PROACTIVE.md] [74724c00ef31] [# Proactive Tasks > ## Goals, Plan, and Status > ### Current Focus] CraftBot, CraftOS, tham yik foong, GitHub, craftbot-live +[PROACTIVE.md] [74724c00ef31] [# Proactive Tasks > ## Goals, Plan, and Status > ### Recent Accomplishments] CraftBot, CraftOS, Living UI, Lucas Ceccon, K K Surendran, Sandra Arias, Notion, Startpass, Google Sheets, CraftOS Command Center +[PROACTIVE.md] [74724c00ef31] [# Proactive Tasks > ## Goals, Plan, and Status > ### Upcoming Priorities] CraftBot, CraftOS, CraftOS Command Center, Living UI, ahmad-ajmal, Notion, CraftBot.live, TSLA + +[USER.md] [8039d7965618] +[USER.md] [8039d7965618] [## Identity] tham yik foong, zfoong +[USER.md] [8039d7965618] [## Communication Preferences] tham yik foong +[USER.md] [8039d7965618] [## Agent Interaction] tham yik foong +[USER.md] [8039d7965618] [## Background] tham yik foong, Kuala Lumpur, Malaysia +[USER.md] [8039d7965618] [## Life Goals] tham yik foong +[USER.md] [8039d7965618] [## Personality] tham yik foong + diff --git a/app/agent_base.py b/app/agent_base.py index 8e8068b4..1d69e59a 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,141 @@ 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 indexed files have + stale/missing ENTITIES.md entries, or None to skip the turn. + """ + if not is_memory_enabled(): + logger.info("[ENTITY-INDEX] Memory is disabled, skipping trigger") + return None + + stale_files = self._stale_entity_files() + unannotated_memories = self._unannotated_memory_items() + if not stale_files and not unannotated_memories: + logger.info("[ENTITY-INDEX] Nothing to extract or confirm") + 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) + + parts: list[str] = [] + # MEMORY.md is annotated INLINE (not via the registry): every item + # without an {entities: ...} field is unreviewed and currently + # carries only provisional (pending) links — confirm or correct them. + if unannotated_memories: + parts.append( + f"Confirm entity links for {unannotated_memories} MEMORY.md " + f"item(s) that have no {{entities: ...}} field: read each item, " + f"decide the entities it is about, and append the " + f"{{entities: ...}} field to that line in place." + ) + # Other indexed files are recorded in the ENTITIES.md registry. Each + # entry carries its exact chunker section keys so the skill's + # registry lines match chunk section_paths verbatim. + if stale_files: + file_specs = [] + for rel, digest in stale_files: + sections = self.memory_manager.get_file_sections(rel) + section_list = " ".join(f"[{s}]" for s in sections) + file_specs.append(f"{rel} (hash {digest}) sections: {section_list}") + parts.append( + f"Extract entities for {len(stale_files)} indexed file(s) into " + f"ENTITIES.md: {'; '.join(file_specs)}. For each file, read it, " + f"decide the entities of each listed section, and update its " + f"registry lines using the given hash and the section keys " + f"exactly as listed." + ) + parts.append("Follow the entity-indexer skill instructions.") + instruction = " ".join(parts) + workflow = { + "run_source": TriggerSource.ENTITY_INDEX.value, + "workflow_skills": ["entity-indexer"], + "workflow_action_sets": ["file_operations"], + } + logger.info( + f"[ENTITY-INDEX] {unannotated_memories} MEMORY.md item(s) to confirm, " + f"{len(stale_files)} file(s) to extract" + ) + return instruction, workflow + + def _unannotated_memory_items(self) -> int: + """Count non-superseded MEMORY.md items with no {entities: ...} field. + + These are the memories the entity-indexer still has to review — they + carry only provisional pending links until it annotates them inline. + """ + memory_file = AGENT_FILE_SYSTEM_PATH / "MEMORY.md" + if not memory_file.exists(): + return 0 + try: + items = _parse_memory_items(memory_file.read_text(encoding="utf-8")) + except Exception as e: + logger.warning(f"[ENTITY-INDEX] Failed to inspect MEMORY.md: {e}") + return 0 + return sum( + 1 + for item in items + if not item.get("entities_annotated") and not item.get("superseded") + ) + + def _stale_entity_files(self) -> list[tuple[str, str]]: + """Indexed files whose ENTITIES.md entry is missing or outdated. + + Returns (relative_path, content_hash) pairs; the hash is passed to + the entity-indexer via the task instruction so it can be written + verbatim into the registry line. + """ + from agent_core.core.impl.memory.graph import ( + ENTITY_REGISTRY_FILE, + parse_entity_registry, + registry_content_hash, + ) + from app.ui_layer.settings.memory_settings import ( + CORE_INDEX_FILES, + get_memory_indexed_files, + ) + + # MEMORY.md items carry their own entity fields; the unprocessed + # buffer is transient; the registry is bookkeeping. + excluded = {"MEMORY.md", "EVENT_UNPROCESSED.md", ENTITY_REGISTRY_FILE} + + registry = {} + registry_path = AGENT_FILE_SYSTEM_PATH / ENTITY_REGISTRY_FILE + if registry_path.exists(): + try: + registry = parse_entity_registry( + registry_path.read_text(encoding="utf-8") + ) + except Exception as e: + logger.warning(f"[MEMORY] Failed to parse {ENTITY_REGISTRY_FILE}: {e}") + + stale: list[tuple[str, str]] = [] + seen = set() + for rel in CORE_INDEX_FILES + get_memory_indexed_files(): + if rel in excluded or rel in seen: + continue + seen.add(rel) + file_path = AGENT_FILE_SYSTEM_PATH / rel + if not file_path.exists(): + continue + try: + digest = registry_content_hash(file_path.read_bytes()) + except OSError: + continue + entry = registry.get(rel) + if entry is None or entry.get("hash") != digest: + stale.append((rel, digest)) + return stale + def _prepare_proactive_run(self, trigger: Trigger) -> Optional[tuple[str, dict]]: """Pre-check a proactive heartbeat/planner trigger. @@ -1358,11 +1519,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/ENTITIES.md b/app/data/agent_file_system_template/ENTITIES.md new file mode 100644 index 00000000..0a8c36e0 --- /dev/null +++ b/app/data/agent_file_system_template/ENTITIES.md @@ -0,0 +1,11 @@ +# Entity Registry + +Agent DO NOT edit this file outside the entity-indexer skill. + +## Overview + +Maps sections of indexed files to the entities they are about, decided by the entity-indexer skill. +Format: [path.md] [content-hash] marker line, plus [path.md] [content-hash] [section key] Entity One, Entity Two per section. + +## Entities + 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/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..dac4afbf 100644 --- a/app/ui_layer/adapters/browser_adapter.py +++ b/app/ui_layer/adapters/browser_adapter.py @@ -51,6 +51,8 @@ reset_memory, clear_unprocessed_events, get_memory_stats, + set_memory_indexed_files, + list_indexable_candidates, # Model settings get_available_providers, get_model_settings, @@ -1415,7 +1417,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", "") @@ -1430,6 +1435,16 @@ async def _handle_ws_message(self, data: Dict[str, Any], ws=None) -> None: elif msg_type == "memory_process_trigger": await self._handle_memory_process_trigger() + 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 +4154,7 @@ async def _handle_reset(self, data: dict | None = None) -> None: "skill_creation", "skill_improvement", "memory_processing", + "entity_index", } ) @@ -4150,6 +4166,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 +4191,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 +4952,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 @@ -5095,6 +5122,97 @@ async def _handle_memory_process_trigger(self) -> None: } ) + 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..e9cea88d --- /dev/null +++ b/app/ui_layer/browser/frontend/src/pages/Memory/MemoryGraphCanvas.tsx @@ -0,0 +1,1149 @@ +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 +] + +// Pending-link accent (muted rose), kept in one place so the dashed graph +// edges match the sidebar's pending chips. Deliberately NOT orange (brand- +// reserved / overused) and off the cool node palette, so pending links read +// as distinct. Dash lengths are world units, divided by zoom at draw time. +const PENDING_RGB = '193, 132, 164' +const PENDING_DASH = [4, 3] + +// ── 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 +// 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 + // 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 + // Provisional memory→entity link (deterministic guess, not yet confirmed + // by the entity-indexer). Drawn dashed and tinted so it reads as tentative. + pending: 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 { + // Colour by entity group (falls back to community). Modulo keeps any + // group index inside the palette. + const group = node.colorGroup ?? node.community ?? 0 + return COMMUNITY_COLORS[((group % COMMUNITY_COLORS.length) + COMMUNITY_COLORS.length) % 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 + + 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.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), + 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, pending: e.status === 'pending' } + }) + .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 + const force = (REPULSION * sizeBoost * overlapRamp / 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). + // Pending links (unconfirmed guesses) are dashed and amber-tinted so + // they read as tentative next to the solid confirmed links. + 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)) + if (e.pending) { + // Amber — matches the "pending" accent used in the sidebar. + ctx.strokeStyle = `rgba(${PENDING_RGB},${inFocus ? 0.6 : 0.22})` + ctx.setLineDash([PENDING_DASH[0] / t.k, PENDING_DASH[1] / t.k]) + } else { + 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() + if (e.pending) ctx.setLineDash([]) + } + + // ── Nodes: solid, workspace-friendly ── + // entity — solid disc, community colour, sized by mention count + // memory — smaller solid dot + // file — solid disc with a thin detached ring + // 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 + ctx.fillStyle = n.color + if (kind === 'file') { + // Solid core + thin detached ring marks a file. + 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 { + 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..f2c7787d --- /dev/null +++ b/app/ui_layer/browser/frontend/src/pages/Memory/MemoryPage.module.css @@ -0,0 +1,788 @@ +/* MemoryPage — graph canvas left, control panel right. */ + +.page { + height: 100%; + display: flex; + overflow: hidden; + /* Pending-link accent (muted rose), shared by every "pending" affordance + * so the chips, tags, and dashed graph edges stay one colour. Off the + * brand orange (reserved/overused) and off the cool node palette so it + * reads as distinct. */ + --pending-accent: 193, 132, 164; + --pending-fg: #ac6389; +} + +/* ── 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); +} + +/* Provisional links awaiting entity-indexer confirmation — amber accent, + * matching the pending edges drawn on the graph. */ +.statChipPending { + color: var(--pending-fg); + background: rgba(var(--pending-accent), 0.12); + border-color: rgba(var(--pending-accent), 0.45); + cursor: help; +} + +.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); +} + +/* A neighbour reached only through a pending (unconfirmed) link. */ +.neighbourChipPending { + border-color: rgba(var(--pending-accent), 0.45); + border-style: dashed; +} + +.pendingTag { + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--pending-fg); + margin-left: 4px; +} + +/* ── 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..f028013b --- /dev/null +++ b/app/ui_layer/browser/frontend/src/pages/Memory/MemoryPage.tsx @@ -0,0 +1,932 @@ +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'}

+ +
+
+
+ + + + +