skills/memstate/scripts/memstate_search.py (2881 bytes)
1 #!/usr/bin/env python3 2 """Search memories (memstated). 3 4 Three modes: 5 - hybrid (default): FTS match on any query word fused with the semantic 6 ranking (reciprocal rank fusion). When the embedder is 7 unavailable the FTS hits are returned alone and the 8 response carries a `degraded` reason. 9 - fts: SQLite FTS5 keyword match on content + keypath. Every 10 query word must match. 11 - semantic: cosine similarity between the query and embeddings of 12 the current content at each keypath. Requires Ollama 13 running locally with the configured embed model 14 (default nomic-embed-text; set MEMSTATE_EMBED_MODEL 15 or start memstated with --embed-model to change it). 16 17 --scope user searches the reserved user scope and drops hits that describe 18 another machine (host.<other_slug>.*). 19 """ 20 import argparse 21 import sys 22 23 from _client import add_scope_args, emit, fetch, is_other_host, resolve_project 24 25 26 def main() -> int: 27 ap = argparse.ArgumentParser(description="Search memories") 28 ap.add_argument("--query", required=True) 29 add_scope_args(ap) 30 ap.add_argument("--all-projects", action="store_true", 31 help="search every project instead of just this repo's") 32 ap.add_argument("--limit", type=int, default=20) 33 ap.add_argument("--mode", choices=("hybrid", "fts", "semantic"), default="hybrid") 34 ap.add_argument("--threshold", type=float, default=None, 35 help="semantic and hybrid: cosine floor for hits (default 0.5)") 36 ap.add_argument("--category", default=None, 37 help="only return memories with this category") 38 ap.add_argument("--topics", default=None, 39 help="comma-separated: match memories tagged with any of these") 40 ap.add_argument("--keypath-prefix", default=None, 41 help="only memories at this keypath or below, e.g. branches.feature_x") 42 args = ap.parse_args() 43 44 body = {"query": args.query, "limit": args.limit, "mode": args.mode} 45 if not args.all_projects: 46 body["project_id"] = resolve_project(args) 47 if args.threshold is not None: 48 body["threshold"] = args.threshold 49 if args.category: 50 body["category"] = args.category 51 if args.topics: 52 body["topics"] = args.topics.split(",") 53 if args.keypath_prefix: 54 body["keypath_prefix"] = args.keypath_prefix 55 56 def search(): 57 out = fetch("POST", "/memories/search", body) 58 if args.scope == "user" and isinstance(out, dict) and isinstance(out.get("results"), list): 59 out["results"] = [h for h in out["results"] if not is_other_host(h.get("keypath", ""))] 60 out["total_found"] = len(out["results"]) 61 return out 62 63 return emit(search) 64 65 66 if __name__ == "__main__": 67 sys.exit(main())