memoryintelligence-mcp 0.1.12__tar.gz → 0.2.0__tar.gz

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 (23) hide show
  1. {memoryintelligence_mcp-0.1.12 → memoryintelligence_mcp-0.2.0}/CHANGELOG.md +21 -0
  2. {memoryintelligence_mcp-0.1.12 → memoryintelligence_mcp-0.2.0}/PKG-INFO +11 -2
  3. {memoryintelligence_mcp-0.1.12 → memoryintelligence_mcp-0.2.0}/README.md +8 -1
  4. {memoryintelligence_mcp-0.1.12 → memoryintelligence_mcp-0.2.0}/pyproject.toml +5 -1
  5. {memoryintelligence_mcp-0.1.12 → memoryintelligence_mcp-0.2.0}/src/mi_mcp/__init__.py +1 -1
  6. {memoryintelligence_mcp-0.1.12 → memoryintelligence_mcp-0.2.0}/src/mi_mcp/__main__.py +15 -1
  7. {memoryintelligence_mcp-0.1.12 → memoryintelligence_mcp-0.2.0}/src/mi_mcp/cli.py +203 -9
  8. {memoryintelligence_mcp-0.1.12 → memoryintelligence_mcp-0.2.0}/src/mi_mcp/client.py +18 -4
  9. {memoryintelligence_mcp-0.1.12 → memoryintelligence_mcp-0.2.0}/src/mi_mcp/config.py +8 -0
  10. memoryintelligence_mcp-0.2.0/src/mi_mcp/embedder.py +84 -0
  11. memoryintelligence_mcp-0.2.0/src/mi_mcp/indexer.py +290 -0
  12. memoryintelligence_mcp-0.2.0/src/mi_mcp/keys.py +231 -0
  13. {memoryintelligence_mcp-0.1.12 → memoryintelligence_mcp-0.2.0}/src/mi_mcp/local_index.py +30 -8
  14. memoryintelligence_mcp-0.2.0/src/mi_mcp/localreads.py +114 -0
  15. {memoryintelligence_mcp-0.1.12 → memoryintelligence_mcp-0.2.0}/src/mi_mcp/paths.py +11 -0
  16. memoryintelligence_mcp-0.2.0/src/mi_mcp/scrub.py +135 -0
  17. {memoryintelligence_mcp-0.1.12 → memoryintelligence_mcp-0.2.0}/src/mi_mcp/server.py +76 -20
  18. {memoryintelligence_mcp-0.1.12 → memoryintelligence_mcp-0.2.0}/src/mi_mcp/umo_format.py +8 -0
  19. memoryintelligence_mcp-0.1.12/src/mi_mcp/keys.py +0 -125
  20. {memoryintelligence_mcp-0.1.12 → memoryintelligence_mcp-0.2.0}/.gitignore +0 -0
  21. {memoryintelligence_mcp-0.1.12 → memoryintelligence_mcp-0.2.0}/CONTRIBUTING.md +0 -0
  22. {memoryintelligence_mcp-0.1.12 → memoryintelligence_mcp-0.2.0}/LICENSE +0 -0
  23. {memoryintelligence_mcp-0.1.12 → memoryintelligence_mcp-0.2.0}/src/mi_mcp/vault.py +0 -0
@@ -3,6 +3,27 @@
3
3
  All notable changes to `memoryintelligence-mcp` are documented here.
