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.
@@ -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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  The flagship product of [Autonomi](https://www.autonomi.dev/). Complete installation instructions for all platforms and use cases.
4
4
 
5
- **Version:** v6.83.1
5
+ **Version:** v7.0.2
6
6
 
7
7
  ---
8
8
 
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '6.83.1'
60
+ __version__ = '7.0.2'
@@ -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",