superlocalmemory 3.3.19 → 3.3.20

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superlocalmemory",
3
- "version": "3.3.19",
3
+ "version": "3.3.20",
4
4
  "description": "Information-geometric agent memory with mathematical guarantees. 4-channel retrieval, Fisher-Rao similarity, zero-LLM mode, EU AI Act compliant. Works with Claude, Cursor, Windsurf, and 17+ AI tools.",
5
5
  "keywords": [
6
6
  "ai-memory",
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "superlocalmemory"
3
- version = "3.3.19"
3
+ version = "3.3.20"
4
4
  description = "Information-geometric agent memory with mathematical guarantees"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -252,10 +252,11 @@ def cmd_remember(args: Namespace) -> None:
252
252
  from superlocalmemory.core.config import SLMConfig
253
253
 
254
254
  use_json = getattr(args, 'json', False)
255
- fire_and_forget = getattr(args, 'fire_and_forget', False)
255
+ sync_mode = getattr(args, 'sync_mode', False)
256
256
 
257
- # V3.3.19: --async flag for hooks/scripts spawn background process, return instantly
258
- if fire_and_forget:
257
+ # V3.3.19: Async by defaultreturn instantly, process in background.
258
+ # Use --sync to wait for completion (e.g., when you need fact_ids back).
259
+ if not sync_mode:
259
260
  import subprocess
260
261
  cmd = [sys.executable, "-m", "superlocalmemory.cli.main", "remember", args.content]
261
262
  if args.tags:
@@ -135,8 +135,8 @@ def main() -> None:
135
135
  remember_p.add_argument("--tags", default="", help="Comma-separated tags")
136
136
  remember_p.add_argument("--json", action="store_true", help="Output structured JSON (agent-native)")
137
137
  remember_p.add_argument(
138
- "--async", dest="fire_and_forget", action="store_true",
139
- help="Return immediately, process in background (for hooks/scripts)",
138
+ "--sync", dest="sync_mode", action="store_true",
139
+ help="Wait for completion (default: async background processing)",
140
140
  )
141
141
 
142
142
  recall_p = sub.add_parser("recall", help="Semantic search with 4-channel retrieval")
@@ -740,9 +740,10 @@ class SLMConfig:
740
740
  retrieval=RetrievalConfig(
741
741
  # V3.3.2: ONNX cross-encoder enabled for all modes (~200MB)
742
742
  use_cross_encoder=True,
743
- # Mode A is zero-LLM: disable agentic retrieval (it replaces
744
- # precision-tuned fusion with crude heuristic expansions)
745
- agentic_max_rounds=0,
743
+ # V3.3.19: Enable 1 round of rule-based query decomposition.
744
+ # The enhanced _heuristic_expand generates entity+action
745
+ # sub-queries that dramatically improve multi-hop retrieval.
746
+ agentic_max_rounds=1,
746
747
  ),
747
748
  math=MathConfig(
748
749
  sheaf_contradiction_threshold=0.45, # 768d threshold
@@ -157,13 +157,17 @@ def run_recall(
157
157
  response = retrieval_engine.recall(query, profile_id, m, limit)
158
158
 
159
159
  # Agentic sufficiency verification
160
+ # V3.3.19: Only trigger for multi_hop queries in Mode A (rule-based).
161
+ # Single-hop/factual/temporal queries get WORSE with decomposition —
162
+ # sub-query noise dilutes precision. Mode C (LLM) can trigger broadly.
160
163
  agentic_rounds = config.retrieval.agentic_max_rounds
161
164
  if agentic_rounds > 0 and response.results:
162
165
  max_score = max((r.score for r in response.results), default=0.0)
166
+ has_llm = llm is not None and getattr(llm, "is_available", False)
163
167
  should_trigger = (
164
- max_score < config.retrieval.agentic_confidence_threshold
165
- or response.query_type == "multi_hop"
166
- or len(response.results) < 3
168
+ response.query_type == "multi_hop"
169
+ or (has_llm and max_score < config.retrieval.agentic_confidence_threshold)
170
+ or (has_llm and len(response.results) < 3)
167
171
  )
168
172
  if should_trigger:
169
173
  try:
@@ -31,7 +31,10 @@ logger = logging.getLogger(__name__)
31
31
 
32
32
  _MAX_ROUNDS = 2
33
33
  _SUFFICIENCY_SCORE_THRESHOLD = 0.6
34
- _SKIP_TYPES = frozenset({"temporal"}) # S15: agentic harms temporal queries
34
+ # V3.3.19: Removed "temporal" from skip list. S15's lesson was with
35
+ # weak alias expansion. The new rule-based decomposer (v3.3.19) helps
36
+ # temporal queries by generating entity+action sub-queries.
37
+ _SKIP_TYPES: frozenset[str] = frozenset() # No types skipped
35
38
 
36
39
  _SUFFICIENCY_SYSTEM = (
37
40
  "You evaluate whether retrieved context is sufficient to answer a query. "
@@ -241,22 +244,91 @@ class AgenticRetriever:
241
244
  def _heuristic_expand(
242
245
  self, query: str, profile_id: str,
243
246
  ) -> list[str]:
244
- """Mode A: expand query with entity aliases (no LLM)."""
245
- if self._db is None:
246
- return []
247
-
248
- expanded_parts: list[str] = []
249
- entities = re.findall(r"\b[A-Z][a-z]{2,}\b", query)
250
- for name in entities:
251
- entity = self._db.get_entity_by_name(name, profile_id)
252
- if entity:
253
- aliases = self._db.get_aliases_for_entity(entity.entity_id)
254
- for a in aliases[:3]:
255
- expanded_parts.append(a.alias)
256
-
257
- if expanded_parts:
258
- return [query + " " + " ".join(expanded_parts)]
259
- return []
247
+ """Mode A: rule-based query decomposition (no LLM).
248
+
249
+ V3.3.19: Full rewrite. Generates targeted sub-queries by:
250
+ 1. Extracting person/place names (real proper nouns only)
251
+ 2. Extracting action/event keywords (non-stopwords minus entities)
252
+ 3. Combining entity + action for focused retrieval
253
+ 4. Entity-only and action-only lookups for broader context
254
+
255
+ For LoCoMo "When did [Person] [Action]?" patterns, this generates:
256
+ "Caroline LGBTQ support group" (entity + action)
257
+ "Caroline" (entity only)
258
+ "LGBTQ support group" (action only)
259
+ """
260
+ sub_queries: list[str] = []
261
+
262
+ # Extract REAL proper nouns from original query (not title-cased)
263
+ # This avoids the extract_query_entities trap where "Support Group"
264
+ # from title-casing gets treated as entities.
265
+ _STARTERS = {
266
+ "What", "Where", "Who", "Which", "How", "When", "Does", "Did",
267
+ "Can", "Could", "Would", "Should", "Are", "Is", "Was", "Were",
268
+ "Has", "Have", "The", "Tell", "Do",
269
+ }
270
+ entities = [
271
+ m for m in re.findall(r"\b[A-Z][a-z]{2,}\b", query)
272
+ if m not in _STARTERS
273
+ ]
274
+ # Also grab all-caps abbreviations (LGBTQ, MIT, NYC)
275
+ abbrevs = re.findall(r"\b[A-Z]{2,}\b", query)
276
+ entities.extend(abbrevs)
277
+
278
+ # Extract action/event keywords (remove question words + entity names)
279
+ _STOP = {
280
+ "when", "did", "does", "do", "what", "where", "who", "which",
281
+ "how", "is", "was", "were", "are", "has", "have", "had",
282
+ "the", "a", "an", "to", "for", "of", "in", "on", "at",
283
+ "and", "or", "but", "with", "from", "about", "that", "this",
284
+ "it", "they", "she", "he", "her", "his", "their", "its",
285
+ "been", "being", "would", "could", "should", "will", "can",
286
+ "may", "might", "not", "no", "so", "if", "by", "up",
287
+ "go", "going", "went", "get", "got", "ago",
288
+ "many", "much", "some", "any", "ever",
289
+ }
290
+ entity_lower = {e.lower() for e in entities}
291
+ words = re.sub(r"[^\w\s]", "", query.lower()).split()
292
+ action_words = [
293
+ w for w in words
294
+ if w not in _STOP and w not in entity_lower and len(w) > 2
295
+ ]
296
+
297
+ # Strategy 1: Entity + action keywords (most targeted)
298
+ if entities and action_words:
299
+ action_phrase = " ".join(action_words)
300
+ for ent in entities[:2]:
301
+ sub_queries.append(f"{ent} {action_phrase}")
302
+
303
+ # Strategy 2: Action keywords only (finds the event regardless of entity)
304
+ if action_words:
305
+ sub_queries.append(" ".join(action_words))
306
+
307
+ # Strategy 3: Entity-only lookup (broad context)
308
+ for ent in entities[:2]:
309
+ sub_queries.append(ent)
310
+
311
+ # Strategy 4: Alias expansion (original approach, still useful)
312
+ if self._db is not None:
313
+ for name in entities[:2]:
314
+ entity = self._db.get_entity_by_name(name, profile_id)
315
+ if entity:
316
+ try:
317
+ aliases = self._db.get_aliases_for_entity(entity.entity_id)
318
+ for a in aliases[:2]:
319
+ sub_queries.append(f"{a.alias} {' '.join(action_words)}")
320
+ except Exception:
321
+ pass
322
+
323
+ # Deduplicate, limit to 3 sub-queries (keep round 2 fast)
324
+ seen: set[str] = set()
325
+ unique: list[str] = []
326
+ for sq in sub_queries:
327
+ sq_lower = sq.strip().lower()
328
+ if sq_lower and sq_lower not in seen and sq_lower != query.lower():
329
+ seen.add(sq_lower)
330
+ unique.append(sq.strip())
331
+ return unique[:3]
260
332
 
261
333
 
262
334
  # ---------------------------------------------------------------------------