4
4
  The format follows [Keep a Changelog](https://keepachangelog.com/); this project uses [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [0.2.0] — 2026-07-04
7
+
8
+ ### Added — the local vault (Path A), previously built on `main` but never released
9
+ The published `0.1.12` shipped as a thin cloud client; the entire local-vault stack
10
+ landed on `main` afterward **under the same version number** and was never cut into a
11
+ release. This release publishes it (release-hygiene fix — no new code, just a version
12
+ bump over what `main` already carried).
13
+
14
+ - **`backfill --execute` now writes the local vault** (`cli.py`): the cloud → local
15
+ migration re-embeds each memory locally, encrypts to the owner's key, and writes a
16
+ signed `.umo`. The prior published build's `--execute` was a dry-run stub.
17
+ - **Offline reads** — `local_index.py` + `localreads.py` + `indexer.py`: a flat-numpy
18
+ cosine index over the decrypted vault, mirroring the hosted ranking, so `mi_ask` works
19
+ network-free (needs the `[local]` extra: `cryptography` + `numpy`).
20
+ - **`mi-mcp index {build,stat,path}`** — build/inspect the local vector index.
21
+ - **On-device redaction** (`scrub.py`) applied on the local read path.
22
+ - **`embedder.py`** — local bge-small embeddings for backfill + query.
23
+ - Note (MI#653): `mi-mcp` still defaults its vault to `~/MemoryIntelligence`; the
24
+ MemorySpace Desktop vault is `~/Somewhere`. Until `wire`/`setup` sets
25
+ `MI_VAULT=~/Somewhere`, point it there manually so the two surfaces share one vault.
26
+
6
27
  ## [0.1.12] — 2026-06-16
7
28
 
8
29
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memoryintelligence-mcp
3
- Version: 0.1.12
3
+ Version: 0.2.0
4
4
  Summary: MCP server for MemoryIntelligence — give Claude a memory you own, set up in one command
5
5
  Project-URL: Homepage, https://memoryintelligence.io
6
6
  Project-URL: Repository, https://github.com/somewhere11/memoryintelligence-mcp
@@ -26,12 +26,14 @@ Requires-Dist: mcp[cli]<2.0,>=1.0.0
26
26
  Requires-Dist: pydantic<3.0,>=2.5.0
27
27
  Provides-Extra: dev
28
28
  Requires-Dist: cryptography<46,>=42.0; extra == 'dev'
29
+ Requires-Dist: fastembed<1.0,>=0.3.0; extra == 'dev'
29
30
  Requires-Dist: numpy<3,>=1.24; extra == 'dev'
30
31
  Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
31
32
  Requires-Dist: pytest>=8.0; extra == 'dev'
32
33
  Requires-Dist: ruff>=0.4.0; extra == 'dev'
33
34
  Provides-Extra: local
34
35
  Requires-Dist: cryptography<46,>=42.0; extra == 'local'
36
+ Requires-Dist: fastembed<1.0,>=0.3.0; extra == 'local'
35
37
  Requires-Dist: numpy<3,>=1.24; extra == 'local'
36
38
  Description-Content-Type: text/markdown
37
39
 
@@ -79,10 +81,17 @@ API key is ever written into a config file.**
79
81
 
80
82
  > Prefer no install? `uvx memoryintelligence-mcp --help` runs it via `uv` with
81
83
  > nothing to install. (You'll still run `mi-mcp setup` once to store your key + wire.)
84
+ >
85
+ > **Note on `--help`:** the admin subcommands (`setup`, `wire`, `doctor`, `status`)
86
+ > are dispatched before the argument parser, so they're listed in `--help`'s footer
87
+ > rather than as standard subcommands — `mi-mcp setup` is real even though it isn't a
88
+ > `--help` subparser. For persistent use prefer `uv tool install memoryintelligence-mcp`
89
+ > over bare `uvx`: the launcher Claude Desktop spawns resolves the binary from `PATH` /
90
+ > `~/.local/bin`, not `uvx`'s ephemeral cache.
82
91
 
83
92
  ---
84
93
 
85
- ## ✅ What works today (0.1.10)
94
+ ## ✅ What works today (0.2.0)
86
95
 
87
96
  Honest status — this is beta, so here's exactly what's live:
88
97
 
@@ -42,10 +42,17 @@ API key is ever written into a config file.**
42
42
 
43
43
  > Prefer no install? `uvx memoryintelligence-mcp --help` runs it via `uv` with
44
44
  > nothing to install. (You'll still run `mi-mcp setup` once to store your key + wire.)
45
+ >
46
+ > **Note on `--help`:** the admin subcommands (`setup`, `wire`, `doctor`, `status`)
47
+ > are dispatched before the argument parser, so they're listed in `--help`'s footer
48
+ > rather than as standard subcommands — `mi-mcp setup` is real even though it isn't a
49
+ > `--help` subparser. For persistent use prefer `uv tool install memoryintelligence-mcp`
50
+ > over bare `uvx`: the launcher Claude Desktop spawns resolves the binary from `PATH` /
51
+ > `~/.local/bin`, not `uvx`'s ephemeral cache.
45
52
 
46
53
  ---
47
54
 
48
- ## ✅ What works today (0.1.10)
55
+ ## ✅ What works today (0.2.0)
49
56
 
50
57
  Honest status — this is beta, so here's exactly what's live:
51
58
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "memoryintelligence-mcp"
7
- version = "0.1.12"
7
+ version = "0.2.0"
8
8
  description = "MCP server for MemoryIntelligence — give Claude a memory you own, set up in one command"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -43,6 +43,9 @@ Documentation = "https://memoryintelligence.io/docs/api-reference"
43
43
  local = [
44
44
  "cryptography>=42.0,<46",
45
45
  "numpy>=1.24,<3",
46
+ # Local-first ONNX embedder (bge-small) for network-free reads. Pulls
47
+ # onnxruntime — real weight, hence kept out of the thin base install.
48
+ "fastembed>=0.3.0,<1.0",
46
49
  ]
47
50
  dev = [
48
51
  "pytest>=8.0",
@@ -50,6 +53,7 @@ dev = [
50
53
  "ruff>=0.4.0",
51
54
  "cryptography>=42.0,<46",
52
55
  "numpy>=1.24,<3",
56
+ "fastembed>=0.3.0,<1.0",
53
57
  ]
54
58
 
55
59
  [project.scripts]
@@ -1,3 +1,3 @@
1
1
  """MI MCP Server — MemoryIntelligence tools for Claude."""
2
2
 
3
- __version__ = "0.1.12"
3
+ __version__ = "0.2.0"
@@ -29,13 +29,27 @@ def main():
29
29
  # Admin subcommands: `mi-mcp {setup|init|wire|doctor|status|memory}`. Bare
30
30
  # `mi-mcp` (no subcommand) runs the server — that's how the MCP host spawns it.
31
31
  argv = sys.argv[1:]
32
- if argv and argv[0] in ("setup", "init", "wire", "doctor", "status", "memory"):
32
+ if argv and argv[0] in (
33
+ "setup", "init", "wire", "doctor", "status", "memory", "backfill", "index",
34
+ ):
33
35
  from .cli import run_admin
34
36
  sys.exit(run_admin(argv[0], argv[1:]))
35
37
 
36
38
  parser = argparse.ArgumentParser(
37
39
  prog="mi-mcp",
38
40
  description="MemoryIntelligence MCP Server",
41
+ # Admin subcommands are dispatched before argparse (above), so they don't
42
+ # appear as subparsers. List them here so `--help` isn't misleading — a new
43
+ # user checking `--help` for `setup` shouldn't conclude the docs are stale.
44
+ epilog=(
45
+ "setup commands (run these first, not shown above):\n"
46
+ " mi-mcp setup store your API key + wire + opt-in + verify (one command)\n"
47
+ " mi-mcp wire wire into Claude Desktop / Code / Cursor\n"
48
+ " mi-mcp doctor verify install, key resolution, and wiring\n"
49
+ " mi-mcp status show wired surfaces + opt-in allowlist\n"
50
+ "\nGet a key at https://memoryintelligence.io/portal, then run `mi-mcp setup`."
51
+ ),
52
+ formatter_class=argparse.RawDescriptionHelpFormatter,
39
53
  )
40
54
  parser.add_argument(
41
55
  "--version",
@@ -6,11 +6,14 @@ a wrapper (``~/.memoryintelligence/mcp/run-mi-mcp.sh``) that resolves
6
6
  ``MI_API_KEY`` at launch from the macOS Keychain, then the on-disk keyfile,
7
7
  else fails. So a leaked config file exposes nothing.
8
8
 
9
- setup — one command: store the key, wire hosts, opt in this dir, verify.
10
- (alias: ``init``). The frictionless front door for new users.
11
- wire — register the server in Claude config(s); no key in any file.
12
- doctor — verify wiring + key resolvability (prints prefix only, never the key).
13
- status — show which surfaces are wired + the capture opt-in allowlist.
9
+ setup — one command: store the key, wire hosts, opt in this dir, verify.
10
+ (alias: ``init``). The frictionless front door for new users.
11
+ wire — register the server in Claude config(s); no key in any file.
12
+ doctor — verify wiring + key resolvability (prints prefix only, never the key).
13
+ status — show which surfaces are wired + the capture opt-in allowlist.
14
+ memory — inspect the local .umo vault (ls|open|verify|rm|path).
15
+ backfill — migrate cloud memories into the local .umo vault (re-embed locally).
16
+ index — build/inspect the local vector index (build|path|stat).
14
17
 
15
18
  The key is stored OUTSIDE every config — in the macOS Keychain (``security``)
16
19
  or, on Linux/Windows (or by choice), a ``chmod 600 ~/.memoryintelligence/.env``
@@ -324,7 +327,12 @@ def cmd_wire(argv: list[str]) -> int:
324
327
  do_wire(home, surfaces, args.dry_run, capture_anywhere=args.capture_anywhere)
325
328
  if not args.dry_run:
326
329
  print("\nNext steps:")
327
- print(f" 1. opt in a project: echo \"$(pwd)\" >> {paths.mcp_config_dir(home=home) / 'opt-in-paths'}")
330
+ # "opt in a directory" reads as "put a file in a folder" to a new user — it's
331
+ # neither. It records a project path in an allowlist; nothing is placed in the
332
+ # folder. Show it as a concrete cd-then-command so the mental model is right.
333
+ print(" 1. allow captures from a project (no file is placed — it records the path):")
334
+ print(" cd ~/Projects/my-app")
335
+ print(f" mi-mcp setup --opt-in \"$(pwd)\" # or: echo \"$(pwd)\" >> {paths.mcp_config_dir(home=home) / 'opt-in-paths'}")
328
336
  print(" 2. restart Claude (MCP servers load at startup)")
329
337
  print(" 3. mi-mcp doctor # verify")
330
338
  return 0
@@ -347,6 +355,15 @@ def cmd_doctor(argv: list[str]) -> int:
347
355
  bin_path = _mi_mcp_bin()
348
356
  check("mi-mcp binary", Path(bin_path).exists(), bin_path)
349
357
 
358
+ # PATH self-check — the #1 post-`uv tool install` stall: the shim exists but its
359
+ # dir isn't on PATH, so `mi-mcp` "isn't found" until the user notices uv's warning.
360
+ bin_dir = str(Path(bin_path).parent)
361
+ path_dirs = os.environ.get("PATH", "").split(os.pathsep)
362
+ on_path = bin_dir in path_dirs
363
+ check("binary on PATH", on_path,
364
+ "" if on_path else f'{bin_dir} not on PATH — run `export PATH="{bin_dir}:$PATH"` (or `uv tool update-shell`) and open a new terminal',
365
+ critical=False)
366
+
350
367
  wrapper = _wrapper_path(home)
351
368
  check("wrapper rendered", wrapper.exists(), str(wrapper))
352
369
  check("wrapper executable", wrapper.exists() and os.access(wrapper, os.X_OK),
@@ -362,6 +379,20 @@ def cmd_doctor(argv: list[str]) -> int:
362
379
  f"{n} entries" if optin.exists() else "absent — all captures will skip",
363
380
  critical=False)
364
381
 
382
+ # Vault path — surface where the local vault resolves and whether it matches the
383
+ # MemorySpace Desktop vault (~/Somewhere). They differ by default (#653): mi-mcp
384
+ # defaults to ~/MemoryIntelligence; the Desktop reads ~/Somewhere. If MI_VAULT
385
+ # isn't unifying them, backfilled memories won't appear in the Desktop.
386
+ from . import vault as _vault
387
+ vpath = _vault.vault_path()
388
+ desktop_vault = home / "Somewhere"
389
+ mi_vault_set = "MI_VAULT" in os.environ
390
+ matches = vpath.resolve() == desktop_vault.resolve()
391
+ check("vault path", matches or mi_vault_set,
392
+ str(vpath) if matches else
393
+ f"{vpath} — Desktop reads {desktop_vault}; set MI_VAULT={desktop_vault} so both share one vault (#653)",
394
+ critical=False)
395
+
365
396
  for s, p in _surface_paths(home).items():
366
397
  servers = _load_json(p).get(_surface_key(s), {})
367
398
  wired = SERVER_KEY in servers
@@ -515,6 +546,77 @@ def _analyze_export(lines) -> BackfillStats:
515
546
  return st
516
547
 
517
548
 
549
+ # --- pure transforms (export row → .umo inputs); unit-testable without I/O -----
550
+
551
+ def _parse_export_objects(lines) -> list[dict]:
552
+ """Parse export NDJSON lines into UMO dicts (skip blanks/errors/malformed)."""
553
+ out: list[dict] = []
554
+ for raw in lines:
555
+ line = (raw or "").strip()
556
+ if not line:
557
+ continue
558
+ try:
559
+ obj = json.loads(line)
560
+ except (json.JSONDecodeError, TypeError):
561
+ continue
562
+ if isinstance(obj, dict) and not obj.get("_error"):
563
+ out.append(obj)
564
+ return out
565
+
566
+
567
+ def _text_for_embedding(row: dict) -> str:
568
+ """Pick the text to (re-)embed — the normalized text, else raw, else summary.
569
+
570
+ The cloud has no exported embedding (`export.py` omits the 384-d vector), so
571
+ backfill must re-embed locally; this chooses the same text capture embeds.
572
+ """
573
+ for k in ("normalized_text", "raw_text", "summary"):
574
+ v = row.get(k)
575
+ if v and str(v).strip():
576
+ return str(v)
577
+ return ""
578
+
579
+
580
+ def _payload_for_umo(row: dict, embedding: list[float]) -> dict:
581
+ """Build the encrypted ``.umo`` payload for a backfilled cloud UMO."""
582
+ return {
583
+ "embedding": embedding,
584
+ "summary": row.get("summary") or "",
585
+ "entities": row.get("entities") or [],
586
+ "topics": row.get("tags") or [],
587
+ "created_at": row.get("created_at"),
588
+ "normalized_text": row.get("normalized_text") or "",
589
+ "raw_text": row.get("raw_text"),
590
+ "semantic_hash": row.get("semantic_hash"),
591
+ "quality_score": row.get("quality_score"),
592
+ "source_type": row.get("source_type"),
593
+ # Provenance: owner-self-signed, NOT MI-attested (we re-produced it locally).
594
+ "origin": "backfill",
595
+ }
596
+
597
+
598
+ def _public_metadata_for_umo(row: dict, owner_did: str) -> dict:
599
+ """Plaintext public metadata for a backfilled ``.umo`` (no PII; vault-readable).
600
+
601
+ Emits the same field set the desktop app writes (content_type / format_version /
602
+ mi_key_id / schema_version / source_count) so the two surfaces share one
603
+ readable home, plus an ``origin`` provenance label (the desktop ignores extras).
604
+ """
605
+ from . import umo_format
606
+ return {
607
+ "umo_id": row.get("id"),
608
+ "created_at": row.get("created_at"),
609
+ "owner_did": owner_did,
610
+ "content_type": "text/plain",
611
+ "format_version": umo_format.FORMAT_VERSION_STR,
612
+ "mi_key_id": "local",
613
+ "schema_version": row.get("schema_version") or "1.0",
614
+ "source_count": 1,
615
+ # MCP provenance: owner-self-signed backfill, NOT MI-attested (extra field).
616
+ "origin": "backfill",
617
+ }
618
+
619
+
518
620
  def cmd_backfill(argv: list[str]) -> int:
519
621
  ap = argparse.ArgumentParser(
520
622
  prog="mi-mcp backfill",
@@ -559,11 +661,15 @@ def cmd_backfill(argv: list[str]) -> int:
559
661
  resp.read()
560
662
  print(f"error: export HTTP {resp.status_code}: {resp.text[:200]}", file=sys.stderr)
561
663
  return 1
562
- stats = _analyze_export(resp.iter_lines())
664
+ # Collect the stream once; we need it for both the stats and (on
665
+ # --execute) the write pass. The migration is a one-shot, so holding
666
+ # the (embedding-free) export in memory is acceptable.
667
+ lines = list(resp.iter_lines())
563
668
  except httpx.HTTPError as exc:
564
669
  print(f"error: export request failed: {exc}", file=sys.stderr)
565
670
  return 1
566
671
 
672
+ stats = _analyze_export(lines)
567
673
  print()
568
674
  print(f" memories : {stats.total}")
569
675
  print(f" approx size : {stats.approx_bytes / 1024:.1f} KB")
@@ -578,8 +684,57 @@ def cmd_backfill(argv: list[str]) -> int:
578
684
  print(f" .umo with your owner key → write to {vault_dir} → build the local index.")
579
685
  return 0
580
686
 
581
- print("\n NOTE: the --execute write path (re-embed → encrypt → vault.write_umo → index)")
582
- print(" is the next increment and is not wired in this build. Dry-run validated the plan.")
687
+ # ---- EXECUTE: re-embed locally → encrypt to .umo → vault → build index ----
688
+ objects = _parse_export_objects(lines)
689
+ embeddable = [(o, t) for o in objects if (t := _text_for_embedding(o)) and o.get("id")]
690
+ skipped_no_text = sum(1 for o in objects if o.get("id") and not _text_for_embedding(o))
691
+ if not embeddable:
692
+ print("\n nothing to write (no embeddable memories in the export).")
693
+ return 0
694
+
695
+ try:
696
+ from . import embedder, indexer, keys, umo_format
697
+ # create=False: NEVER mint a new owner key as a side effect of a migration —
698
+ # that would encrypt the backfill to a key the owner doesn't hold, and reads
699
+ # would silently skip every file (review H3). Fail loudly if it's not resolvable.
700
+ owner_priv = keys.load_master_private_key(create=False)
701
+ owner_pub = owner_priv.public_key()
702
+ signing = keys.load_local_signing_key(create=False)
703
+ # Match the desktop's fixed owner_did so MCP- and desktop-written .umo share
704
+ # one consistent home (owner_did is a label, not used in decryption).
705
+ owner_did = umo_format.LOCAL_OWNER_DID
706
+ except Exception as e: # missing crypto extra / no Keychain key / etc.
707
+ print(f"\nerror: cannot resolve owner keys: {e}", file=sys.stderr)
708
+ print(" (run `mi-mcp setup`/the desktop app first so the owner key exists)", file=sys.stderr)
709
+ return 2
710
+
711
+ # Print the key fingerprint so you can confirm the backfill is encrypted to YOUR key.
712
+ print(f"\n owner key: {keys.owner_did(owner_priv)} (confirm this is yours)")
713
+ print(f" embedding {len(embeddable)} memories locally (bge-small)…")
714
+ try:
715
+ vectors = embedder.embed([t for _, t in embeddable])
716
+ except embedder.LocalEmbedderError as e:
717
+ print(f"\nerror: {e}", file=sys.stderr)
718
+ return 2
719
+
720
+ written = 0
721
+ for (row, _text), vec in zip(embeddable, vectors):
722
+ pm = _public_metadata_for_umo(row, owner_did)
723
+ blob = umo_format.produce_umo_for_owner(
724
+ _payload_for_umo(row, vec), pm, owner_pub, signing
725
+ )
726
+ vault.write_umo(pm["umo_id"], owner_did, blob)
727
+ written += 1
728
+
729
+ print(f" wrote {written} .umo files → {vault_dir}")
730
+ if skipped_no_text:
731
+ print(f" skipped {skipped_no_text} memories with no embeddable text")
732
+
733
+ print(" building local index …")
734
+ count = indexer.rebuild_and_save(owner_priv=owner_priv)
735
+ print(f" ✅ index built: {count} memories rankable locally → {paths.local_index_path()}")
736
+ print("\n Local reads are wired: set MI_MCP_LOCAL=1 to serve mi_ask/mi_list from this")
737
+ print(" vault. `mi-mcp index build` rebuilds the index after vault changes.")
583
738
  return 0
584
739
 
585
740
 
@@ -746,6 +901,44 @@ def cmd_setup(argv: list[str]) -> int:
746
901
  return rc
747
902
 
748
903
 
904
+ def cmd_index(argv: list[str]) -> int:
905
+ """Build/inspect the local vector index: ``mi-mcp index {build|path|stat}``.
906
+
907
+ ``build`` decrypts the vault with your master key (Keychain prompt) and writes
908
+ the rank sidecar; the server loads that prebuilt sidecar on the read path.
909
+ """
910
+ ap = argparse.ArgumentParser(prog="mi-mcp index")
911
+ sub = ap.add_subparsers(dest="action", required=True)
912
+ sub.add_parser("build", help="(re)build the index from the vault (needs your master key)")
913
+ sub.add_parser("path", help="print the index sidecar path")
914
+ sub.add_parser("stat", help="show how many memories are indexed")
915
+ args = ap.parse_args(argv)
916
+
917
+ from . import indexer
918
+
919
+ if args.action == "path":
920
+ print(paths.local_index_path())
921
+ return 0
922
+
923
+ if args.action == "stat":
924
+ idx = indexer.load_index()
925
+ if idx is None:
926
+ print(f"(no index yet — run `mi-mcp index build`) [{paths.local_index_path()}]")
927
+ return 0
928
+ print(f"{len(idx)} memories indexed [{paths.local_index_path()}]")
929
+ return 0
930
+
931
+ # build
932
+ try:
933
+ count = indexer.rebuild_and_save()
934
+ except Exception as e:
935
+ detail = str(e) or type(e).__name__
936
+ print(f"cannot build index: {detail}", file=sys.stderr)
937
+ return 1
938
+ print(f"✅ indexed {count} memories → {paths.local_index_path()}")
939
+ return 0
940
+
941
+
749
942
  _COMMANDS = {
750
943
  "setup": cmd_setup,
751
944
  "init": cmd_setup, # alias
@@ -754,6 +947,7 @@ _COMMANDS = {
754
947
  "status": cmd_status,
755
948
  "memory": cmd_memory,
756
949
  "backfill": cmd_backfill,
950
+ "index": cmd_index,
757
951
  }
758
952
 
759
953
 
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  import asyncio
12
12
  import logging
13
+ import re
13
14
  from typing import Any
14
15
 
15
16
  import httpx
@@ -61,13 +62,13 @@ class MIClient:
61
62
  # because an LLM must never receive unredacted memories. This is by
62
63
  # design, not a limitation: the owner views raw values in the human dev
63
64
  # portal, never through a model context. Do not change this to an
64
- # owner-raw value. See core/security/export_scrub.AGENT_SOURCES.
65
+ # owner-raw value. It mirrors the server-side agent-surface redaction policy.
65
66
  "X-MI-Source": "mcp",
66
67
  },
67
68
  timeout=httpx.Timeout(30.0, connect=10.0),
68
69
  # Connection-level retries are SAFE for every verb — httpx only retries
69
70
  # when the connection never established (ConnectError/ConnectTimeout from
70
- # the Railway sdk-api cold-start), so no request body is ever re-sent.
71
+ # the hosted API's cold-start), so no request body is ever re-sent.
71
72
  # Read-timeout / 5xx retries are handled per-request in _request(), gated
72
73
  # on idempotency, because re-sending a write after the body landed could
73
74
  # double-apply it.
@@ -77,6 +78,19 @@ class MIClient:
77
78
  async def close(self):
78
79
  await self._http.aclose()
79
80
 
81
+ def set_client_identity(self, name: str) -> None:
82
+ """Forward the MCP host identity as the X-MI-Client header (#600).
83
+
84
+ `name` is the initialize handshake's clientInfo.name (e.g.
85
+ "claude-code"). The API uses it to resolve the provenance channel's
86
+ platform slot — agent:claude-code instead of the transport-generic
87
+ agent:mcp. Normalized to a registry-comparable key; empty names are
88
+ a no-op, and unregistered ones fall back to agent:mcp server-side.
89
+ """
90
+ normalized = re.sub(r"[^a-z0-9._-]+", "-", (name or "").strip().lower()).strip("-")[:40]
91
+ if normalized:
92
+ self._http.headers["X-MI-Client"] = normalized
93
+
80
94
  # ----- helpers -----
81
95
 
82
96
  # Transient HTTP statuses worth a retry on idempotent reads — gateway/
@@ -98,8 +112,8 @@ class MIClient:
98
112
  """Make an API request and return the JSON response.
99
113
 
100
114
  Idempotent reads (ask/list/explain/verify/match/account_info) are retried
101
- on a read timeout or a transient 5xx, with exponential backoff. The Railway
102
- sdk-api is a single small instance that intermittently cold-starts/saturates
115
+ on a read timeout or a transient 5xx, with exponential backoff. The hosted
116
+ API can intermittently cold-start or saturate
103
117
  (CPU-bound bge-small embed + pgvector rerank); the MCP previously had a hard
104
118
  30s budget with NO retry — the "MCP keeps timing out" report. Writes
105
119
  (capture/upload/forget/batch) are NOT read-retried: a timeout after the body
@@ -37,6 +37,11 @@ class MIConfig:
37
37
  # Tool surface — v0 exposes 3 tools by default; MI_MCP_FULL=1 exposes all 10 (#256).
38
38
  full_tools: bool = False
39
39
 
40
+ # Local reads (#432 Phase 1): when True AND a built index sidecar exists, route
41
+ # mi_ask/mi_list to the on-device vault index (network-free) and fall back to
42
+ # cloud on any error. Opt-in; cloud stays the default. Set MI_MCP_LOCAL=1.
43
+ local_mode: bool = False
44
+
40
45
  @classmethod
41
46
  def from_env(cls) -> MIConfig:
42
47
  """Build config from environment variables.
@@ -51,6 +56,8 @@ class MIConfig:
51
56
  MI_HOST — bind host for SSE/HTTP (default: 127.0.0.1, loopback only)
52
57
  MI_PORT — bind port for SSE/HTTP (default: 8100)
53
58
  MI_MCP_FULL — "1" exposes all 10 tools; otherwise only the core set (#256)
59
+ MI_MCP_LOCAL — "1" routes mi_ask/mi_list to the local vault index when
60
+ one is built (network-free); falls back to cloud on error
54
61
  """
55
62
  api_key = os.environ.get("MI_API_KEY", "")
56
63
  if not api_key:
@@ -73,6 +80,7 @@ class MIConfig:
73
80
  host=os.environ.get("MI_HOST", cls.host),
74
81
  port=int(os.environ.get("MI_PORT", str(cls.port))),
75
82
  full_tools=os.environ.get("MI_MCP_FULL") == "1",
83
+ local_mode=os.environ.get("MI_MCP_LOCAL") == "1",
76
84
  )
77
85
 
78
86
 
@@ -0,0 +1,84 @@
1
+ """Local document/query embedder — bge-small via fastembed (the ``[local]`` extra).
2
+
3
+ This is the network-free embedder that lets the vault be ranked offline. It MUST
4
+ produce the same model's vectors as capture (``BAAI/bge-small-en-v1.5``, 384-d) or
5
+ local vectors won't compare to anything. fastembed (ONNX) + onnxruntime are real
6
+ weight, so they live in the ``[local]`` extra, NOT the thin base server-client, and
7
+ are imported lazily — merely importing this module never pulls them in.
8
+
9
+ DISTRIBUTION (decided 2026-06-23): download-on-first-run by default — fastembed
10
+ fetches the ONNX model on first use and caches it (``~/.cache/fastembed`` or
11
+ ``$MI_BGE_MODEL_PATH``). ``MI_BGE_MODEL_PATH`` is the drop-in seam for a *vendored*
12
+ model directory: a sealed school appliance / notarized desktop build points it at a
13
+ bundled model so the first run needs no network. Swapping to a fully-vendored wheel
14
+ later is then a packaging change, not a code change.
15
+
16
+ PARITY NOTE: queries and documents are embedded the SAME way (no asymmetric bge
17
+ retrieval prefix), because backfill embeds the documents here too — both sides share
18
+ this code, so they share a vector space. If capture-side embedding ever diverges,
19
+ reconcile here.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import os
25
+
26
+ MODEL_NAME = "BAAI/bge-small-en-v1.5"
27
+ EMBED_DIM = 384
28
+
29
+ # Override the model cache / vendored-model location (the offline seam).
30
+ MODEL_PATH_ENV = "MI_BGE_MODEL_PATH"
31
+
32
+ _MODEL = None # process-lifetime singleton — load once, reuse (warm).
33
+
34
+
35
+ class LocalEmbedderError(Exception):
36
+ """Raised when the local embedder can't be loaded (missing extra, etc.)."""
37
+
38
+
39
+ def _load_model():
40
+ """Load (once) and return the fastembed model. Lazy + cached."""
41
+ global _MODEL
42
+ if _MODEL is not None:
43
+ return _MODEL
44
+ try:
45
+ from fastembed import TextEmbedding
46
+ except ImportError as e: # pragma: no cover - depends on install extras
47
+ raise LocalEmbedderError(
48
+ "Local embedding needs the 'local' extra (fastembed + onnxruntime).\n"
49
+ "Install: pip install 'memoryintelligence-mcp[local]'"
50
+ ) from e
51
+
52
+ kwargs: dict = {}
53
+ model_path = os.environ.get(MODEL_PATH_ENV)
54
+ if model_path:
55
+ # Point fastembed at a vendored / pre-downloaded model dir → no network.
56
+ kwargs["cache_dir"] = model_path
57
+ try:
58
+ _MODEL = TextEmbedding(model_name=MODEL_NAME, **kwargs)
59
+ except Exception as e: # network failure, bad vendored path, etc.
60
+ raise LocalEmbedderError(
61
+ f"could not load embedding model {MODEL_NAME!r}: {e}. "
62
+ f"For offline use, set {MODEL_PATH_ENV} to a vendored model directory."
63
+ ) from e
64
+ return _MODEL
65
+
66
+
67
+ def warm() -> None:
68
+ """Preload the model so the first real query/backfill doesn't pay the load.
69
+
70
+ The cold-start cost of local reads is the model load + first embed, not the
71
+ cosine search (<1ms/50k). Call this at server start to keep ``mi_ask`` fast.
72
+ """
73
+ _load_model()
74
+
75
+
76
+ def embed(texts) -> list[list[float]]:
77
+ """Embed a sequence of texts → list of 384-d float vectors (batched)."""
78
+ model = _load_model()
79
+ return [[float(x) for x in vec] for vec in model.embed(list(texts))]
80
+
81
+
82
+ def embed_one(text: str) -> list[float]:
83
+ """Embed a single text → one 384-d float vector."""
84
+ return embed([text])[0]