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.
Files changed (83) hide show
  1. memnex/__init__.py +31 -0
  2. memnex/__main__.py +6 -0
  3. memnex/_plugin/.claude-plugin/plugin.json +24 -0
  4. memnex/_plugin/.mcp.json +9 -0
  5. memnex/_plugin/__init__.py +0 -0
  6. memnex/_plugin/hooks/hooks.json +43 -0
  7. memnex/_plugin/scripts/hook-runner.py +166 -0
  8. memnex/_plugin/skills/mem-explore/SKILL.md +83 -0
  9. memnex/_plugin/skills/mem-manage/SKILL.md +92 -0
  10. memnex/_plugin/skills/mem-search/SKILL.md +85 -0
  11. memnex/_plugin/skills/mem-write/SKILL.md +78 -0
  12. memnex/adapters/__init__.py +14 -0
  13. memnex/adapters/claude_skill.py +169 -0
  14. memnex/adapters/cli.py +525 -0
  15. memnex/adapters/http_api.py +314 -0
  16. memnex/adapters/mcp_server.py +448 -0
  17. memnex/compaction.py +563 -0
  18. memnex/config.py +366 -0
  19. memnex/core/__init__.py +13 -0
  20. memnex/core/associator/__init__.py +8 -0
  21. memnex/core/associator/domain_classifier.py +75 -0
  22. memnex/core/associator/entity_aligner.py +127 -0
  23. memnex/core/associator/ref_linker.py +197 -0
  24. memnex/core/associator/term_mapper.py +77 -0
  25. memnex/core/dictionaries/__init__.py +50 -0
  26. memnex/core/engine.py +667 -0
  27. memnex/core/extractors/__init__.py +15 -0
  28. memnex/core/extractors/docx.py +97 -0
  29. memnex/core/extractors/image.py +233 -0
  30. memnex/core/extractors/markdown.py +139 -0
  31. memnex/core/extractors/pdf.py +133 -0
  32. memnex/core/extractors/vision_mapper.py +131 -0
  33. memnex/core/handlers/__init__.py +7 -0
  34. memnex/core/handlers/clipboard.py +40 -0
  35. memnex/core/handlers/file_handler.py +62 -0
  36. memnex/core/handlers/url_handler.py +132 -0
  37. memnex/llm/__init__.py +25 -0
  38. memnex/llm/enhancer.py +226 -0
  39. memnex/llm/fallback_chain.py +87 -0
  40. memnex/llm/injection_guard.py +178 -0
  41. memnex/llm/provider.py +130 -0
  42. memnex/llm/providers/__init__.py +22 -0
  43. memnex/llm/providers/anthropic.py +135 -0
  44. memnex/llm/providers/local.py +135 -0
  45. memnex/llm/providers/rule_based.py +68 -0
  46. memnex/llm/sanitizer.py +67 -0
  47. memnex/models/__init__.py +68 -0
  48. memnex/models/feedback.py +42 -0
  49. memnex/models/graph.py +33 -0
  50. memnex/models/memory.py +102 -0
  51. memnex/models/misc.py +185 -0
  52. memnex/models/paragraph.py +45 -0
  53. memnex/models/search.py +51 -0
  54. memnex/models/source.py +23 -0
  55. memnex/models/task.py +62 -0
  56. memnex/processing/__init__.py +1 -0
  57. memnex/processing/graph_builder.py +278 -0
  58. memnex/processing/merger/__init__.py +6 -0
  59. memnex/processing/merger/confidence_calculator.py +127 -0
  60. memnex/processing/merger/conflict_resolver.py +116 -0
  61. memnex/retrieval/__init__.py +1 -0
  62. memnex/retrieval/dedup.py +386 -0
  63. memnex/retrieval/embedding.py +289 -0
  64. memnex/retrieval/reranker.py +299 -0
  65. memnex/service.py +902 -0
  66. memnex/storage/__init__.py +65 -0
  67. memnex/storage/base.py +132 -0
  68. memnex/storage/changelog.py +106 -0
  69. memnex/storage/feedback.py +486 -0
  70. memnex/storage/lite/__init__.py +5 -0
  71. memnex/storage/lite/store.py +606 -0
  72. memnex/storage/vector.py +265 -0
  73. memnex/wiki/__init__.py +11 -0
  74. memnex/wiki/community.py +221 -0
  75. memnex/wiki/compiler.py +545 -0
  76. memnex/wiki/generator.py +270 -0
  77. memnex/wiki/search.py +282 -0
  78. memnex/worker.py +412 -0
  79. memplex-3.2.0.dist-info/METADATA +37 -0
  80. memplex-3.2.0.dist-info/RECORD +83 -0
  81. memplex-3.2.0.dist-info/WHEEL +5 -0
  82. memplex-3.2.0.dist-info/entry_points.txt +2 -0
  83. 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