cortext-memory 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cortext/__init__.py +72 -0
- cortext/core/__init__.py +1 -0
- cortext/core/decay/__init__.py +23 -0
- cortext/core/decay/ebbinghaus.py +126 -0
- cortext/core/decay/forget_gate.py +169 -0
- cortext/core/entity.py +85 -0
- cortext/core/graph.py +226 -0
- cortext/core/memory.py +253 -0
- cortext/core/recall/__init__.py +38 -0
- cortext/core/recall/embedding.py +157 -0
- cortext/core/recall/extractor.py +132 -0
- cortext/core/recall/extractors/__init__.py +19 -0
- cortext/core/recall/extractors/regex_lang.py +335 -0
- cortext/core/recall/pack.py +81 -0
- cortext/core/recall/parser.py +194 -0
- cortext/core/recall/text_extractor.py +224 -0
- cortext/core/relation.py +138 -0
- cortext/core/validation/__init__.py +19 -0
- cortext/core/validation/canonical.py +448 -0
- cortext/cortex.py +325 -0
- cortext/integration/__init__.py +5 -0
- cortext/integration/hermes_bridge.py +138 -0
- cortext/workers/__init__.py +5 -0
- cortext/workers/dream_agent.py +293 -0
- cortext_memory-0.1.0.dist-info/METADATA +194 -0
- cortext_memory-0.1.0.dist-info/RECORD +29 -0
- cortext_memory-0.1.0.dist-info/WHEEL +5 -0
- cortext_memory-0.1.0.dist-info/licenses/LICENSE +21 -0
- cortext_memory-0.1.0.dist-info/top_level.txt +1 -0
cortext/__init__.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Cortext — Memory system for AI agents.
|
|
2
|
+
|
|
3
|
+
5-element detector compliant, internationalized, efficient.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from cortext.cortex import CortexV5
|
|
7
|
+
from cortext.core.memory import Memory
|
|
8
|
+
from cortext.core.entity import Entity
|
|
9
|
+
from cortext.core.relation import Relation, RelationType
|
|
10
|
+
from cortext.core.graph import MemoryGraph, RecallResult
|
|
11
|
+
from cortext.core.validation import (
|
|
12
|
+
CanonicalValidator,
|
|
13
|
+
ValidationResult,
|
|
14
|
+
ValidationStatus,
|
|
15
|
+
ValidationPolicy,
|
|
16
|
+
create_default_validator,
|
|
17
|
+
create_strict_validator,
|
|
18
|
+
)
|
|
19
|
+
from cortext.core.recall import (
|
|
20
|
+
StructuralQueryParser,
|
|
21
|
+
QueryIntent,
|
|
22
|
+
pack_for_context,
|
|
23
|
+
RegexExtractor,
|
|
24
|
+
LLMExtractor,
|
|
25
|
+
HybridExtractor,
|
|
26
|
+
)
|
|
27
|
+
from cortext.core.decay import (
|
|
28
|
+
DecayConfig,
|
|
29
|
+
retrievability,
|
|
30
|
+
effective_stability,
|
|
31
|
+
decay_status,
|
|
32
|
+
ForgetGate,
|
|
33
|
+
ForgetGateConfig,
|
|
34
|
+
)
|
|
35
|
+
from cortext.workers import DreamAgent
|
|
36
|
+
|
|
37
|
+
__version__ = "0.1.0"
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
# Main entry point
|
|
41
|
+
"CortexV5",
|
|
42
|
+
# Core data structures
|
|
43
|
+
"Memory",
|
|
44
|
+
"Entity",
|
|
45
|
+
"Relation",
|
|
46
|
+
"RelationType",
|
|
47
|
+
"MemoryGraph",
|
|
48
|
+
"RecallResult",
|
|
49
|
+
# Validation
|
|
50
|
+
"CanonicalValidator",
|
|
51
|
+
"ValidationResult",
|
|
52
|
+
"ValidationStatus",
|
|
53
|
+
"ValidationPolicy",
|
|
54
|
+
"create_default_validator",
|
|
55
|
+
"create_strict_validator",
|
|
56
|
+
# Recall
|
|
57
|
+
"StructuralQueryParser",
|
|
58
|
+
"QueryIntent",
|
|
59
|
+
"pack_for_context",
|
|
60
|
+
"RegexExtractor",
|
|
61
|
+
"LLMExtractor",
|
|
62
|
+
"HybridExtractor",
|
|
63
|
+
# Decay
|
|
64
|
+
"DecayConfig",
|
|
65
|
+
"retrievability",
|
|
66
|
+
"effective_stability",
|
|
67
|
+
"decay_status",
|
|
68
|
+
"ForgetGate",
|
|
69
|
+
"ForgetGateConfig",
|
|
70
|
+
# Workers
|
|
71
|
+
"DreamAgent",
|
|
72
|
+
]
|
cortext/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core data structures: Memory, Entity, Relation, MemoryGraph."""
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Decay subsystem: Ebbinghaus R = e^(-t/S) + Forget Gate.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from cortext.core.decay.ebbinghaus import (
|
|
6
|
+
DecayConfig,
|
|
7
|
+
retrievability,
|
|
8
|
+
effective_stability,
|
|
9
|
+
decay_status,
|
|
10
|
+
)
|
|
11
|
+
from cortext.core.decay.forget_gate import (
|
|
12
|
+
ForgetGate,
|
|
13
|
+
ForgetGateConfig,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"DecayConfig",
|
|
18
|
+
"retrievability",
|
|
19
|
+
"effective_stability",
|
|
20
|
+
"decay_status",
|
|
21
|
+
"ForgetGate",
|
|
22
|
+
"ForgetGateConfig",
|
|
23
|
+
]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Ebbinghaus decay — R = e^(-t/S).
|
|
3
|
+
|
|
4
|
+
The classic forgetting curve, applied to memory retrieval. Simple,
|
|
5
|
+
universal, and validated empirically (Ebbinghaus, 1885).
|
|
6
|
+
|
|
7
|
+
Formula: R = e^(-t/S)
|
|
8
|
+
- R: retrievability (0.0-1.0)
|
|
9
|
+
- t: time since last access (days)
|
|
10
|
+
- S: stability (days) — base × modifiers
|
|
11
|
+
|
|
12
|
+
Stability modifiers (extensible):
|
|
13
|
+
- access_count: more accesses = more stable (log scale)
|
|
14
|
+
- importance: high importance = more stable
|
|
15
|
+
- consolidation: consolidated memories = more stable
|
|
16
|
+
- (custom) user-defined via metadata
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import math
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from cortext.core.memory import Memory
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class DecayConfig:
|
|
32
|
+
"""Tunables for the decay function."""
|
|
33
|
+
|
|
34
|
+
# Base stability (days for 63% decay without reinforcement)
|
|
35
|
+
base_stability_days: float = 7.0
|
|
36
|
+
|
|
37
|
+
# Stability modifiers
|
|
38
|
+
access_log_multiplier: float = 1.0 # log(access_count + 1) factor
|
|
39
|
+
importance_bonus: float = 1.3 # bonus for high importance (>0.7)
|
|
40
|
+
consolidation_bonus: float = 2.0 # bonus for consolidated memories
|
|
41
|
+
|
|
42
|
+
# Min/max stability (prevent degenerate values)
|
|
43
|
+
min_stability: float = 0.1
|
|
44
|
+
max_stability: float = 365.0
|
|
45
|
+
|
|
46
|
+
# Thresholds for status
|
|
47
|
+
active_threshold: float = 0.7
|
|
48
|
+
fading_threshold: float = 0.3
|
|
49
|
+
forgotten_threshold: float = 0.1
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def retrievability(
|
|
53
|
+
memory: "Memory",
|
|
54
|
+
now: datetime | None = None,
|
|
55
|
+
config: DecayConfig | None = None,
|
|
56
|
+
) -> float:
|
|
57
|
+
"""
|
|
58
|
+
Compute retrievability of a memory using the Ebbinghaus curve.
|
|
59
|
+
|
|
60
|
+
R = e^(-t/S), where S = base_stability × modifiers.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
float between 0.0 (forgotten) and 1.0 (fresh)
|
|
64
|
+
"""
|
|
65
|
+
if config is None:
|
|
66
|
+
config = DecayConfig()
|
|
67
|
+
now = now or datetime.now()
|
|
68
|
+
|
|
69
|
+
# Reference time = last access, fallback to creation
|
|
70
|
+
reference_time = memory.last_accessed or memory.when
|
|
71
|
+
days_since = max(0.0, (now - reference_time).total_seconds() / 86400)
|
|
72
|
+
|
|
73
|
+
# Compute effective stability
|
|
74
|
+
s = effective_stability(memory, config)
|
|
75
|
+
|
|
76
|
+
# Ebbinghaus formula
|
|
77
|
+
return math.exp(-days_since / s)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def effective_stability(
|
|
81
|
+
memory: "Memory",
|
|
82
|
+
config: DecayConfig | None = None,
|
|
83
|
+
) -> float:
|
|
84
|
+
"""
|
|
85
|
+
Compute the effective stability S for a memory.
|
|
86
|
+
|
|
87
|
+
S = base × access_modifier × importance_modifier × consolidation_modifier
|
|
88
|
+
"""
|
|
89
|
+
if config is None:
|
|
90
|
+
config = DecayConfig()
|
|
91
|
+
|
|
92
|
+
s = config.base_stability_days
|
|
93
|
+
|
|
94
|
+
# Access count modifier (logarithmic)
|
|
95
|
+
access_factor = 1.0 + config.access_log_multiplier * math.log(memory.access_count + 1)
|
|
96
|
+
s *= access_factor
|
|
97
|
+
|
|
98
|
+
# Importance bonus
|
|
99
|
+
if memory.importance > 0.7:
|
|
100
|
+
s *= config.importance_bonus
|
|
101
|
+
|
|
102
|
+
# Consolidation bonus
|
|
103
|
+
if memory.is_consolidated:
|
|
104
|
+
s *= config.consolidation_bonus
|
|
105
|
+
|
|
106
|
+
return max(config.min_stability, min(config.max_stability, s))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def decay_status(
|
|
110
|
+
memory: "Memory",
|
|
111
|
+
now: datetime | None = None,
|
|
112
|
+
config: DecayConfig | None = None,
|
|
113
|
+
) -> str:
|
|
114
|
+
"""
|
|
115
|
+
Get a categorical status: "active" | "fading" | "weak" | "forgotten".
|
|
116
|
+
"""
|
|
117
|
+
if config is None:
|
|
118
|
+
config = DecayConfig()
|
|
119
|
+
r = retrievability(memory, now, config)
|
|
120
|
+
if r >= config.active_threshold:
|
|
121
|
+
return "active"
|
|
122
|
+
elif r >= config.fading_threshold:
|
|
123
|
+
return "fading"
|
|
124
|
+
elif r >= config.forgotten_threshold:
|
|
125
|
+
return "weak"
|
|
126
|
+
return "forgotten"
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Forget Gate — active forgetting based on 3 signals.
|
|
3
|
+
|
|
4
|
+
Inspired by LSTM forget gates and active forgetting in human cognition.
|
|
5
|
+
3 signals combine into a forget score (0-1, higher = more forgettable):
|
|
6
|
+
|
|
7
|
+
1. Noise (0.4 weight): how likely the memory is noise vs signal
|
|
8
|
+
- Low importance, no participants, empty/short content
|
|
9
|
+
2. Redundancy (0.35): is this memory redundant with others?
|
|
10
|
+
3. Obsolescence (0.25): is this memory outdated?
|
|
11
|
+
|
|
12
|
+
Usage: gate = ForgetGate()
|
|
13
|
+
score = gate.compute_forget_signal(memory, graph)
|
|
14
|
+
if score >= threshold: accelerate_decay(memory)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from typing import TYPE_CHECKING
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from cortext.core.memory import Memory
|
|
25
|
+
from cortext.core.graph import MemoryGraph
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ForgetGateConfig:
|
|
30
|
+
noise_weight: float = 0.4
|
|
31
|
+
redundancy_weight: float = 0.35
|
|
32
|
+
obsolescence_weight: float = 0.25
|
|
33
|
+
forget_threshold: float = 0.3 # lowered from 0.6 — typical "noisy" memories cross this
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ForgetGate:
|
|
37
|
+
"""
|
|
38
|
+
Active forgetting: score memories for deletion.
|
|
39
|
+
|
|
40
|
+
Combines 3 signals into a single forget score.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, config: ForgetGateConfig | None = None) -> None:
|
|
44
|
+
self.config = config or ForgetGateConfig()
|
|
45
|
+
|
|
46
|
+
def compute_forget_signal(
|
|
47
|
+
self, memory: "Memory", graph: "MemoryGraph"
|
|
48
|
+
) -> float:
|
|
49
|
+
"""Compute forget signal (0-1, higher = more forgettable)."""
|
|
50
|
+
noise = self._noise_score(memory)
|
|
51
|
+
redundancy = self._redundancy_score(memory, graph)
|
|
52
|
+
obsolescence = self._obsolescence_score(memory)
|
|
53
|
+
|
|
54
|
+
return min(1.0, max(0.0, (
|
|
55
|
+
self.config.noise_weight * noise +
|
|
56
|
+
self.config.redundancy_weight * redundancy +
|
|
57
|
+
self.config.obsolescence_weight * obsolescence
|
|
58
|
+
)))
|
|
59
|
+
|
|
60
|
+
def filter_forgettable(
|
|
61
|
+
self, memories: list["Memory"], graph: "MemoryGraph"
|
|
62
|
+
) -> tuple[list["Memory"], list["Memory"]]:
|
|
63
|
+
"""
|
|
64
|
+
Split memories into (keep, forget) based on forget signal.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
(memories_to_keep, memories_to_forget)
|
|
68
|
+
"""
|
|
69
|
+
keep: list["Memory"] = []
|
|
70
|
+
forget: list["Memory"] = []
|
|
71
|
+
for mem in memories:
|
|
72
|
+
signal = self.compute_forget_signal(mem, graph)
|
|
73
|
+
if signal >= self.config.forget_threshold:
|
|
74
|
+
forget.append(mem)
|
|
75
|
+
else:
|
|
76
|
+
keep.append(mem)
|
|
77
|
+
return keep, forget
|
|
78
|
+
|
|
79
|
+
def accelerate_decay(
|
|
80
|
+
self, memory: "Memory", multiplier: float = 0.5
|
|
81
|
+
) -> float:
|
|
82
|
+
"""Reduce importance to accelerate decay. Returns new importance."""
|
|
83
|
+
memory.importance = max(0.0, memory.importance * multiplier)
|
|
84
|
+
return memory.importance
|
|
85
|
+
|
|
86
|
+
def _noise_score(self, memory: "Memory") -> float:
|
|
87
|
+
"""Estimate if memory is noise vs signal."""
|
|
88
|
+
score = 0.0
|
|
89
|
+
|
|
90
|
+
# Low importance
|
|
91
|
+
if memory.importance < 0.3:
|
|
92
|
+
score += 0.3
|
|
93
|
+
elif memory.importance < 0.5:
|
|
94
|
+
score += 0.1
|
|
95
|
+
|
|
96
|
+
# No participants
|
|
97
|
+
if not memory.who:
|
|
98
|
+
score += 0.2
|
|
99
|
+
|
|
100
|
+
# Empty/short content
|
|
101
|
+
content_len = len(memory.what or "")
|
|
102
|
+
if content_len < 10:
|
|
103
|
+
score += 0.2
|
|
104
|
+
elif content_len < 30:
|
|
105
|
+
score += 0.1
|
|
106
|
+
|
|
107
|
+
# Generic values
|
|
108
|
+
generic_terms = {"undefined", "none", "null", "unknown", "n/a", ""}
|
|
109
|
+
if (memory.what or "").lower().strip() in generic_terms:
|
|
110
|
+
score += 0.2
|
|
111
|
+
|
|
112
|
+
# Not consolidated
|
|
113
|
+
if not memory.is_consolidated:
|
|
114
|
+
score += 0.1
|
|
115
|
+
|
|
116
|
+
return min(1.0, score)
|
|
117
|
+
|
|
118
|
+
def _redundancy_score(
|
|
119
|
+
self, memory: "Memory", graph: "MemoryGraph"
|
|
120
|
+
) -> float:
|
|
121
|
+
"""Estimate redundancy with other memories."""
|
|
122
|
+
score = 0.0
|
|
123
|
+
similar_count = 0
|
|
124
|
+
if hasattr(graph, "iter_memories"):
|
|
125
|
+
for other in graph.iter_memories():
|
|
126
|
+
if other.id == memory.id:
|
|
127
|
+
continue
|
|
128
|
+
# Simple overlap check on what-field
|
|
129
|
+
a = set((memory.what or "").lower().split())
|
|
130
|
+
b = set((other.what or "").lower().split())
|
|
131
|
+
if a and b:
|
|
132
|
+
overlap = len(a & b) / len(a | b)
|
|
133
|
+
if overlap > 0.7:
|
|
134
|
+
similar_count += 1
|
|
135
|
+
|
|
136
|
+
if similar_count >= 3:
|
|
137
|
+
score += 0.5
|
|
138
|
+
elif similar_count >= 1:
|
|
139
|
+
score += 0.2
|
|
140
|
+
|
|
141
|
+
return min(1.0, score)
|
|
142
|
+
|
|
143
|
+
def _obsolescence_score(self, memory: "Memory") -> float:
|
|
144
|
+
"""Estimate if memory is obsolete."""
|
|
145
|
+
score = 0.0
|
|
146
|
+
now = datetime.now()
|
|
147
|
+
|
|
148
|
+
# Old with no recent access
|
|
149
|
+
if memory.when:
|
|
150
|
+
days_old = (now - memory.when).days
|
|
151
|
+
last_access = memory.last_accessed or memory.when
|
|
152
|
+
days_since_access = (now - last_access).days
|
|
153
|
+
if days_old > 90 and days_since_access > 60:
|
|
154
|
+
score += 0.4
|
|
155
|
+
elif days_old > 30 and days_since_access > 30:
|
|
156
|
+
score += 0.2
|
|
157
|
+
|
|
158
|
+
# Low retrievability (uses ebbinghaus)
|
|
159
|
+
try:
|
|
160
|
+
from cortext.core.decay.ebbinghaus import retrievability
|
|
161
|
+
r = retrievability(memory)
|
|
162
|
+
if r < 0.2:
|
|
163
|
+
score += 0.4
|
|
164
|
+
elif r < 0.4:
|
|
165
|
+
score += 0.2
|
|
166
|
+
except Exception:
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
return min(1.0, score)
|
cortext/core/entity.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Entity — represents a "thing" that can be referenced in memories.
|
|
3
|
+
|
|
4
|
+
Entities are language-agnostic. A person named "Maria" in PT and
|
|
5
|
+
"Maria" in EN is the same entity. The name is just a string.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from typing import Any
|
|
13
|
+
from uuid import uuid4
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Entity:
|
|
18
|
+
"""
|
|
19
|
+
A referenceable thing in the memory system.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
type: type tag (e.g., "person", "place", "concept", "file")
|
|
23
|
+
name: display name (in any language)
|
|
24
|
+
identifiers: list of ways to identify this entity (emails, IDs, paths)
|
|
25
|
+
attributes: free-form metadata
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
type: str
|
|
29
|
+
name: str
|
|
30
|
+
identifiers: list[str] = field(default_factory=list)
|
|
31
|
+
attributes: dict[str, Any] = field(default_factory=dict)
|
|
32
|
+
id: str = field(default_factory=lambda: str(uuid4()))
|
|
33
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
34
|
+
updated_at: datetime = field(default_factory=datetime.now)
|
|
35
|
+
|
|
36
|
+
def __post_init__(self) -> None:
|
|
37
|
+
if not self.type or not self.type.strip():
|
|
38
|
+
raise ValueError("Entity 'type' is required")
|
|
39
|
+
if not self.name or not self.name.strip():
|
|
40
|
+
raise ValueError("Entity 'name' is required")
|
|
41
|
+
self.type = self.type.strip()
|
|
42
|
+
self.name = self.name.strip()
|
|
43
|
+
|
|
44
|
+
def matches(self, query: str) -> bool:
|
|
45
|
+
"""Check if this entity matches a query (name, ID, or identifier)."""
|
|
46
|
+
if not query:
|
|
47
|
+
return False
|
|
48
|
+
q = query.lower()
|
|
49
|
+
if q == self.id or q == self.name.lower():
|
|
50
|
+
return True
|
|
51
|
+
if any(q in ident.lower() for ident in self.identifiers):
|
|
52
|
+
return True
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
def to_dict(self) -> dict[str, Any]:
|
|
56
|
+
return {
|
|
57
|
+
"id": self.id,
|
|
58
|
+
"type": self.type,
|
|
59
|
+
"name": self.name,
|
|
60
|
+
"identifiers": list(self.identifiers),
|
|
61
|
+
"attributes": dict(self.attributes),
|
|
62
|
+
"created_at": self.created_at.isoformat(),
|
|
63
|
+
"updated_at": self.updated_at.isoformat(),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_dict(cls, data: dict[str, Any]) -> "Entity":
|
|
68
|
+
return cls(
|
|
69
|
+
id=data.get("id", str(uuid4())),
|
|
70
|
+
type=data.get("type", ""),
|
|
71
|
+
name=data.get("name", ""),
|
|
72
|
+
identifiers=data.get("identifiers", []),
|
|
73
|
+
attributes=data.get("attributes", {}),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def __repr__(self) -> str:
|
|
77
|
+
return f"Entity(type={self.type!r}, name={self.name!r})"
|
|
78
|
+
|
|
79
|
+
def __eq__(self, other: object) -> bool:
|
|
80
|
+
if not isinstance(other, Entity):
|
|
81
|
+
return False
|
|
82
|
+
return self.id == other.id
|
|
83
|
+
|
|
84
|
+
def __hash__(self) -> int:
|
|
85
|
+
return hash(self.id)
|
cortext/core/graph.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MemoryGraph — collection of memories, entities, and relations.
|
|
3
|
+
|
|
4
|
+
Enxuto, sem feature flags, sem BFS/Louvain/PageRank. Operations are
|
|
5
|
+
O(1) for direct lookup, O(n) for scans. The Dream Agent (background
|
|
6
|
+
worker) handles consolidation separately.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Iterator, Optional
|
|
13
|
+
|
|
14
|
+
from cortext.core.memory import Memory
|
|
15
|
+
from cortext.core.entity import Entity
|
|
16
|
+
from cortext.core.relation import Relation
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class RecallResult:
|
|
21
|
+
"""Result of a memory recall operation."""
|
|
22
|
+
|
|
23
|
+
memories: list[Memory] = field(default_factory=list)
|
|
24
|
+
entities: list[Entity] = field(default_factory=list)
|
|
25
|
+
relations: list[Relation] = field(default_factory=list)
|
|
26
|
+
metrics: dict[str, Any] = field(default_factory=dict)
|
|
27
|
+
|
|
28
|
+
def __len__(self) -> int:
|
|
29
|
+
return len(self.memories)
|
|
30
|
+
|
|
31
|
+
def is_empty(self) -> bool:
|
|
32
|
+
return len(self.memories) == 0 and len(self.entities) == 0 and len(self.relations) == 0
|
|
33
|
+
|
|
34
|
+
def to_dict(self) -> dict[str, Any]:
|
|
35
|
+
return {
|
|
36
|
+
"memories": [m.to_dict() for m in self.memories],
|
|
37
|
+
"entities": [e.to_dict() for e in self.entities],
|
|
38
|
+
"relations": [r.to_dict() for r in self.relations],
|
|
39
|
+
"metrics": self.metrics,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MemoryGraph:
|
|
44
|
+
"""
|
|
45
|
+
In-memory graph of memories, entities, and relations.
|
|
46
|
+
|
|
47
|
+
API enxuto: 5 métodos públicos (add_memory, add_entity, add_relation,
|
|
48
|
+
get_*, iter_*) + 1 busca livre (find). Sem indexação sofisticada
|
|
49
|
+
(sem inverted index, sem BFS expansion). Dream Agent pode adicionar
|
|
50
|
+
indexação opcional se necessário.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, namespace: str = "default") -> None:
|
|
54
|
+
"""Initialize an in-memory graph (no persistence in core)."""
|
|
55
|
+
self.namespace = namespace
|
|
56
|
+
self._memories: dict[str, Memory] = {}
|
|
57
|
+
self._entities: dict[str, Entity] = {}
|
|
58
|
+
self._relations: dict[str, Relation] = {}
|
|
59
|
+
|
|
60
|
+
# === Add operations ===
|
|
61
|
+
|
|
62
|
+
def add_memory(self, memory: Memory) -> Memory:
|
|
63
|
+
"""Add a memory. Returns the memory (with id)."""
|
|
64
|
+
if not isinstance(memory, Memory):
|
|
65
|
+
raise TypeError(f"expected Memory, got {type(memory).__name__}")
|
|
66
|
+
self._memories[memory.id] = memory
|
|
67
|
+
return memory
|
|
68
|
+
|
|
69
|
+
def add_entity(self, entity: Entity) -> Entity:
|
|
70
|
+
"""Add an entity. Returns the entity (with id)."""
|
|
71
|
+
if not isinstance(entity, Entity):
|
|
72
|
+
raise TypeError(f"expected Entity, got {type(entity).__name__}")
|
|
73
|
+
self._entities[entity.id] = entity
|
|
74
|
+
return entity
|
|
75
|
+
|
|
76
|
+
def add_relation(self, relation: Relation) -> Relation:
|
|
77
|
+
"""Add a relation. Returns the relation (with id)."""
|
|
78
|
+
if not isinstance(relation, Relation):
|
|
79
|
+
raise TypeError(f"expected Relation, got {type(relation).__name__}")
|
|
80
|
+
self._relations[relation.id] = relation
|
|
81
|
+
return relation
|
|
82
|
+
|
|
83
|
+
# === Get operations ===
|
|
84
|
+
|
|
85
|
+
def get_memory(self, memory_id: str) -> Optional[Memory]:
|
|
86
|
+
return self._memories.get(memory_id)
|
|
87
|
+
|
|
88
|
+
def get_entity(self, entity_id: str) -> Optional[Entity]:
|
|
89
|
+
return self._entities.get(entity_id)
|
|
90
|
+
|
|
91
|
+
def get_relation(self, relation_id: str) -> Optional[Relation]:
|
|
92
|
+
return self._relations.get(relation_id)
|
|
93
|
+
|
|
94
|
+
# === Iteration ===
|
|
95
|
+
|
|
96
|
+
def iter_memories(self) -> Iterator[Memory]:
|
|
97
|
+
return iter(self._memories.values())
|
|
98
|
+
|
|
99
|
+
def iter_entities(self) -> Iterator[Entity]:
|
|
100
|
+
return iter(self._entities.values())
|
|
101
|
+
|
|
102
|
+
def iter_relations(self) -> Iterator[Relation]:
|
|
103
|
+
return iter(self._relations.values())
|
|
104
|
+
|
|
105
|
+
def all_memories(self) -> list[Memory]:
|
|
106
|
+
return list(self._memories.values())
|
|
107
|
+
|
|
108
|
+
def all_entities(self) -> list[Entity]:
|
|
109
|
+
return list(self._entities.values())
|
|
110
|
+
|
|
111
|
+
def all_relations(self) -> list[Relation]:
|
|
112
|
+
return list(self._relations.values())
|
|
113
|
+
|
|
114
|
+
# === Find operations ===
|
|
115
|
+
|
|
116
|
+
def find_memories(
|
|
117
|
+
self,
|
|
118
|
+
who: Optional[str] = None,
|
|
119
|
+
where: Optional[str] = None,
|
|
120
|
+
what_contains: Optional[str] = None,
|
|
121
|
+
) -> list[Memory]:
|
|
122
|
+
"""
|
|
123
|
+
Find memories matching filters. O(n) scan.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
who: filter by participant (case-insensitive substring)
|
|
127
|
+
where: filter by namespace/exact match
|
|
128
|
+
what_contains: filter by substring in 'what' field
|
|
129
|
+
"""
|
|
130
|
+
results: list[Memory] = []
|
|
131
|
+
for mem in self._memories.values():
|
|
132
|
+
if who is not None and not mem.is_about(who):
|
|
133
|
+
continue
|
|
134
|
+
if where is not None and mem.where != where:
|
|
135
|
+
continue
|
|
136
|
+
if what_contains is not None and what_contains.lower() not in mem.what.lower():
|
|
137
|
+
continue
|
|
138
|
+
results.append(mem)
|
|
139
|
+
return results
|
|
140
|
+
|
|
141
|
+
def find_entities_by_name(self, name: str) -> list[Entity]:
|
|
142
|
+
"""Find entities by name (case-insensitive exact match)."""
|
|
143
|
+
name_lower = name.lower()
|
|
144
|
+
return [e for e in self._entities.values() if e.name.lower() == name_lower]
|
|
145
|
+
|
|
146
|
+
def find_relations(
|
|
147
|
+
self,
|
|
148
|
+
from_id: Optional[str] = None,
|
|
149
|
+
to_id: Optional[str] = None,
|
|
150
|
+
relation_type: Optional[str] = None,
|
|
151
|
+
) -> list[Relation]:
|
|
152
|
+
"""Find relations matching filters. O(n) scan."""
|
|
153
|
+
results: list[Relation] = []
|
|
154
|
+
for rel in self._relations.values():
|
|
155
|
+
if from_id is not None and rel.from_id != from_id:
|
|
156
|
+
continue
|
|
157
|
+
if to_id is not None and rel.to_id != to_id:
|
|
158
|
+
continue
|
|
159
|
+
if relation_type is not None and rel.relation_type != relation_type.lower():
|
|
160
|
+
continue
|
|
161
|
+
results.append(rel)
|
|
162
|
+
return results
|
|
163
|
+
|
|
164
|
+
# === Persistence ===
|
|
165
|
+
|
|
166
|
+
def to_dict(self) -> dict[str, Any]:
|
|
167
|
+
"""Serialize the whole graph to a plain dict (JSON-ready)."""
|
|
168
|
+
return {
|
|
169
|
+
"namespace": self.namespace,
|
|
170
|
+
"memories": [m.to_dict() for m in self._memories.values()],
|
|
171
|
+
"entities": [e.to_dict() for e in self._entities.values()],
|
|
172
|
+
"relations": [r.to_dict() for r in self._relations.values()],
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
@classmethod
|
|
176
|
+
def from_dict(cls, data: dict[str, Any]) -> "MemoryGraph":
|
|
177
|
+
"""Reconstruct a graph from a dict produced by ``to_dict``."""
|
|
178
|
+
graph = cls(namespace=data.get("namespace", "default"))
|
|
179
|
+
for m in data.get("memories", []):
|
|
180
|
+
graph.add_memory(Memory.from_dict(m))
|
|
181
|
+
for e in data.get("entities", []):
|
|
182
|
+
graph.add_entity(Entity.from_dict(e))
|
|
183
|
+
for r in data.get("relations", []):
|
|
184
|
+
graph.add_relation(Relation.from_dict(r))
|
|
185
|
+
return graph
|
|
186
|
+
|
|
187
|
+
def save(self, path: Any) -> None:
|
|
188
|
+
"""Persist the graph to a JSON file at ``path`` (atomic write)."""
|
|
189
|
+
import json
|
|
190
|
+
import os
|
|
191
|
+
from pathlib import Path
|
|
192
|
+
|
|
193
|
+
path = Path(path)
|
|
194
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
195
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
196
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
197
|
+
json.dump(self.to_dict(), f, ensure_ascii=False, default=str)
|
|
198
|
+
os.replace(tmp, path)
|
|
199
|
+
|
|
200
|
+
@classmethod
|
|
201
|
+
def load(cls, path: Any, namespace: str = "default") -> "MemoryGraph":
|
|
202
|
+
"""Load a graph from a JSON file. Returns an empty graph if missing."""
|
|
203
|
+
import json
|
|
204
|
+
from pathlib import Path
|
|
205
|
+
|
|
206
|
+
path = Path(path)
|
|
207
|
+
if not path.exists():
|
|
208
|
+
return cls(namespace=namespace)
|
|
209
|
+
with open(path, encoding="utf-8") as f:
|
|
210
|
+
return cls.from_dict(json.load(f))
|
|
211
|
+
|
|
212
|
+
# === Stats ===
|
|
213
|
+
|
|
214
|
+
def stats(self) -> dict[str, Any]:
|
|
215
|
+
return {
|
|
216
|
+
"namespace": self.namespace,
|
|
217
|
+
"total_memories": len(self._memories),
|
|
218
|
+
"total_entities": len(self._entities),
|
|
219
|
+
"total_relations": len(self._relations),
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
def __len__(self) -> int:
|
|
223
|
+
return len(self._memories)
|
|
224
|
+
|
|
225
|
+
def __repr__(self) -> str:
|
|
226
|
+
return f"MemoryGraph(ns={self.namespace!r}, M={len(self._memories)}, E={len(self._entities)}, R={len(self._relations)})"
|