Skip to content

Commit 3a6cc0d

Browse files
authored
Merge pull request #209 from Context-Engine-AI/memory-fix
Normalize 'under' path handling and add remote embedding support
2 parents 3853760 + 3f4d62d commit 3a6cc0d

7 files changed

Lines changed: 119 additions & 32 deletions

File tree

docs/CLAUDE.example.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ These rules are NOT optional - favor qdrant-indexer tooling at all costs over ex
6464
- Increase to limit=5, include_snippet=true for details
6565
- Use language and under filters to narrow scope
6666
- Set rerank_enabled=false for faster but less accurate results
67+
- Use output_format="toon" for 60-80% token reduction
68+
- Fire independent tool calls in parallel (same message block) for 2-3x speedup
6769

6870
When to Use Advanced Features:
6971

@@ -158,8 +160,38 @@ These rules are NOT optional - favor qdrant-indexer tooling at all costs over ex
158160

159161
- Call set_session_defaults (indexer and memory) early in a session so subsequent
160162
calls inherit the right collection without repeating it in every request.
163+
- Set defaults with: set_session_defaults(output_format="toon", compact=true, limit=5)
161164
- Use context_search with include_memories and per_source_limits when you want
162165
blended code + memory results instead of calling repo_search and memory.memory_find
163166
separately.
164167
- Treat expand_query and the expand flag on context_answer as expensive options:
165168
only use them after a normal search/answer attempt failed to find good context.
169+
170+
Two-Phase Search Strategy:
171+
172+
- Phase 1 (Discovery): limit=3, compact=true, output_format="toon", per_path=1
173+
- Phase 2 (Deep Dive): limit=5-8, include_snippet=true, context_lines=3-5
174+
- Only move to Phase 2 after identifying high-value targets from Phase 1
175+
176+
Parallel Execution Pattern:
177+
178+
- Fire independent tool calls in a single message block (3x faster)
179+
- Example: repo_search + repo_search + symbol_graph all at once
180+
- Do NOT wait for one search to complete before starting another
181+
182+
Token Efficiency Defaults:
183+
184+
| Parameter | Discovery | Deep Dive |
185+
|-----------|-----------|-----------|
186+
| limit | 3 | 5-8 |
187+
| per_path | 1 | 2 |
188+
| compact | true | false |
189+
| output_format | "toon" | "json" |
190+
| include_snippet | false | true |
191+
| context_lines | 0 | 3-5 |
192+
193+
Fallback Chains:
194+
195+
- context_answer timeout → repo_search + info_request(include_explanation=true)
196+
- pattern_search unavailable → repo_search with structural query terms
197+
- neo4j_graph_query empty → symbol_graph (Qdrant-backed fallback)

scripts/hybrid/expand.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -781,18 +781,14 @@ def expand_via_embeddings(
781781
except Exception:
782782
vec_name = None
783783

784-
def _norm_under(u: str | None) -> str | None:
784+
def _norm_under_suffix(u: str | None) -> str | None:
785785
if not u:
786786
return None
787787
u = str(u).strip().replace("\\", "/")
788788
u = "/".join([p for p in u.split("/") if p])
789789
if not u:
790790
return None
791-
if u.startswith("/work/"):
792-
return u
793-
if not u.startswith("/"):
794-
return "/work/" + u
795-
return "/work/" + u.lstrip("/")
791+
return "/" + u
796792

797793
flt = None
798794
try:
@@ -807,12 +803,12 @@ def _norm_under(u: str | None) -> str | None:
807803
)
808804
)
809805
if under:
810-
eff_under = _norm_under(under)
806+
eff_under = _norm_under_suffix(under)
811807
if eff_under:
812808
must.append(
813809
models.FieldCondition(
814810
key="metadata.path_prefix",
815-
match=models.MatchValue(value=eff_under),
811+
match=models.MatchText(text=eff_under),
816812
)
817813
)
818814
if kind:

