loki-mode 6.83.1 → 7.0.2
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/SKILL.md +62 -11
- package/VERSION +1 -1
- package/agents/managed_registry.py +246 -0
- package/agents/types.json +330 -0
- package/autonomy/completion-council.sh +226 -0
- package/autonomy/loki +346 -15
- package/autonomy/run.sh +408 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +235 -0
- package/docs/INSTALLATION.md +1 -1
- package/mcp/__init__.py +1 -1
- package/mcp/managed_tools.py +245 -0
- package/mcp/server.py +22 -0
- package/memory/managed_memory/__init__.py +9 -0
- package/memory/managed_memory/retrieve.py +237 -1
- package/package.json +4 -2
- package/providers/managed.py +789 -0
- package/skills/00-index.md +1 -0
- package/skills/memory.md +187 -0
package/dashboard/server.py
CHANGED
|
@@ -5559,6 +5559,241 @@ def start_migration_phase(migration_id: str, request_body: dict):
|
|
|
5559
5559
|
raise HTTPException(status_code=500, detail="Failed to start phase")
|
|
5560
5560
|
|
|
5561
5561
|
|
|
5562
|
+
# ---------------------------------------------------------------------------
|
|
5563
|
+
# Managed Agents Memory bridge (Phase 5, read-only)
|
|
5564
|
+
#
|
|
5565
|
+
# These endpoints expose the contents of .loki/managed/events.ndjson plus a
|
|
5566
|
+
# thin proxy to beta.memory_stores.memory_versions.list(). All endpoints are
|
|
5567
|
+
# safe to call when the managed-agents flags are off: they return empty
|
|
5568
|
+
# lists / {enabled: false} rather than 500s. No endpoint writes to the
|
|
5569
|
+
# managed store -- the only writer in the codebase remains
|
|
5570
|
+
# memory/managed_memory/shadow_write.py.
|
|
5571
|
+
# ---------------------------------------------------------------------------
|
|
5572
|
+
|
|
5573
|
+
_MANAGED_EVENTS_TAIL_MAX = 10000 # Safety ceiling on tail reads.
|
|
5574
|
+
|
|
5575
|
+
|
|
5576
|
+
def _managed_events_path() -> _Path:
|
|
5577
|
+
"""Return the absolute path to .loki/managed/events.ndjson."""
|
|
5578
|
+
return _get_loki_dir() / "managed" / "events.ndjson"
|
|
5579
|
+
|
|
5580
|
+
|
|
5581
|
+
def _managed_flags_snapshot() -> dict[str, Any]:
|
|
5582
|
+
"""Read managed-agents flags without importing the SDK path."""
|
|
5583
|
+
parent = os.environ.get("LOKI_MANAGED_AGENTS", "").strip().lower() == "true"
|
|
5584
|
+
child = os.environ.get("LOKI_MANAGED_MEMORY", "").strip().lower() == "true"
|
|
5585
|
+
try:
|
|
5586
|
+
from memory.managed_memory._beta import BETA_HEADER as _beta_header
|
|
5587
|
+
except Exception:
|
|
5588
|
+
_beta_header = "managed-agents-2026-04-01"
|
|
5589
|
+
return {
|
|
5590
|
+
"enabled": parent and child,
|
|
5591
|
+
"parent_flag": parent,
|
|
5592
|
+
"child_flags": {"LOKI_MANAGED_MEMORY": child},
|
|
5593
|
+
"beta_header": _beta_header,
|
|
5594
|
+
}
|
|
5595
|
+
|
|
5596
|
+
|
|
5597
|
+
def _tail_ndjson(
|
|
5598
|
+
path: _Path,
|
|
5599
|
+
limit: int,
|
|
5600
|
+
since_iso: Optional[str],
|
|
5601
|
+
event_type: Optional[str],
|
|
5602
|
+
) -> list[dict[str, Any]]:
|
|
5603
|
+
"""
|
|
5604
|
+
Return the last *limit* records from an ndjson file, optionally filtered
|
|
5605
|
+
by ts >= since_iso and/or type == event_type. The file is streamed line
|
|
5606
|
+
by line; malformed lines are skipped rather than raising.
|
|
5607
|
+
"""
|
|
5608
|
+
if not path.exists():
|
|
5609
|
+
return []
|
|
5610
|
+
try:
|
|
5611
|
+
# Read lines (file is small: rotation at 10MB per the writer).
|
|
5612
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
5613
|
+
lines = f.readlines()
|
|
5614
|
+
except OSError:
|
|
5615
|
+
return []
|
|
5616
|
+
|
|
5617
|
+
results: list[dict[str, Any]] = []
|
|
5618
|
+
# Scan from newest to oldest so we can early-exit once we have enough.
|
|
5619
|
+
for raw in reversed(lines):
|
|
5620
|
+
line = raw.strip()
|
|
5621
|
+
if not line:
|
|
5622
|
+
continue
|
|
5623
|
+
try:
|
|
5624
|
+
record = json.loads(line)
|
|
5625
|
+
except (json.JSONDecodeError, ValueError):
|
|
5626
|
+
continue
|
|
5627
|
+
if not isinstance(record, dict):
|
|
5628
|
+
continue
|
|
5629
|
+
if event_type and record.get("type") != event_type:
|
|
5630
|
+
continue
|
|
5631
|
+
if since_iso:
|
|
5632
|
+
ts = record.get("ts", "")
|
|
5633
|
+
if isinstance(ts, str) and ts < since_iso:
|
|
5634
|
+
# ISO-8601 strings sort lexicographically with Z suffix; once
|
|
5635
|
+
# we pass the floor we can stop scanning.
|
|
5636
|
+
break
|
|
5637
|
+
results.append(record)
|
|
5638
|
+
if len(results) >= limit:
|
|
5639
|
+
break
|
|
5640
|
+
# Return in chronological order (oldest first) for UI convenience.
|
|
5641
|
+
results.reverse()
|
|
5642
|
+
return results
|
|
5643
|
+
|
|
5644
|
+
|
|
5645
|
+
def _last_fallback_ts(events: list[dict[str, Any]]) -> Optional[str]:
|
|
5646
|
+
"""Return the ts of the most recent managed_agents_fallback event, if any."""
|
|
5647
|
+
for rec in reversed(events):
|
|
5648
|
+
if rec.get("type") == "managed_agents_fallback":
|
|
5649
|
+
ts = rec.get("ts")
|
|
5650
|
+
return ts if isinstance(ts, str) else None
|
|
5651
|
+
return None
|
|
5652
|
+
|
|
5653
|
+
|
|
5654
|
+
@app.get("/api/managed/events")
|
|
5655
|
+
async def get_managed_events(
|
|
5656
|
+
limit: int = Query(default=100, ge=1, le=_MANAGED_EVENTS_TAIL_MAX),
|
|
5657
|
+
since: Optional[str] = Query(default=None),
|
|
5658
|
+
type: Optional[str] = Query(default=None, alias="type"),
|
|
5659
|
+
):
|
|
5660
|
+
"""
|
|
5661
|
+
Return the tail of .loki/managed/events.ndjson.
|
|
5662
|
+
|
|
5663
|
+
Works regardless of flag state. When the flags are off or the file does
|
|
5664
|
+
not exist yet, returns an empty list. Never raises on I/O error.
|
|
5665
|
+
"""
|
|
5666
|
+
try:
|
|
5667
|
+
path = _managed_events_path()
|
|
5668
|
+
records = _tail_ndjson(path, limit=limit, since_iso=since, event_type=type)
|
|
5669
|
+
return {
|
|
5670
|
+
"events": records,
|
|
5671
|
+
"count": len(records),
|
|
5672
|
+
"source": str(path),
|
|
5673
|
+
}
|
|
5674
|
+
except Exception as exc: # defensive: never 500 on read-only tail.
|
|
5675
|
+
logger.warning("managed events tail failed: %s", exc)
|
|
5676
|
+
return {"events": [], "count": 0, "error": str(exc)}
|
|
5677
|
+
|
|
5678
|
+
|
|
5679
|
+
@app.get("/api/managed/status")
|
|
5680
|
+
async def get_managed_status():
|
|
5681
|
+
"""
|
|
5682
|
+
Return the managed-agents flag snapshot plus last_fallback_ts.
|
|
5683
|
+
|
|
5684
|
+
When flags are off, returns {enabled: false, ...} rather than 503. This
|
|
5685
|
+
endpoint is meant to be polled by the UI to decide whether to surface
|
|
5686
|
+
the managed-memory panel at all.
|
|
5687
|
+
"""
|
|
5688
|
+
snapshot = _managed_flags_snapshot()
|
|
5689
|
+
# last_fallback_ts is best-effort from the local events file.
|
|
5690
|
+
try:
|
|
5691
|
+
events = _tail_ndjson(
|
|
5692
|
+
_managed_events_path(),
|
|
5693
|
+
limit=500,
|
|
5694
|
+
since_iso=None,
|
|
5695
|
+
event_type="managed_agents_fallback",
|
|
5696
|
+
)
|
|
5697
|
+
snapshot["last_fallback_ts"] = _last_fallback_ts(events)
|
|
5698
|
+
except Exception:
|
|
5699
|
+
snapshot["last_fallback_ts"] = None
|
|
5700
|
+
return snapshot
|
|
5701
|
+
|
|
5702
|
+
|
|
5703
|
+
@app.get("/api/managed/memory_versions/{memory_id}")
|
|
5704
|
+
async def list_managed_memory_versions(memory_id: str):
|
|
5705
|
+
"""
|
|
5706
|
+
Proxy to beta.memory_stores.memory_versions.list(memory_id=...).
|
|
5707
|
+
|
|
5708
|
+
Returns 503 with a helpful JSON body when flags are off or the SDK does
|
|
5709
|
+
not expose the expected attribute path. On any SDK / transport error the
|
|
5710
|
+
endpoint returns 502 with the error detail -- the managed store owns the
|
|
5711
|
+
source of truth, so we do NOT silently return an empty list here.
|
|
5712
|
+
"""
|
|
5713
|
+
# Validate memory_id early so we don't leak path-traversal attempts into
|
|
5714
|
+
# the SDK payload. The managed API uses opaque identifiers; alphanumerics,
|
|
5715
|
+
# hyphens, underscores only.
|
|
5716
|
+
if (
|
|
5717
|
+
not memory_id
|
|
5718
|
+
or len(memory_id) > 256
|
|
5719
|
+
or ".." in memory_id
|
|
5720
|
+
or not re.match(r"^[a-zA-Z0-9_\-]+$", memory_id)
|
|
5721
|
+
):
|
|
5722
|
+
raise HTTPException(status_code=400, detail="Invalid memory_id")
|
|
5723
|
+
|
|
5724
|
+
snapshot = _managed_flags_snapshot()
|
|
5725
|
+
if not snapshot["enabled"]:
|
|
5726
|
+
raise HTTPException(
|
|
5727
|
+
status_code=503,
|
|
5728
|
+
detail=(
|
|
5729
|
+
"managed memory disabled: set LOKI_MANAGED_AGENTS=true and "
|
|
5730
|
+
"LOKI_MANAGED_MEMORY=true to enable"
|
|
5731
|
+
),
|
|
5732
|
+
)
|
|
5733
|
+
|
|
5734
|
+
try:
|
|
5735
|
+
from memory.managed_memory import ManagedDisabled
|
|
5736
|
+
from memory.managed_memory.client import get_client
|
|
5737
|
+
except Exception as exc:
|
|
5738
|
+
raise HTTPException(status_code=503, detail=f"managed client unavailable: {exc}")
|
|
5739
|
+
|
|
5740
|
+
try:
|
|
5741
|
+
client = get_client()
|
|
5742
|
+
except ManagedDisabled as exc:
|
|
5743
|
+
raise HTTPException(status_code=503, detail=str(exc))
|
|
5744
|
+
|
|
5745
|
+
# Resolve beta.memory_stores.memory_versions.list(...) defensively. Some
|
|
5746
|
+
# SDK versions may not expose this path yet; treat missing attributes as
|
|
5747
|
+
# 503 (flag state prevents us from guaranteeing anything else).
|
|
5748
|
+
try:
|
|
5749
|
+
beta = getattr(client._client, "beta", None) # type: ignore[attr-defined]
|
|
5750
|
+
memory_stores = getattr(beta, "memory_stores", None) if beta is not None else None
|
|
5751
|
+
memory_versions = (
|
|
5752
|
+
getattr(memory_stores, "memory_versions", None)
|
|
5753
|
+
if memory_stores is not None
|
|
5754
|
+
else None
|
|
5755
|
+
)
|
|
5756
|
+
list_fn = getattr(memory_versions, "list", None) if memory_versions is not None else None
|
|
5757
|
+
if list_fn is None:
|
|
5758
|
+
raise HTTPException(
|
|
5759
|
+
status_code=503,
|
|
5760
|
+
detail="memory_versions.list not available in installed SDK",
|
|
5761
|
+
)
|
|
5762
|
+
except HTTPException:
|
|
5763
|
+
raise
|
|
5764
|
+
except Exception as exc:
|
|
5765
|
+
raise HTTPException(status_code=503, detail=f"SDK introspection failed: {exc}")
|
|
5766
|
+
|
|
5767
|
+
try:
|
|
5768
|
+
result = list_fn(memory_id=memory_id)
|
|
5769
|
+
except Exception as exc:
|
|
5770
|
+
# Distinguish "not found" from transport errors when we can.
|
|
5771
|
+
status = getattr(exc, "status_code", None) or getattr(exc, "status", None)
|
|
5772
|
+
if status == 404:
|
|
5773
|
+
raise HTTPException(status_code=404, detail=f"memory_id not found: {memory_id}")
|
|
5774
|
+
logger.warning("memory_versions.list failed: %s", exc)
|
|
5775
|
+
raise HTTPException(status_code=502, detail=f"managed API error: {exc}")
|
|
5776
|
+
|
|
5777
|
+
# Normalize to a list of dicts.
|
|
5778
|
+
data = getattr(result, "data", result)
|
|
5779
|
+
if data is None:
|
|
5780
|
+
data = []
|
|
5781
|
+
items: list[dict[str, Any]] = []
|
|
5782
|
+
for entry in data:
|
|
5783
|
+
if isinstance(entry, dict):
|
|
5784
|
+
items.append(entry)
|
|
5785
|
+
continue
|
|
5786
|
+
to_dict = getattr(entry, "model_dump", None) or getattr(entry, "dict", None)
|
|
5787
|
+
if callable(to_dict):
|
|
5788
|
+
try:
|
|
5789
|
+
items.append(to_dict())
|
|
5790
|
+
continue
|
|
5791
|
+
except Exception:
|
|
5792
|
+
pass
|
|
5793
|
+
items.append({"raw": str(entry)})
|
|
5794
|
+
return {"memory_id": memory_id, "versions": items, "count": len(items)}
|
|
5795
|
+
|
|
5796
|
+
|
|
5562
5797
|
# ---------------------------------------------------------------------------
|
|
5563
5798
|
# SPA catch-all: serve index.html for any path not matched by API routes
|
|
5564
5799
|
# or static asset mounts. This lets the dashboard UI handle client-side routing.
|
package/docs/INSTALLATION.md
CHANGED
package/mcp/__init__.py
CHANGED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""MCP tools for Managed Agents Memory (PII redaction, read proxies).
|
|
2
|
+
|
|
3
|
+
This module hosts the actual implementation of the loki_memory_redact tool.
|
|
4
|
+
The logic lives here -- rather than inline in mcp/server.py -- so unit tests
|
|
5
|
+
can import and exercise ``redact_memory_versions`` directly without having
|
|
6
|
+
to load the full MCP FastMCP runtime.
|
|
7
|
+
|
|
8
|
+
Registration pattern:
|
|
9
|
+
from mcp.managed_tools import register_managed_tools
|
|
10
|
+
register_managed_tools(mcp_server) # Called from mcp/server.py
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_VALID_SCOPES = ("user", "org", "all")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _store_scope(store: Any) -> str:
|
|
24
|
+
if isinstance(store, dict):
|
|
25
|
+
return (store.get("scope") or "").lower()
|
|
26
|
+
return (getattr(store, "scope", "") or "").lower()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _store_id(store: Any) -> Optional[str]:
|
|
30
|
+
if isinstance(store, dict):
|
|
31
|
+
return store.get("id") or store.get("store_id")
|
|
32
|
+
return getattr(store, "id", None) or getattr(store, "store_id", None)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _version_to_dict(version: Any) -> Dict[str, Any]:
|
|
36
|
+
if isinstance(version, dict):
|
|
37
|
+
return version
|
|
38
|
+
to_dict = getattr(version, "model_dump", None) or getattr(version, "dict", None)
|
|
39
|
+
if callable(to_dict):
|
|
40
|
+
try:
|
|
41
|
+
return to_dict()
|
|
42
|
+
except Exception:
|
|
43
|
+
return {"raw": str(version)}
|
|
44
|
+
return {"raw": str(version)}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _resolve_sdk(client: Any) -> Tuple[Any, Any, Any]:
|
|
48
|
+
"""
|
|
49
|
+
Return (stores_list_fn, versions_list_fn, redact_fn) or raise RuntimeError.
|
|
50
|
+
"""
|
|
51
|
+
beta = getattr(client._client, "beta", None) # type: ignore[attr-defined]
|
|
52
|
+
memory_stores = getattr(beta, "memory_stores", None) if beta is not None else None
|
|
53
|
+
stores_list_fn = (
|
|
54
|
+
getattr(memory_stores, "list", None) if memory_stores is not None else None
|
|
55
|
+
)
|
|
56
|
+
memory_versions = (
|
|
57
|
+
getattr(memory_stores, "memory_versions", None)
|
|
58
|
+
if memory_stores is not None else None
|
|
59
|
+
)
|
|
60
|
+
versions_list_fn = (
|
|
61
|
+
getattr(memory_versions, "list", None) if memory_versions is not None else None
|
|
62
|
+
)
|
|
63
|
+
redact_fn = (
|
|
64
|
+
getattr(memory_versions, "redact", None) if memory_versions is not None else None
|
|
65
|
+
)
|
|
66
|
+
if versions_list_fn is None or redact_fn is None:
|
|
67
|
+
raise RuntimeError(
|
|
68
|
+
"memory_versions.list / memory_versions.redact not available in SDK"
|
|
69
|
+
)
|
|
70
|
+
return stores_list_fn, versions_list_fn, redact_fn
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def redact_memory_versions(
|
|
74
|
+
pattern: str,
|
|
75
|
+
scope: str = "all",
|
|
76
|
+
) -> Dict[str, Any]:
|
|
77
|
+
"""
|
|
78
|
+
Redact memory versions whose content matches ``pattern`` (regex).
|
|
79
|
+
|
|
80
|
+
Hard requirements:
|
|
81
|
+
- LOKI_MANAGED_AGENTS=true AND LOKI_MANAGED_MEMORY=true
|
|
82
|
+
(otherwise raises ManagedDisabled).
|
|
83
|
+
|
|
84
|
+
Soft failures (returned as structured dicts, never raise):
|
|
85
|
+
- invalid regex -> {"error": "...", "redacted_count": 0}
|
|
86
|
+
- invalid scope -> {"error": "...", "redacted_count": 0}
|
|
87
|
+
- per-store / per-redact SDK errors -> collected in "errors"
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
{"redacted_count": int, "scanned": int, "errors": [...]}.
|
|
91
|
+
"""
|
|
92
|
+
if scope not in _VALID_SCOPES:
|
|
93
|
+
return {
|
|
94
|
+
"error": f"invalid scope '{scope}'; expected one of "
|
|
95
|
+
+ "|".join(_VALID_SCOPES),
|
|
96
|
+
"redacted_count": 0,
|
|
97
|
+
"errors": [],
|
|
98
|
+
"scanned": 0,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
# v7.0.2: bound pattern length to mitigate ReDoS. Catastrophic-backtracking
|
|
102
|
+
# patterns like (a+)+$ can hang the MCP server. 512 chars is generous for
|
|
103
|
+
# legitimate compliance/PII patterns.
|
|
104
|
+
if not isinstance(pattern, str) or len(pattern) > 512:
|
|
105
|
+
return {
|
|
106
|
+
"error": "pattern must be a string of <=512 characters (ReDoS guard)",
|
|
107
|
+
"redacted_count": 0,
|
|
108
|
+
"errors": [],
|
|
109
|
+
"scanned": 0,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
compiled = re.compile(pattern)
|
|
114
|
+
except re.error as e:
|
|
115
|
+
return {
|
|
116
|
+
"error": f"invalid regex: {e}",
|
|
117
|
+
"redacted_count": 0,
|
|
118
|
+
"errors": [],
|
|
119
|
+
"scanned": 0,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
# Hard flag check: raise so MCP callers see the ManagedDisabled exception
|
|
123
|
+
# path rather than a silent no-op.
|
|
124
|
+
from memory.managed_memory import ManagedDisabled, is_enabled
|
|
125
|
+
from memory.managed_memory.client import get_client
|
|
126
|
+
from memory.managed_memory.events import emit_managed_event
|
|
127
|
+
|
|
128
|
+
if not is_enabled():
|
|
129
|
+
raise ManagedDisabled(
|
|
130
|
+
"loki_memory_redact requires LOKI_MANAGED_AGENTS=true and "
|
|
131
|
+
"LOKI_MANAGED_MEMORY=true"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
client = get_client()
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
stores_list_fn, versions_list_fn, redact_fn = _resolve_sdk(client)
|
|
138
|
+
except RuntimeError as exc:
|
|
139
|
+
return {
|
|
140
|
+
"error": str(exc),
|
|
141
|
+
"redacted_count": 0,
|
|
142
|
+
"errors": [],
|
|
143
|
+
"scanned": 0,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
errors: List[Dict[str, Any]] = []
|
|
147
|
+
redacted_count = 0
|
|
148
|
+
scanned = 0
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
stores_result = stores_list_fn() if stores_list_fn is not None else []
|
|
152
|
+
stores_data = getattr(stores_result, "data", stores_result) or []
|
|
153
|
+
except Exception as e:
|
|
154
|
+
errors.append({"op": "stores_list", "error": str(e)})
|
|
155
|
+
stores_data = []
|
|
156
|
+
|
|
157
|
+
for store in stores_data:
|
|
158
|
+
if scope != "all" and _store_scope(store) != scope:
|
|
159
|
+
continue
|
|
160
|
+
sid = _store_id(store)
|
|
161
|
+
if not sid:
|
|
162
|
+
continue
|
|
163
|
+
try:
|
|
164
|
+
versions_result = versions_list_fn(store_id=sid)
|
|
165
|
+
versions_data = getattr(versions_result, "data", versions_result) or []
|
|
166
|
+
except Exception as e:
|
|
167
|
+
errors.append({"op": "versions_list", "store_id": sid, "error": str(e)})
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
for version in versions_data:
|
|
171
|
+
scanned += 1
|
|
172
|
+
vdict = _version_to_dict(version)
|
|
173
|
+
content = vdict.get("content") or vdict.get("text") or ""
|
|
174
|
+
if not isinstance(content, str):
|
|
175
|
+
try:
|
|
176
|
+
content = json.dumps(content, default=str)
|
|
177
|
+
except Exception:
|
|
178
|
+
content = str(content)
|
|
179
|
+
if not compiled.search(content):
|
|
180
|
+
continue
|
|
181
|
+
vid = (
|
|
182
|
+
vdict.get("id")
|
|
183
|
+
or vdict.get("memory_version_id")
|
|
184
|
+
or vdict.get("version_id")
|
|
185
|
+
)
|
|
186
|
+
if not vid:
|
|
187
|
+
errors.append(
|
|
188
|
+
{"op": "redact", "store_id": sid, "error": "no version id"}
|
|
189
|
+
)
|
|
190
|
+
continue
|
|
191
|
+
try:
|
|
192
|
+
redact_fn(store_id=sid, memory_version_id=vid)
|
|
193
|
+
redacted_count += 1
|
|
194
|
+
try:
|
|
195
|
+
emit_managed_event(
|
|
196
|
+
"managed_memory_redact",
|
|
197
|
+
{
|
|
198
|
+
"store_id": sid,
|
|
199
|
+
"memory_version_id": vid,
|
|
200
|
+
"scope": scope,
|
|
201
|
+
"pattern": pattern,
|
|
202
|
+
},
|
|
203
|
+
)
|
|
204
|
+
except Exception:
|
|
205
|
+
pass
|
|
206
|
+
except Exception as e:
|
|
207
|
+
errors.append(
|
|
208
|
+
{
|
|
209
|
+
"op": "redact",
|
|
210
|
+
"store_id": sid,
|
|
211
|
+
"memory_version_id": vid,
|
|
212
|
+
"error": str(e),
|
|
213
|
+
}
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
"redacted_count": redacted_count,
|
|
218
|
+
"scanned": scanned,
|
|
219
|
+
"errors": errors,
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def register_managed_tools(mcp) -> None:
|
|
224
|
+
"""Attach managed-memory MCP tools to a FastMCP instance."""
|
|
225
|
+
|
|
226
|
+
@mcp.tool()
|
|
227
|
+
async def loki_memory_redact(pattern: str, scope: str = "all") -> str:
|
|
228
|
+
"""
|
|
229
|
+
Redact memory versions in the managed-agents store whose content matches a regex.
|
|
230
|
+
|
|
231
|
+
Iterates memory versions within the requested scope and calls
|
|
232
|
+
``client.beta.memory_stores.memory_versions.redact(...)`` for each
|
|
233
|
+
match. Requires ``LOKI_MANAGED_AGENTS=true`` and
|
|
234
|
+
``LOKI_MANAGED_MEMORY=true`` -- otherwise raises ``ManagedDisabled``.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
pattern: Python regex compiled with ``re.search`` against each
|
|
238
|
+
version's content.
|
|
239
|
+
scope: One of ``user``, ``org``, or ``all`` (default).
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
JSON ``{"redacted_count": int, "errors": [...], "scanned": int}``.
|
|
243
|
+
"""
|
|
244
|
+
result = redact_memory_versions(pattern=pattern, scope=scope)
|
|
245
|
+
return json.dumps(result)
|
package/mcp/server.py
CHANGED
|
@@ -2059,6 +2059,28 @@ async def loki_phase_report() -> str:
|
|
|
2059
2059
|
Use loki_state_get and loki_task_queue_list to gather data."""
|
|
2060
2060
|
|
|
2061
2061
|
|
|
2062
|
+
# ============================================================
|
|
2063
|
+
# MANAGED MEMORY TOOLS (PII redaction, read proxy)
|
|
2064
|
+
#
|
|
2065
|
+
# The actual implementation lives in mcp/managed_tools.py so unit tests can
|
|
2066
|
+
# import the core redact function without booting the FastMCP runtime.
|
|
2067
|
+
# loki_memory_redact appears below for grep-ability and is a thin wrapper.
|
|
2068
|
+
# ============================================================
|
|
2069
|
+
|
|
2070
|
+
try:
|
|
2071
|
+
from mcp.managed_tools import register_managed_tools
|
|
2072
|
+
register_managed_tools(mcp)
|
|
2073
|
+
# Emit tool-call events by wrapping the registered tool's underlying
|
|
2074
|
+
# callable. We reference loki_memory_redact by name here for discoverability.
|
|
2075
|
+
_MANAGED_MEMORY_TOOLS = ("loki_memory_redact",)
|
|
2076
|
+
except Exception as _managed_err:
|
|
2077
|
+
import sys as _sys
|
|
2078
|
+
print(
|
|
2079
|
+
f"[warn] managed_tools registration skipped: {_managed_err}",
|
|
2080
|
+
file=_sys.stderr,
|
|
2081
|
+
)
|
|
2082
|
+
|
|
2083
|
+
|
|
2062
2084
|
# ============================================================
|
|
2063
2085
|
# MAGIC MODULES TOOLS (spec-driven component generation)
|
|
2064
2086
|
# ============================================================
|
|
@@ -100,10 +100,19 @@ def hydrate_patterns(local_mtime_floor: float):
|
|
|
100
100
|
return _r.hydrate_patterns(local_mtime_floor)
|
|
101
101
|
|
|
102
102
|
|
|
103
|
+
def hydrate(namespace: Optional[str] = None, mtime_floor: Optional[float] = None):
|
|
104
|
+
"""Session-boot hydrate (patterns + skills). No-op when disabled."""
|
|
105
|
+
if not is_enabled():
|
|
106
|
+
return {"patterns": 0, "skills": 0, "skipped": True}
|
|
107
|
+
from . import retrieve as _r
|
|
108
|
+
return _r.hydrate(namespace=namespace, mtime_floor=mtime_floor)
|
|
109
|
+
|
|
110
|
+
|
|
103
111
|
__all__ = [
|
|
104
112
|
"BETA_HEADER",
|
|
105
113
|
"ManagedDisabled",
|
|
106
114
|
"emit_managed_event",
|
|
115
|
+
"hydrate",
|
|
107
116
|
"hydrate_patterns",
|
|
108
117
|
"is_enabled",
|
|
109
118
|
"probe_beta_header",
|