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.
- {memoryintelligence_mcp-0.1.12 → memoryintelligence_mcp-0.2.0}/CHANGELOG.md +21 -0
- {memoryintelligence_mcp-0.1.12 → memoryintelligence_mcp-0.2.0}/PKG-INFO +11 -2
- {memoryintelligence_mcp-0.1.12 → memoryintelligence_mcp-0.2.0}/README.md +8 -1
- {memoryintelligence_mcp-0.1.12 → memoryintelligence_mcp-0.2.0}/pyproject.toml +5 -1
- {memoryintelligence_mcp-0.1.12 → memoryintelligence_mcp-0.2.0}/src/mi_mcp/__init__.py +1 -1
- {memoryintelligence_mcp-0.1.12 → memoryintelligence_mcp-0.2.0}/src/mi_mcp/__main__.py +15 -1
- {memoryintelligence_mcp-0.1.12 → memoryintelligence_mcp-0.2.0}/src/mi_mcp/cli.py +203 -9
- {memoryintelligence_mcp-0.1.12 → memoryintelligence_mcp-0.2.0}/src/mi_mcp/client.py +18 -4
- {memoryintelligence_mcp-0.1.12 → memoryintelligence_mcp-0.2.0}/src/mi_mcp/config.py +8 -0
- memoryintelligence_mcp-0.2.0/src/mi_mcp/embedder.py +84 -0
- memoryintelligence_mcp-0.2.0/src/mi_mcp/indexer.py +290 -0
- memoryintelligence_mcp-0.2.0/src/mi_mcp/keys.py +231 -0
- {memoryintelligence_mcp-0.1.12 → memoryintelligence_mcp-0.2.0}/src/mi_mcp/local_index.py +30 -8
- memoryintelligence_mcp-0.2.0/src/mi_mcp/localreads.py +114 -0
- {memoryintelligence_mcp-0.1.12 → memoryintelligence_mcp-0.2.0}/src/mi_mcp/paths.py +11 -0
- memoryintelligence_mcp-0.2.0/src/mi_mcp/scrub.py +135 -0
- {memoryintelligence_mcp-0.1.12 → memoryintelligence_mcp-0.2.0}/src/mi_mcp/server.py +76 -20
- {memoryintelligence_mcp-0.1.12 → memoryintelligence_mcp-0.2.0}/src/mi_mcp/umo_format.py +8 -0
- memoryintelligence_mcp-0.1.12/src/mi_mcp/keys.py +0 -125
- {memoryintelligence_mcp-0.1.12 → memoryintelligence_mcp-0.2.0}/.gitignore +0 -0
- {memoryintelligence_mcp-0.1.12 → memoryintelligence_mcp-0.2.0}/CONTRIBUTING.md +0 -0
- {memoryintelligence_mcp-0.1.12 → memoryintelligence_mcp-0.2.0}/LICENSE +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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]
|
|
@@ -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 (
|
|
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
|
|
10
|
-
|
|
11
|
-
wire
|
|
12
|
-
doctor
|
|
13
|
-
status
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
582
|
-
|
|
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.
|
|
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
|
|
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
|
|
102
|
-
|
|
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]
|