memplex 3.2.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.
- memnex/__init__.py +31 -0
- memnex/__main__.py +6 -0
- memnex/_plugin/.claude-plugin/plugin.json +24 -0
- memnex/_plugin/.mcp.json +9 -0
- memnex/_plugin/__init__.py +0 -0
- memnex/_plugin/hooks/hooks.json +43 -0
- memnex/_plugin/scripts/hook-runner.py +166 -0
- memnex/_plugin/skills/mem-explore/SKILL.md +83 -0
- memnex/_plugin/skills/mem-manage/SKILL.md +92 -0
- memnex/_plugin/skills/mem-search/SKILL.md +85 -0
- memnex/_plugin/skills/mem-write/SKILL.md +78 -0
- memnex/adapters/__init__.py +14 -0
- memnex/adapters/claude_skill.py +169 -0
- memnex/adapters/cli.py +525 -0
- memnex/adapters/http_api.py +314 -0
- memnex/adapters/mcp_server.py +448 -0
- memnex/compaction.py +563 -0
- memnex/config.py +366 -0
- memnex/core/__init__.py +13 -0
- memnex/core/associator/__init__.py +8 -0
- memnex/core/associator/domain_classifier.py +75 -0
- memnex/core/associator/entity_aligner.py +127 -0
- memnex/core/associator/ref_linker.py +197 -0
- memnex/core/associator/term_mapper.py +77 -0
- memnex/core/dictionaries/__init__.py +50 -0
- memnex/core/engine.py +667 -0
- memnex/core/extractors/__init__.py +15 -0
- memnex/core/extractors/docx.py +97 -0
- memnex/core/extractors/image.py +233 -0
- memnex/core/extractors/markdown.py +139 -0
- memnex/core/extractors/pdf.py +133 -0
- memnex/core/extractors/vision_mapper.py +131 -0
- memnex/core/handlers/__init__.py +7 -0
- memnex/core/handlers/clipboard.py +40 -0
- memnex/core/handlers/file_handler.py +62 -0
- memnex/core/handlers/url_handler.py +132 -0
- memnex/llm/__init__.py +25 -0
- memnex/llm/enhancer.py +226 -0
- memnex/llm/fallback_chain.py +87 -0
- memnex/llm/injection_guard.py +178 -0
- memnex/llm/provider.py +130 -0
- memnex/llm/providers/__init__.py +22 -0
- memnex/llm/providers/anthropic.py +135 -0
- memnex/llm/providers/local.py +135 -0
- memnex/llm/providers/rule_based.py +68 -0
- memnex/llm/sanitizer.py +67 -0
- memnex/models/__init__.py +68 -0
- memnex/models/feedback.py +42 -0
- memnex/models/graph.py +33 -0
- memnex/models/memory.py +102 -0
- memnex/models/misc.py +185 -0
- memnex/models/paragraph.py +45 -0
- memnex/models/search.py +51 -0
- memnex/models/source.py +23 -0
- memnex/models/task.py +62 -0
- memnex/processing/__init__.py +1 -0
- memnex/processing/graph_builder.py +278 -0
- memnex/processing/merger/__init__.py +6 -0
- memnex/processing/merger/confidence_calculator.py +127 -0
- memnex/processing/merger/conflict_resolver.py +116 -0
- memnex/retrieval/__init__.py +1 -0
- memnex/retrieval/dedup.py +386 -0
- memnex/retrieval/embedding.py +289 -0
- memnex/retrieval/reranker.py +299 -0
- memnex/service.py +902 -0
- memnex/storage/__init__.py +65 -0
- memnex/storage/base.py +132 -0
- memnex/storage/changelog.py +106 -0
- memnex/storage/feedback.py +486 -0
- memnex/storage/lite/__init__.py +5 -0
- memnex/storage/lite/store.py +606 -0
- memnex/storage/vector.py +265 -0
- memnex/wiki/__init__.py +11 -0
- memnex/wiki/community.py +221 -0
- memnex/wiki/compiler.py +545 -0
- memnex/wiki/generator.py +270 -0
- memnex/wiki/search.py +282 -0
- memnex/worker.py +412 -0
- memplex-3.2.0.dist-info/METADATA +37 -0
- memplex-3.2.0.dist-info/RECORD +83 -0
- memplex-3.2.0.dist-info/WHEEL +5 -0
- memplex-3.2.0.dist-info/entry_points.txt +2 -0
- memplex-3.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
"""LiteMemoryStore -- in-memory + JSON persistence backend.
|
|
2
|
+
|
|
3
|
+
Data paths::
|
|
4
|
+
|
|
5
|
+
~/.memnex/memory.json Functions + graph edges
|
|
6
|
+
~/.memnex/changelog.json Changelog events (via ChangelogStore)
|
|
7
|
+
|
|
8
|
+
All data is held in memory and flushed to JSON on every write.
|
|
9
|
+
Atomic replacement (write-to-temp + rename) guards against partial writes.
|
|
10
|
+
|
|
11
|
+
Single-thread assumption: optimistic lock is skipped.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import copy
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import math
|
|
20
|
+
import tempfile
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Dict, List, Optional
|
|
24
|
+
|
|
25
|
+
from memnex.models import (
|
|
26
|
+
BatchResult,
|
|
27
|
+
ChangelogEvent,
|
|
28
|
+
FieldValue,
|
|
29
|
+
Function,
|
|
30
|
+
GraphData,
|
|
31
|
+
GraphEdge,
|
|
32
|
+
MergeResult,
|
|
33
|
+
Observation,
|
|
34
|
+
SearchFilters,
|
|
35
|
+
SearchResult,
|
|
36
|
+
SourceDocument,
|
|
37
|
+
SourceType,
|
|
38
|
+
)
|
|
39
|
+
from memnex.storage.changelog import ChangelogStore
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ── Serialization helpers ────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _json_serializer(obj: Any) -> Any:
|
|
48
|
+
"""Default serializer for ``json.dumps``."""
|
|
49
|
+
if isinstance(obj, datetime):
|
|
50
|
+
return obj.isoformat()
|
|
51
|
+
if isinstance(obj, Path):
|
|
52
|
+
return str(obj)
|
|
53
|
+
if isinstance(obj, SourceType):
|
|
54
|
+
return obj.value
|
|
55
|
+
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _serialize_field_value(fv: FieldValue) -> dict:
|
|
59
|
+
return {
|
|
60
|
+
"desc": fv.desc,
|
|
61
|
+
"sources": fv.sources,
|
|
62
|
+
"source_method": fv.source_method,
|
|
63
|
+
"weight": fv.weight,
|
|
64
|
+
"observation": fv.observation,
|
|
65
|
+
"created_at": (
|
|
66
|
+
fv.created_at.isoformat() if isinstance(fv.created_at, datetime) else fv.created_at
|
|
67
|
+
),
|
|
68
|
+
"status": fv.status,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _deserialize_field_value(d: dict) -> FieldValue:
|
|
73
|
+
created_at = d.get("created_at")
|
|
74
|
+
if isinstance(created_at, str):
|
|
75
|
+
created_at = datetime.fromisoformat(created_at)
|
|
76
|
+
return FieldValue(
|
|
77
|
+
desc=d["desc"],
|
|
78
|
+
sources=d.get("sources", []),
|
|
79
|
+
source_method=d.get("source_method", "rule_based"),
|
|
80
|
+
weight=d.get("weight", 1.0),
|
|
81
|
+
observation=d.get("observation"),
|
|
82
|
+
created_at=created_at,
|
|
83
|
+
status=d.get("status", "active"),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _serialize_function(func: Function) -> dict:
|
|
88
|
+
return {
|
|
89
|
+
"id": func.id,
|
|
90
|
+
"memory_type": func.memory_type,
|
|
91
|
+
"name": func.name,
|
|
92
|
+
"name_normalized": func.name_normalized,
|
|
93
|
+
"domain": func.domain,
|
|
94
|
+
"confidence": func.confidence,
|
|
95
|
+
"source_type": func.source_type.value if isinstance(func.source_type, SourceType) else func.source_type,
|
|
96
|
+
"owner": func.owner,
|
|
97
|
+
"version": func.version,
|
|
98
|
+
"created_at": func.created_at,
|
|
99
|
+
"updated_at": func.updated_at,
|
|
100
|
+
"origin_session": func.origin_session,
|
|
101
|
+
"access_count": func.access_count,
|
|
102
|
+
"last_accessed_at": func.last_accessed_at,
|
|
103
|
+
"source_paragraphs": func.source_paragraphs,
|
|
104
|
+
"needs_review": func.needs_review,
|
|
105
|
+
"needs_review_until": func.needs_review_until,
|
|
106
|
+
"content_hash": func.content_hash,
|
|
107
|
+
"trigger": [_serialize_field_value(fv) for fv in func.trigger],
|
|
108
|
+
"condition": [_serialize_field_value(fv) for fv in func.condition],
|
|
109
|
+
"action": [_serialize_field_value(fv) for fv in func.action],
|
|
110
|
+
"benefit": [_serialize_field_value(fv) for fv in func.benefit],
|
|
111
|
+
"attributes": func.attributes,
|
|
112
|
+
"cross_references": func.cross_references,
|
|
113
|
+
"priority_from_source": func.priority_from_source,
|
|
114
|
+
"source_authority": func.source_authority,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _deserialize_function(d: dict) -> Function:
|
|
119
|
+
source_type = d.get("source_type", "wiki")
|
|
120
|
+
if isinstance(source_type, str):
|
|
121
|
+
try:
|
|
122
|
+
source_type = SourceType(source_type)
|
|
123
|
+
except ValueError:
|
|
124
|
+
source_type = SourceType.WIKI
|
|
125
|
+
return Function(
|
|
126
|
+
id=d["id"],
|
|
127
|
+
memory_type=d.get("memory_type", "function"),
|
|
128
|
+
name=d.get("name", ""),
|
|
129
|
+
name_normalized=d.get("name_normalized", ""),
|
|
130
|
+
domain=d.get("domain"),
|
|
131
|
+
confidence=d.get("confidence", 1.0),
|
|
132
|
+
source_type=source_type,
|
|
133
|
+
owner=d.get("owner"),
|
|
134
|
+
version=d.get("version", 1),
|
|
135
|
+
created_at=d.get("created_at"),
|
|
136
|
+
updated_at=d.get("updated_at"),
|
|
137
|
+
origin_session=d.get("origin_session"),
|
|
138
|
+
access_count=d.get("access_count", 0),
|
|
139
|
+
last_accessed_at=d.get("last_accessed_at"),
|
|
140
|
+
source_paragraphs=d.get("source_paragraphs", []),
|
|
141
|
+
needs_review=d.get("needs_review", False),
|
|
142
|
+
needs_review_until=d.get("needs_review_until"),
|
|
143
|
+
content_hash=d.get("content_hash"),
|
|
144
|
+
trigger=[_deserialize_field_value(fv) for fv in d.get("trigger", [])],
|
|
145
|
+
condition=[_deserialize_field_value(fv) for fv in d.get("condition", [])],
|
|
146
|
+
action=[_deserialize_field_value(fv) for fv in d.get("action", [])],
|
|
147
|
+
benefit=[_deserialize_field_value(fv) for fv in d.get("benefit", [])],
|
|
148
|
+
attributes=d.get("attributes", {}),
|
|
149
|
+
cross_references=d.get("cross_references", []),
|
|
150
|
+
priority_from_source=d.get("priority_from_source"),
|
|
151
|
+
source_authority=d.get("source_authority"),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _serialize_edge(edge: GraphEdge) -> dict:
|
|
156
|
+
return {
|
|
157
|
+
"source": edge.source,
|
|
158
|
+
"target": edge.target,
|
|
159
|
+
"edge_type": edge.edge_type,
|
|
160
|
+
"weight": edge.weight,
|
|
161
|
+
"evidence": edge.evidence,
|
|
162
|
+
"created_at": (
|
|
163
|
+
edge.created_at.isoformat() if isinstance(edge.created_at, datetime) else edge.created_at
|
|
164
|
+
),
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _deserialize_edge(d: dict) -> GraphEdge:
|
|
169
|
+
created_at = d.get("created_at")
|
|
170
|
+
if isinstance(created_at, str):
|
|
171
|
+
created_at = datetime.fromisoformat(created_at)
|
|
172
|
+
return GraphEdge(
|
|
173
|
+
source=d["source"],
|
|
174
|
+
target=d["target"],
|
|
175
|
+
edge_type=d["edge_type"],
|
|
176
|
+
weight=d.get("weight", 1.0),
|
|
177
|
+
evidence=d.get("evidence", []),
|
|
178
|
+
created_at=created_at,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ── Merge helpers ────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _merge_field_values(
|
|
186
|
+
existing: List[FieldValue],
|
|
187
|
+
incoming: List[FieldValue],
|
|
188
|
+
) -> List[FieldValue]:
|
|
189
|
+
"""Merge incoming FieldValues into existing. Duplicates (by desc) are
|
|
190
|
+
skipped; weight and observation are taken from the newer entry.
|
|
191
|
+
"""
|
|
192
|
+
seen = {fv.desc for fv in existing}
|
|
193
|
+
merged = list(existing)
|
|
194
|
+
for fv in incoming:
|
|
195
|
+
if fv.desc not in seen:
|
|
196
|
+
merged.append(fv)
|
|
197
|
+
seen.add(fv.desc)
|
|
198
|
+
return merged
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _normalize_name(name: str) -> str:
|
|
202
|
+
"""Produce a normalised form for dedup matching."""
|
|
203
|
+
return name.strip().lower()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ── LiteMemoryStore ──────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class LiteMemoryStore:
|
|
210
|
+
"""InMemory + JSON persistence backend.
|
|
211
|
+
|
|
212
|
+
Parameters
|
|
213
|
+
----------
|
|
214
|
+
path:
|
|
215
|
+
Root JSON file path. Defaults to ``~/.memnex/memory.json``.
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
def __init__(self, path: Optional[Path] = None) -> None:
|
|
219
|
+
self._path = path or Path("~/.memnex/memory.json").expanduser()
|
|
220
|
+
self._functions: Dict[str, Function] = {}
|
|
221
|
+
self._name_index: Dict[str, str] = {} # name_normalized -> func_id
|
|
222
|
+
self._edges: List[GraphEdge] = []
|
|
223
|
+
self._observations: List[Observation] = []
|
|
224
|
+
self._changelog = ChangelogStore(
|
|
225
|
+
path=self._path.parent / "changelog.json"
|
|
226
|
+
)
|
|
227
|
+
self._load()
|
|
228
|
+
|
|
229
|
+
# ── Public: Write ───────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
def add(self, func: Function, source: SourceDocument) -> None:
|
|
232
|
+
norm = _normalize_name(func.name_normalized or func.name)
|
|
233
|
+
existing_id = self._name_index.get(norm)
|
|
234
|
+
|
|
235
|
+
if existing_id and existing_id in self._functions:
|
|
236
|
+
existing = self._functions[existing_id]
|
|
237
|
+
# Merge FieldValues
|
|
238
|
+
existing.trigger = _merge_field_values(existing.trigger, func.trigger)
|
|
239
|
+
existing.condition = _merge_field_values(existing.condition, func.condition)
|
|
240
|
+
existing.action = _merge_field_values(existing.action, func.action)
|
|
241
|
+
existing.benefit = _merge_field_values(existing.benefit, func.benefit)
|
|
242
|
+
# Merge source paragraphs
|
|
243
|
+
for sp in func.source_paragraphs:
|
|
244
|
+
if sp not in existing.source_paragraphs:
|
|
245
|
+
existing.source_paragraphs.append(sp)
|
|
246
|
+
existing.updated_at = datetime.utcnow().isoformat()
|
|
247
|
+
existing.version += 1
|
|
248
|
+
|
|
249
|
+
self._changelog.append(ChangelogEvent(
|
|
250
|
+
func_id=existing.id,
|
|
251
|
+
timestamp=datetime.now(),
|
|
252
|
+
event_type="updated",
|
|
253
|
+
description=f"Merged fields from source",
|
|
254
|
+
source=getattr(source, "source_path", None) or getattr(source, "url", "") or "",
|
|
255
|
+
actor="system",
|
|
256
|
+
))
|
|
257
|
+
else:
|
|
258
|
+
self._functions[func.id] = func
|
|
259
|
+
self._name_index[norm] = func.id
|
|
260
|
+
|
|
261
|
+
self._changelog.append(ChangelogEvent(
|
|
262
|
+
func_id=func.id,
|
|
263
|
+
timestamp=datetime.now(),
|
|
264
|
+
event_type="created",
|
|
265
|
+
description=f"Created function: {func.name}",
|
|
266
|
+
source=getattr(source, "source_path", None) or getattr(source, "url", "") or "",
|
|
267
|
+
actor="system",
|
|
268
|
+
))
|
|
269
|
+
|
|
270
|
+
self._save()
|
|
271
|
+
|
|
272
|
+
def add_batch(
|
|
273
|
+
self,
|
|
274
|
+
funcs: List[Function],
|
|
275
|
+
sources: List[SourceDocument],
|
|
276
|
+
) -> BatchResult:
|
|
277
|
+
result = BatchResult(total=len(funcs))
|
|
278
|
+
for func, src in zip(funcs, sources):
|
|
279
|
+
try:
|
|
280
|
+
self.add(func, src)
|
|
281
|
+
result.succeeded += 1
|
|
282
|
+
except Exception as exc:
|
|
283
|
+
result.failed_items.append({
|
|
284
|
+
"func_id": func.id,
|
|
285
|
+
"name": func.name,
|
|
286
|
+
"error": str(exc),
|
|
287
|
+
})
|
|
288
|
+
return result
|
|
289
|
+
|
|
290
|
+
def add_observation(self, observation: Observation) -> None:
|
|
291
|
+
self._observations.append(observation)
|
|
292
|
+
|
|
293
|
+
def increment_access(self, func_id: str) -> None:
|
|
294
|
+
func = self._functions.get(func_id)
|
|
295
|
+
if func is None:
|
|
296
|
+
return
|
|
297
|
+
func.access_count += 1
|
|
298
|
+
func.last_accessed_at = datetime.utcnow().isoformat()
|
|
299
|
+
self._save()
|
|
300
|
+
|
|
301
|
+
# ── Public: Retrieval ───────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
def vector_search(self, text: str, top_k: int = 5) -> List[SearchResult]:
|
|
304
|
+
"""Basic TF-IDF cosine similarity search over Function text."""
|
|
305
|
+
query_words = set(text.lower().split())
|
|
306
|
+
scored: List[tuple] = []
|
|
307
|
+
|
|
308
|
+
for func in self._functions.values():
|
|
309
|
+
func_text = self._function_to_search_text(func)
|
|
310
|
+
func_words = set(func_text.lower().split())
|
|
311
|
+
|
|
312
|
+
if not query_words or not func_words:
|
|
313
|
+
score = 0.0
|
|
314
|
+
else:
|
|
315
|
+
intersection = query_words & func_words
|
|
316
|
+
union = query_words | func_words
|
|
317
|
+
score = len(intersection) / (len(union) + 1e-10)
|
|
318
|
+
|
|
319
|
+
scored.append((score, func))
|
|
320
|
+
|
|
321
|
+
scored.sort(key=lambda x: x[0], reverse=True)
|
|
322
|
+
results: List[SearchResult] = []
|
|
323
|
+
for score, func in scored[:top_k]:
|
|
324
|
+
if score <= 0:
|
|
325
|
+
continue
|
|
326
|
+
results.append(SearchResult(
|
|
327
|
+
func_id=func.id,
|
|
328
|
+
name=func.name,
|
|
329
|
+
domain=func.domain or "",
|
|
330
|
+
relevance_score=score,
|
|
331
|
+
summary=self._function_to_search_text(func),
|
|
332
|
+
source_type=func.source_type,
|
|
333
|
+
created_at=func.created_at,
|
|
334
|
+
updated_at=func.updated_at,
|
|
335
|
+
origin=func.origin_session or "",
|
|
336
|
+
))
|
|
337
|
+
return results
|
|
338
|
+
|
|
339
|
+
def fts_search(self, text: str, top_k: int = 10) -> List[SearchResult]:
|
|
340
|
+
"""Keyword matching search."""
|
|
341
|
+
query_lower = text.lower()
|
|
342
|
+
scored: List[tuple] = []
|
|
343
|
+
|
|
344
|
+
for func in self._functions.values():
|
|
345
|
+
func_text = self._function_to_search_text(func).lower()
|
|
346
|
+
count = func_text.count(query_lower)
|
|
347
|
+
if count > 0:
|
|
348
|
+
scored.append((count, func))
|
|
349
|
+
|
|
350
|
+
scored.sort(key=lambda x: x[0], reverse=True)
|
|
351
|
+
results: List[SearchResult] = []
|
|
352
|
+
for count, func in scored[:top_k]:
|
|
353
|
+
results.append(SearchResult(
|
|
354
|
+
func_id=func.id,
|
|
355
|
+
name=func.name,
|
|
356
|
+
domain=func.domain or "",
|
|
357
|
+
relevance_score=min(count / 5.0, 1.0),
|
|
358
|
+
summary=self._function_to_search_text(func),
|
|
359
|
+
source_type=func.source_type,
|
|
360
|
+
created_at=func.created_at,
|
|
361
|
+
updated_at=func.updated_at,
|
|
362
|
+
origin=func.origin_session or "",
|
|
363
|
+
))
|
|
364
|
+
return results
|
|
365
|
+
|
|
366
|
+
def filter(self, filters: SearchFilters) -> List[Function]:
|
|
367
|
+
results: List[Function] = []
|
|
368
|
+
for func in self._functions.values():
|
|
369
|
+
if not self._matches_filter(func, filters):
|
|
370
|
+
continue
|
|
371
|
+
results.append(func)
|
|
372
|
+
return results
|
|
373
|
+
|
|
374
|
+
# ── Public: Read ────────────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
def get(self, func_id: str) -> Optional[Function]:
|
|
377
|
+
return self._functions.get(func_id)
|
|
378
|
+
|
|
379
|
+
def get_neighbors(
|
|
380
|
+
self,
|
|
381
|
+
func_id: str,
|
|
382
|
+
edge_types: Optional[List[str]] = None,
|
|
383
|
+
max_hops: int = 1,
|
|
384
|
+
) -> List[Function]:
|
|
385
|
+
if max_hops < 1:
|
|
386
|
+
return []
|
|
387
|
+
|
|
388
|
+
# BFS
|
|
389
|
+
visited: set = {func_id}
|
|
390
|
+
current_level = {func_id}
|
|
391
|
+
neighbor_ids: set = set()
|
|
392
|
+
|
|
393
|
+
for _ in range(max_hops):
|
|
394
|
+
next_level: set = set()
|
|
395
|
+
for fid in current_level:
|
|
396
|
+
for edge in self._edges:
|
|
397
|
+
if edge_types and edge.edge_type not in edge_types:
|
|
398
|
+
continue
|
|
399
|
+
if edge.source == fid and edge.target not in visited:
|
|
400
|
+
next_level.add(edge.target)
|
|
401
|
+
elif edge.target == fid and edge.source not in visited:
|
|
402
|
+
next_level.add(edge.source)
|
|
403
|
+
visited |= next_level
|
|
404
|
+
neighbor_ids |= next_level
|
|
405
|
+
current_level = next_level
|
|
406
|
+
|
|
407
|
+
return [
|
|
408
|
+
self._functions[fid]
|
|
409
|
+
for fid in neighbor_ids
|
|
410
|
+
if fid in self._functions
|
|
411
|
+
]
|
|
412
|
+
|
|
413
|
+
def get_graph(self, func_ids: Optional[List[str]] = None) -> GraphData:
|
|
414
|
+
if func_ids is None:
|
|
415
|
+
nodes = list(self._functions.values())
|
|
416
|
+
edges = list(self._edges)
|
|
417
|
+
else:
|
|
418
|
+
id_set = set(func_ids)
|
|
419
|
+
nodes = [
|
|
420
|
+
self._functions[fid]
|
|
421
|
+
for fid in func_ids
|
|
422
|
+
if fid in self._functions
|
|
423
|
+
]
|
|
424
|
+
edges = [
|
|
425
|
+
e for e in self._edges
|
|
426
|
+
if e.source in id_set or e.target in id_set
|
|
427
|
+
]
|
|
428
|
+
return GraphData(nodes=nodes, edges=edges)
|
|
429
|
+
|
|
430
|
+
def get_timeline(self, func_id: str, limit: int = 20) -> List[ChangelogEvent]:
|
|
431
|
+
return self._changelog.get_timeline(func_id, limit)
|
|
432
|
+
|
|
433
|
+
def list_functions(
|
|
434
|
+
self,
|
|
435
|
+
offset: int = 0,
|
|
436
|
+
limit: int = 1000,
|
|
437
|
+
owner: Optional[str] = None,
|
|
438
|
+
) -> List[Function]:
|
|
439
|
+
funcs = list(self._functions.values())
|
|
440
|
+
if owner is not None:
|
|
441
|
+
funcs = [f for f in funcs if f.owner == owner]
|
|
442
|
+
return funcs[offset : offset + limit]
|
|
443
|
+
|
|
444
|
+
# ── Public: Delete / Merge / Clear ──────────────────────────────
|
|
445
|
+
|
|
446
|
+
def delete(self, func_id: str) -> None:
|
|
447
|
+
self._functions.pop(func_id, None)
|
|
448
|
+
# Remove from name index
|
|
449
|
+
to_remove = [
|
|
450
|
+
norm for norm, fid in self._name_index.items() if fid == func_id
|
|
451
|
+
]
|
|
452
|
+
for norm in to_remove:
|
|
453
|
+
del self._name_index[norm]
|
|
454
|
+
# Remove edges referencing this function
|
|
455
|
+
self._edges = [
|
|
456
|
+
e for e in self._edges
|
|
457
|
+
if e.source != func_id and e.target != func_id
|
|
458
|
+
]
|
|
459
|
+
self._save()
|
|
460
|
+
|
|
461
|
+
def merge(self, sub_graph: GraphData) -> MergeResult:
|
|
462
|
+
result = MergeResult(merged=True)
|
|
463
|
+
# Merge nodes
|
|
464
|
+
for node in sub_graph.nodes:
|
|
465
|
+
func_id = getattr(node, "id", None)
|
|
466
|
+
if not func_id:
|
|
467
|
+
continue
|
|
468
|
+
if func_id in self._functions:
|
|
469
|
+
existing = self._functions[func_id]
|
|
470
|
+
if hasattr(node, "trigger"):
|
|
471
|
+
existing.trigger = _merge_field_values(
|
|
472
|
+
existing.trigger, node.trigger
|
|
473
|
+
)
|
|
474
|
+
if hasattr(node, "condition"):
|
|
475
|
+
existing.condition = _merge_field_values(
|
|
476
|
+
existing.condition, node.condition
|
|
477
|
+
)
|
|
478
|
+
if hasattr(node, "action"):
|
|
479
|
+
existing.action = _merge_field_values(
|
|
480
|
+
existing.action, node.action
|
|
481
|
+
)
|
|
482
|
+
if hasattr(node, "benefit"):
|
|
483
|
+
existing.benefit = _merge_field_values(
|
|
484
|
+
existing.benefit, node.benefit
|
|
485
|
+
)
|
|
486
|
+
existing.updated_at = datetime.utcnow().isoformat()
|
|
487
|
+
existing.version += 1
|
|
488
|
+
result.updated_functions += 1
|
|
489
|
+
else:
|
|
490
|
+
self._functions[func_id] = node
|
|
491
|
+
norm = _normalize_name(
|
|
492
|
+
getattr(node, "name_normalized", "")
|
|
493
|
+
or getattr(node, "name", "")
|
|
494
|
+
)
|
|
495
|
+
if norm:
|
|
496
|
+
self._name_index[norm] = func_id
|
|
497
|
+
result.new_functions += 1
|
|
498
|
+
|
|
499
|
+
# Merge edges (skip duplicates)
|
|
500
|
+
existing_edge_keys = {
|
|
501
|
+
(e.source, e.target, e.edge_type) for e in self._edges
|
|
502
|
+
}
|
|
503
|
+
for edge in sub_graph.edges:
|
|
504
|
+
key = (edge.source, edge.target, edge.edge_type)
|
|
505
|
+
if key not in existing_edge_keys:
|
|
506
|
+
self._edges.append(edge)
|
|
507
|
+
existing_edge_keys.add(key)
|
|
508
|
+
result.new_edges += 1
|
|
509
|
+
|
|
510
|
+
self._save()
|
|
511
|
+
return result
|
|
512
|
+
|
|
513
|
+
def clear(self) -> None:
|
|
514
|
+
self._functions.clear()
|
|
515
|
+
self._name_index.clear()
|
|
516
|
+
self._edges.clear()
|
|
517
|
+
self._observations.clear()
|
|
518
|
+
self._changelog.clear()
|
|
519
|
+
self._save()
|
|
520
|
+
|
|
521
|
+
# ── Persistence ─────────────────────────────────────────────────
|
|
522
|
+
|
|
523
|
+
def _save(self) -> None:
|
|
524
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
525
|
+
data = {
|
|
526
|
+
"functions": [_serialize_function(f) for f in self._functions.values()],
|
|
527
|
+
"edges": [_serialize_edge(e) for e in self._edges],
|
|
528
|
+
}
|
|
529
|
+
tmp_fd, tmp_path = tempfile.mkstemp(
|
|
530
|
+
dir=str(self._path.parent), suffix=".tmp"
|
|
531
|
+
)
|
|
532
|
+
try:
|
|
533
|
+
with open(tmp_fd, "w", encoding="utf-8") as fh:
|
|
534
|
+
json.dump(data, fh, default=_json_serializer, ensure_ascii=False, indent=2)
|
|
535
|
+
Path(tmp_path).replace(self._path)
|
|
536
|
+
except Exception:
|
|
537
|
+
Path(tmp_path).unlink(missing_ok=True)
|
|
538
|
+
raise
|
|
539
|
+
|
|
540
|
+
def _load(self) -> None:
|
|
541
|
+
if not self._path.exists():
|
|
542
|
+
return
|
|
543
|
+
try:
|
|
544
|
+
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
|
545
|
+
except Exception:
|
|
546
|
+
logger.warning("Failed to load memory from %s", self._path)
|
|
547
|
+
return
|
|
548
|
+
|
|
549
|
+
for fd in raw.get("functions", []):
|
|
550
|
+
func = _deserialize_function(fd)
|
|
551
|
+
self._functions[func.id] = func
|
|
552
|
+
norm = _normalize_name(func.name_normalized or func.name)
|
|
553
|
+
if norm:
|
|
554
|
+
self._name_index[norm] = func.id
|
|
555
|
+
|
|
556
|
+
for ed in raw.get("edges", []):
|
|
557
|
+
self._edges.append(_deserialize_edge(ed))
|
|
558
|
+
|
|
559
|
+
# ── Internal helpers ────────────────────────────────────────────
|
|
560
|
+
|
|
561
|
+
@staticmethod
|
|
562
|
+
def _function_to_search_text(func: Function) -> str:
|
|
563
|
+
parts = [func.name, func.domain or ""]
|
|
564
|
+
for fv in func.trigger:
|
|
565
|
+
parts.append(fv.desc)
|
|
566
|
+
for fv in func.action:
|
|
567
|
+
parts.append(fv.desc)
|
|
568
|
+
for fv in func.benefit:
|
|
569
|
+
parts.append(fv.desc)
|
|
570
|
+
return " ".join(parts)
|
|
571
|
+
|
|
572
|
+
@staticmethod
|
|
573
|
+
def _matches_filter(func: Function, filters: SearchFilters) -> bool:
|
|
574
|
+
if filters.domain and func.domain not in filters.domain:
|
|
575
|
+
return False
|
|
576
|
+
if (
|
|
577
|
+
filters.source_type
|
|
578
|
+
and func.source_type not in filters.source_type
|
|
579
|
+
):
|
|
580
|
+
return False
|
|
581
|
+
if filters.confidence_min is not None:
|
|
582
|
+
if func.confidence < filters.confidence_min:
|
|
583
|
+
return False
|
|
584
|
+
if filters.owner is not None and func.owner != filters.owner:
|
|
585
|
+
return False
|
|
586
|
+
if filters.needs_review is not None:
|
|
587
|
+
if func.needs_review != filters.needs_review:
|
|
588
|
+
return False
|
|
589
|
+
# Datetime filters: compare ISO strings lexicographically
|
|
590
|
+
if filters.updated_after is not None:
|
|
591
|
+
after = (
|
|
592
|
+
filters.updated_after.isoformat()
|
|
593
|
+
if hasattr(filters.updated_after, "isoformat")
|
|
594
|
+
else str(filters.updated_after)
|
|
595
|
+
)
|
|
596
|
+
if func.updated_at and func.updated_at < after:
|
|
597
|
+
return False
|
|
598
|
+
if filters.updated_before is not None:
|
|
599
|
+
before = (
|
|
600
|
+
filters.updated_before.isoformat()
|
|
601
|
+
if hasattr(filters.updated_before, "isoformat")
|
|
602
|
+
else str(filters.updated_before)
|
|
603
|
+
)
|
|
604
|
+
if func.updated_at and func.updated_at > before:
|
|
605
|
+
return False
|
|
606
|
+
return True
|