scripts/hybrid_search.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -510,7 +510,13 @@ def run_pure_dense_search(
510510
if language:
511511
must.append(models.FieldCondition(key="metadata.language", match=models.MatchValue(value=language)))
512512
if under:
513-
must.append(models.FieldCondition(key="metadata.path_prefix", match=models.MatchValue(value=under)))
513+
# Normalize under to suffix format for substring matching
514+
# e.g., "scripts" -> "/scripts" matches path_prefix "/work/Context-Engine-xxx/scripts"
515+
norm_under = str(under).strip().replace("\\", "/")
516+
norm_under = "/".join([p for p in norm_under.split("/") if p])
517+
if norm_under:
518+
norm_under = "/" + norm_under
519+
must.append(models.FieldCondition(key="metadata.path_prefix", match=models.MatchText(text=norm_under)))
514520
if repo and repo != "*":
515521
if isinstance(repo, list):
516522
must.append(models.FieldCondition(key="metadata.repo", match=models.MatchAny(any=repo)))
@@ -981,21 +987,17 @@ def _normalize_globs(globs: list[str]) -> list[str]:
981987
eff_path_globs_norm = _normalize_globs(eff_path_globs)
982988
eff_not_globs_norm = _normalize_globs(eff_not_globs)
983989

984-
# Normalize under
985-
def _norm_under(u: str | None) -> str | None:
990+
def _norm_under_suffix(u: str | None) -> str | None:
991+
"""Normalize under to suffix format for MatchText substring matching."""
986992
if not u:
987993
return None
988994
u = str(u).strip().replace("\\", "/")
989995
u = "/".join([p for p in u.split("/") if p])
990996
if not u:
991997
return None
992-
if not u.startswith("/"):
993-
v = "/work/" + u
994-
else:
995-
v = "/work/" + u.lstrip("/") if not u.startswith("/work/") else u
996-
return v
998+
return "/" + u
997999

998-
eff_under = _norm_under(eff_under)
1000+
eff_under = _norm_under_suffix(eff_under)
9991001

10001002
# Expansion knobs that affect query construction/results (must be part of cache key)
10011003
try:
@@ -1106,7 +1108,7 @@ def _norm_under(u: str | None) -> str | None:
11061108
if eff_under:
11071109
must.append(
11081110
models.FieldCondition(
1109-
key="metadata.path_prefix", match=models.MatchValue(value=eff_under)
1111+
key="metadata.path_prefix", match=models.MatchText(text=eff_under)
11101112
)
11111113
)
11121114
if eff_kind:

scripts/mcp_impl/memory.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,35 @@
3434
# Environment
3535
QDRANT_URL = os.environ.get("QDRANT_URL", "http://qdrant:6333")
3636

37+
# Remote embedding support
38+
try:
39+
from scripts.embedder import RemoteEmbeddingStub
40+
from scripts.ingest.qdrant import embed_batch as _embed_batch_remote
41+
_REMOTE_EMBED_AVAILABLE = True
42+
except ImportError:
43+
RemoteEmbeddingStub = None # type: ignore
44+
_embed_batch_remote = None # type: ignore
45+
_REMOTE_EMBED_AVAILABLE = False
46+
47+
48+
def _embed_text(model, text: str, model_name: str) -> list:
49+
"""Embed text using either local model or remote service.
50+
51+
Detects RemoteEmbeddingStub and routes to embed_batch() accordingly.
52+
"""
53+
is_remote_stub = (
54+
RemoteEmbeddingStub is not None
55+
and isinstance(model, RemoteEmbeddingStub)
56+
)
57+
58+
if is_remote_stub and _REMOTE_EMBED_AVAILABLE and _embed_batch_remote is not None:
59+
# Use remote embedding service
60+
vecs = _embed_batch_remote(model, [text])
61+
return vecs[0] if isinstance(vecs[0], list) else vecs[0].tolist()
62+
else:
63+
# Local embedding
64+
return next(model.embed([text])).tolist()
65+
3766

3867
async def _memory_store_impl(
3968
information: str,
@@ -116,7 +145,8 @@ def _lex_hash_vector(text: str, dim: int = LEX_VECTOR_DIM) -> list[float]:
116145
from scripts.mcp_impl.admin_tools import _get_embedding_model
117146
model = _get_embedding_model(model_name)
118147

119-
dense = next(model.embed([str(information)])).tolist()
148+
# Use helper that handles remote vs local embedding
149+
dense = _embed_text(model, str(information), model_name)
120150

121151
lex = _lex_hash_vector(str(information))
122152

@@ -254,7 +284,8 @@ def _lex_hash_vector(text: str, dim: int = LEX_VECTOR_DIM) -> list[float]:
254284
from scripts.mcp_impl.admin_tools import _get_embedding_model
255285
model = _get_embedding_model(model_name)
256286

257-
dense_query = next(model.embed([str(query)])).tolist()
287+
# Use helper that handles remote vs local embedding
288+
dense_query = _embed_text(model, str(query), model_name)
258289
lex_query = _lex_hash_vector(str(query))
259290

260291
client = QdrantClient(

scripts/mcp_memory_server.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,31 @@
7979
from scripts.utils import sanitize_vector_name as _sanitize_vector_name
8080
from scripts.utils import lex_hash_vector_text as _lex_hash_vector_text
8181

82+
# Remote embedding support
83+
try:
84+
from scripts.embedder import RemoteEmbeddingStub
85+
from scripts.ingest.qdrant import embed_batch as _embed_batch_remote
86+
_REMOTE_EMBED_AVAILABLE = True
87+
except ImportError:
88+
RemoteEmbeddingStub = None # type: ignore
89+
_embed_batch_remote = None # type: ignore
90+
_REMOTE_EMBED_AVAILABLE = False
91+
92+
93+
def _embed_text(model, text: str) -> list:
94+
"""Embed text using either local model or remote service."""
95+
is_remote_stub = (
96+
RemoteEmbeddingStub is not None
97+
and isinstance(model, RemoteEmbeddingStub)
98+
)
99+
100+
if is_remote_stub and _REMOTE_EMBED_AVAILABLE and _embed_batch_remote is not None:
101+
vecs = _embed_batch_remote(model, [text])
102+
return vecs[0] if isinstance(vecs[0], list) else vecs[0].tolist()
103+
else:
104+
return next(model.embed([text])).tolist()
105+
106+
82107
VECTOR_NAME = _sanitize_vector_name(EMBEDDING_MODEL)
83108

84109
# I/O-safety knobs for memory server behavior
@@ -806,7 +831,7 @@ def memory_store(
806831
md["source"] = "memory"
807832

808833
model = _get_embedding_model()
809-
dense = next(model.embed([str(information)])).tolist()
834+
dense = _embed_text(model, str(information))
810835
lex = _lex_hash_vector_text(str(information), LEX_VECTOR_DIM)
811836

812837
# Use UUID to avoid point ID collisions under concurrent load
@@ -959,7 +984,7 @@ def memory_find(
959984
use_dense = False
960985
if use_dense:
961986
model = _get_embedding_model()
962-
dense = next(model.embed([str(query)])).tolist()
987+
dense = _embed_text(model, str(query))
963988
else:
964989
dense = None
965990
lex = _lex_hash_vector_text(str(query), LEX_VECTOR_DIM)

scripts/rerank_tools/local.py

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -166,18 +166,14 @@ def _start_background_warmup():
166166
_start_background_warmup()
167167

168168

169-
def _norm_under(u: str | None) -> str | None:
169+
def _norm_under_suffix(u: str | None) -> str | None:
170170
if not u:
171171
return None
172172
u = str(u).strip().replace("\\", "/")
173173
u = "/".join([p for p in u.split("/") if p])
174174
if not u:
175175
return None
176-
if not u.startswith("/"):
177-
return "/work/" + u
178-
if not u.startswith("/work/"):
179-
return "/work/" + u.lstrip("/")
180-
return u
176+
return "/" + u
181177

182178

183179
def _select_dense_vector_name(
@@ -369,11 +365,11 @@ def rerank_in_process(
369365
key="metadata.language", match=models.MatchValue(value=language)
370366
)
371367
)
372-
eff_under = _norm_under(under)
368+
eff_under = _norm_under_suffix(under)
373369
if eff_under:
374370
must.append(
375371
models.FieldCondition(
376-
key="metadata.path_prefix", match=models.MatchValue(value=eff_under)
372+
key="metadata.path_prefix", match=models.MatchText(text=eff_under)
377373
)
378374
)
379375
flt = models.Filter(must=must) if must else None
@@ -450,11 +446,11 @@ def main():
450446
key="metadata.language", match=models.MatchValue(value=args.language)
451447
)
452448
)
453-
eff_under = _norm_under(args.under)
449+
eff_under = _norm_under_suffix(args.under)
454450
if eff_under:
455451
must.append(
456452
models.FieldCondition(
457-
key="metadata.path_prefix", match=models.MatchValue(value=eff_under)
453+
key="metadata.path_prefix", match=models.MatchText(text=eff_under)
458454
)
459455
)
460456
flt = models.Filter(must=must) if must else None

skills/context-engine/SKILL.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,4 +409,9 @@ Common issues:
409409
7. **Index before search** - Always run `qdrant_index_root` on first use or after cloning a repo
410410
8. **Use pattern_search for structural matching** - When looking for code with similar control flow (retry loops, error handling), use `pattern_search` instead of `repo_search` (if enabled)
411411
9. **Describe patterns in natural language** - `pattern_search` understands "retry with backoff" just as well as actual code examples (if enabled)
412+
10. **Fire independent searches in parallel** - Call multiple `repo_search`, `symbol_graph`, etc. in the same message block for 2-3x speedup
413+
11. **Use TOON format for discovery** - Set `output_format: "toon"` for 60-80% token reduction on exploratory queries
414+
12. **Bootstrap sessions with defaults** - Call `set_session_defaults(output_format="toon", compact=true)` early to avoid repeating params
415+
13. **Two-phase search** - Discovery first (`limit=3, compact=true`), then deep dive (`limit=5-8, include_snippet=true`) on targets
416+
14. **Use fallback chains** - If `context_answer` times out, fall back to `repo_search` + `info_request(include_explanation=true)`
412417

0 commit comments

Comments
 (0)