loki-mode 6.83.0 → 7.0.1

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.
@@ -0,0 +1,234 @@
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
+ try:
102
+ compiled = re.compile(pattern)
103
+ except re.error as e:
104
+ return {
105
+ "error": f"invalid regex: {e}",
106
+ "redacted_count": 0,
107
+ "errors": [],
108
+ "scanned": 0,
109
+ }
110
+
111
+ # Hard flag check: raise so MCP callers see the ManagedDisabled exception
112
+ # path rather than a silent no-op.
113
+ from memory.managed_memory import ManagedDisabled, is_enabled
114
+ from memory.managed_memory.client import get_client
115
+ from memory.managed_memory.events import emit_managed_event
116
+
117
+ if not is_enabled():
118
+ raise ManagedDisabled(
119
+ "loki_memory_redact requires LOKI_MANAGED_AGENTS=true and "
120
+ "LOKI_MANAGED_MEMORY=true"
121
+ )
122
+
123
+ client = get_client()
124
+
125
+ try:
126
+ stores_list_fn, versions_list_fn, redact_fn = _resolve_sdk(client)
127
+ except RuntimeError as exc:
128
+ return {
129
+ "error": str(exc),
130
+ "redacted_count": 0,
131
+ "errors": [],
132
+ "scanned": 0,
133
+ }
134
+
135
+ errors: List[Dict[str, Any]] = []
136
+ redacted_count = 0
137
+ scanned = 0
138
+
139
+ try:
140
+ stores_result = stores_list_fn() if stores_list_fn is not None else []
141
+ stores_data = getattr(stores_result, "data", stores_result) or []
142
+ except Exception as e:
143
+ errors.append({"op": "stores_list", "error": str(e)})
144
+ stores_data = []
145
+
146
+ for store in stores_data:
147
+ if scope != "all" and _store_scope(store) != scope:
148
+ continue
149
+ sid = _store_id(store)
150
+ if not sid:
151
+ continue
152
+ try:
153
+ versions_result = versions_list_fn(store_id=sid)
154
+ versions_data = getattr(versions_result, "data", versions_result) or []
155
+ except Exception as e:
156
+ errors.append({"op": "versions_list", "store_id": sid, "error": str(e)})
157
+ continue
158
+
159
+ for version in versions_data:
160
+ scanned += 1
161
+ vdict = _version_to_dict(version)
162
+ content = vdict.get("content") or vdict.get("text") or ""
163
+ if not isinstance(content, str):
164
+ try:
165
+ content = json.dumps(content, default=str)
166
+ except Exception:
167
+ content = str(content)
168
+ if not compiled.search(content):
169
+ continue
170
+ vid = (
171
+ vdict.get("id")
172
+ or vdict.get("memory_version_id")
173
+ or vdict.get("version_id")
174
+ )
175
+ if not vid:
176
+ errors.append(
177
+ {"op": "redact", "store_id": sid, "error": "no version id"}
178
+ )
179
+ continue
180
+ try:
181
+ redact_fn(store_id=sid, memory_version_id=vid)
182
+ redacted_count += 1
183
+ try:
184
+ emit_managed_event(
185
+ "managed_memory_redact",
186
+ {
187
+ "store_id": sid,
188
+ "memory_version_id": vid,
189
+ "scope": scope,
190
+ "pattern": pattern,
191
+ },
192
+ )
193
+ except Exception:
194
+ pass
195
+ except Exception as e:
196
+ errors.append(
197
+ {
198
+ "op": "redact",
199
+ "store_id": sid,
200
+ "memory_version_id": vid,
201
+ "error": str(e),
202
+ }
203
+ )
204
+
205
+ return {
206
+ "redacted_count": redacted_count,
207
+ "scanned": scanned,
208
+ "errors": errors,
209
+ }
210
+
211
+
212
+ def register_managed_tools(mcp) -> None:
213
+ """Attach managed-memory MCP tools to a FastMCP instance."""
214
+
215
+ @mcp.tool()
216
+ async def loki_memory_redact(pattern: str, scope: str = "all") -> str:
217
+ """
218
+ Redact memory versions in the managed-agents store whose content matches a regex.
219
+
220
+ Iterates memory versions within the requested scope and calls
221
+ ``client.beta.memory_stores.memory_versions.redact(...)`` for each
222
+ match. Requires ``LOKI_MANAGED_AGENTS=true`` and
223
+ ``LOKI_MANAGED_MEMORY=true`` -- otherwise raises ``ManagedDisabled``.
224
+
225
+ Args:
226
+ pattern: Python regex compiled with ``re.search`` against each
227
+ version's content.
228
+ scope: One of ``user``, ``org``, or ``all`` (default).
229
+
230
+ Returns:
231
+ JSON ``{"redacted_count": int, "errors": [...], "scanned": int}``.
232
+ """
233
+ result = redact_memory_versions(pattern=pattern, scope=scope)
234
+ 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",
@@ -284,6 +284,234 @@ def hydrate_patterns(
284
284
  return merged
285
285
 
286
286
 
287
+ # ---------------------------------------------------------------------------
288
+ # Hydrate procedural skills
289
+ # ---------------------------------------------------------------------------
290
+
291
+
292
+ def hydrate_skills(
293
+ local_mtime_floor: float,
294
+ target_dir: Optional[str] = None,
295
+ ) -> int:
296
+ """
297
+ Pull procedural skills from the managed store and merge them into
298
+ .loki/memory/skills/{name}.json (one file per skill). Returns the number
299
+ of skill files written. Returns 0 on disabled / error.
300
+
301
+ Only skills whose remote timestamp is newer than `local_mtime_floor` are
302
+ merged. Local wins on conflict: a skill whose filename already exists is
303
+ NOT overwritten.
304
+ """
305
+ if not is_enabled():
306
+ return 0
307
+
308
+ try:
309
+ client = _get_client()
310
+ except ManagedDisabled as e:
311
+ emit_managed_event(
312
+ "managed_agents_fallback",
313
+ {"reason": "client_unavailable", "detail": str(e), "op": "hydrate_skills"},
314
+ )
315
+ return 0
316
+ except Exception as e: # pragma: no cover
317
+ emit_managed_event(
318
+ "managed_agents_fallback",
319
+ {"reason": "client_error", "detail": str(e), "op": "hydrate_skills"},
320
+ )
321
+ return 0
322
+
323
+ try:
324
+ store = client.stores_get_or_create(
325
+ name=_store_name(),
326
+ description="Loki Mode RARV-C shadow-write store (v6.83.0)",
327
+ scope="project",
328
+ )
329
+ store_id = store.get("id") or store.get("store_id")
330
+ if not store_id:
331
+ return 0
332
+ entries = client.memories_list(store_id=store_id, path_prefix="skills/")
333
+ except Exception as e:
334
+ emit_managed_event(
335
+ "managed_agents_fallback",
336
+ {"reason": "list_error", "detail": str(e), "op": "hydrate_skills"},
337
+ )
338
+ return 0
339
+
340
+ target_dir = target_dir or os.environ.get("LOKI_TARGET_DIR") or os.getcwd()
341
+ skills_dir = Path(target_dir) / ".loki" / "memory" / "skills"
342
+ skills_dir.mkdir(parents=True, exist_ok=True)
343
+
344
+ merged = 0
345
+ for e in entries:
346
+ content = e.get("content")
347
+ if not content:
348
+ continue
349
+ try:
350
+ skill = json.loads(content)
351
+ except (TypeError, json.JSONDecodeError):
352
+ continue
353
+ sid = skill.get("id") or skill.get("skill_id")
354
+ name = skill.get("name") or sid
355
+ if not name:
356
+ continue
357
+
358
+ # Sanitize filename (mirror MemoryStorage.save_skill).
359
+ safe_name = "".join(
360
+ c if c.isalnum() or c in "-_" else "_" for c in str(name)
361
+ )
362
+ skill_path = skills_dir / f"{safe_name}.json"
363
+ if skill_path.exists():
364
+ # Local wins on conflict.
365
+ continue
366
+
367
+ # Optional mtime gate.
368
+ ts = skill.get("updated_at") or skill.get("created_at")
369
+ if ts and local_mtime_floor:
370
+ try:
371
+ if isinstance(ts, (int, float)) and float(ts) < local_mtime_floor:
372
+ continue
373
+ except (TypeError, ValueError):
374
+ pass
375
+
376
+ try:
377
+ from memory.storage import MemoryStorage # type: ignore
378
+
379
+ storage = MemoryStorage(str(skills_dir.parent))
380
+ storage._atomic_write(skill_path, skill)
381
+ except Exception:
382
+ import tempfile
383
+
384
+ fd, tmp = tempfile.mkstemp(
385
+ dir=str(skills_dir), prefix=".tmp_", suffix=".json"
386
+ )
387
+ try:
388
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
389
+ json.dump(skill, f, indent=2, default=str)
390
+ os.replace(tmp, skill_path)
391
+ except Exception as ex:
392
+ if os.path.exists(tmp):
393
+ os.unlink(tmp)
394
+ emit_managed_event(
395
+ "managed_agents_fallback",
396
+ {"reason": "atomic_write_failed", "detail": str(ex), "op": "hydrate_skills"},
397
+ )
398
+ continue
399
+ merged += 1
400
+
401
+ emit_managed_event(
402
+ "managed_memory_hydrate_skills",
403
+ {"merged": merged, "candidates": len(entries)},
404
+ )
405
+ return merged
406
+
407
+
408
+ # ---------------------------------------------------------------------------
409
+ # Session hydrate (patterns + skills) with idempotency guard
410
+ # ---------------------------------------------------------------------------
411
+
412
+
413
+ _HYDRATE_SENTINEL = ".loki/managed/hydrate.lock"
414
+
415
+
416
+ def _already_hydrated_this_session(target_dir: str) -> bool:
417
+ """Idempotent: once we write the sentinel file, a second hydrate is no-op."""
418
+ sentinel = Path(target_dir) / _HYDRATE_SENTINEL
419
+ return sentinel.exists()
420
+
421
+
422
+ def _mark_hydrated(target_dir: str) -> None:
423
+ sentinel = Path(target_dir) / _HYDRATE_SENTINEL
424
+ sentinel.parent.mkdir(parents=True, exist_ok=True)
425
+ try:
426
+ sentinel.write_text(str(int(time.time())), encoding="utf-8")
427
+ except OSError:
428
+ pass
429
+
430
+
431
+ def hydrate(
432
+ namespace: Optional[str] = None,
433
+ mtime_floor: Optional[float] = None,
434
+ target_dir: Optional[str] = None,
435
+ ) -> Dict[str, int]:
436
+ """
437
+ Session-boot hydrate: pull semantic patterns AND procedural skills from
438
+ the managed store and merge them into local .loki/memory/. Emits a single
439
+ `managed_memory_hydrate` event with counts.
440
+
441
+ Args:
442
+ namespace: Optional logical namespace label; reserved for multi-tenant
443
+ stores (not yet used by the backend). Included in the event for
444
+ observability.
445
+ mtime_floor: Only merge remote entries updated after this epoch
446
+ timestamp. Defaults to 0.0 (pull everything not already local).
447
+ target_dir: Override .loki root; defaults to LOKI_TARGET_DIR or cwd.
448
+
449
+ Returns:
450
+ {"patterns": N, "skills": M, "skipped": bool}. Disabled flags / errors
451
+ return {"patterns": 0, "skills": 0, "skipped": True/False}.
452
+
453
+ Idempotent: a second call within the same session (while the lock file
454
+ exists) short-circuits and returns zero counts with skipped=True.
455
+ """
456
+ target_dir = target_dir or os.environ.get("LOKI_TARGET_DIR") or os.getcwd()
457
+
458
+ if not is_enabled():
459
+ return {"patterns": 0, "skills": 0, "skipped": True}
460
+
461
+ if _already_hydrated_this_session(target_dir):
462
+ emit_managed_event(
463
+ "managed_memory_hydrate",
464
+ {
465
+ "patterns": 0,
466
+ "skills": 0,
467
+ "skipped": True,
468
+ "reason": "already_hydrated_this_session",
469
+ "namespace": namespace or "",
470
+ },
471
+ )
472
+ return {"patterns": 0, "skills": 0, "skipped": True}
473
+
474
+ floor = float(mtime_floor) if mtime_floor is not None else 0.0
475
+
476
+ patterns_merged = 0
477
+ skills_merged = 0
478
+ try:
479
+ patterns_merged = hydrate_patterns(
480
+ local_mtime_floor=floor, target_dir=target_dir
481
+ )
482
+ except Exception as e: # pragma: no cover - defensive
483
+ emit_managed_event(
484
+ "managed_agents_fallback",
485
+ {"reason": "hydrate_patterns_error", "detail": str(e), "op": "hydrate"},
486
+ )
487
+ try:
488
+ skills_merged = hydrate_skills(
489
+ local_mtime_floor=floor, target_dir=target_dir
490
+ )
491
+ except Exception as e: # pragma: no cover - defensive
492
+ emit_managed_event(
493
+ "managed_agents_fallback",
494
+ {"reason": "hydrate_skills_error", "detail": str(e), "op": "hydrate"},
495
+ )
496
+
497
+ _mark_hydrated(target_dir)
498
+
499
+ emit_managed_event(
500
+ "managed_memory_hydrate",
501
+ {
502
+ "patterns": patterns_merged,
503
+ "skills": skills_merged,
504
+ "skipped": False,
505
+ "namespace": namespace or "",
506
+ },
507
+ )
508
+ return {
509
+ "patterns": patterns_merged,
510
+ "skills": skills_merged,
511
+ "skipped": False,
512
+ }
513
+
514
+
287
515
  # ---------------------------------------------------------------------------
288
516
  # Module CLI
289
517
  # ---------------------------------------------------------------------------
@@ -319,7 +547,15 @@ def _main(argv: Optional[list] = None) -> int:
319
547
  floor = 0.0
320
548
  if args.since_seconds and args.since_seconds > 0:
321
549
  floor = time.time() - args.since_seconds
322
- hydrate_patterns(local_mtime_floor=floor)
550
+ # Phase 2: session-boot hydrate covers patterns + skills and is
551
+ # idempotent (sentinel-guarded). Prints a one-line summary to
552
+ # stdout so callers can log counts without parsing JSON.
553
+ result = hydrate(mtime_floor=floor)
554
+ print(
555
+ f"[managed] hydrate patterns={result.get('patterns', 0)} "
556
+ f"skills={result.get('skills', 0)} "
557
+ f"skipped={result.get('skipped', False)}"
558
+ )
323
559
  return 0
324
560
 
325
561
  query = args.query or ""
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loki-mode",
3
- "version": "6.83.0",
3
+ "version": "7.0.1",
4
4
  "description": "Loki Mode by Autonomi - Multi-agent autonomous startup system for Claude Code, Codex CLI, and Gemini CLI",
5
5
  "keywords": [
6
6
  "agent",
@@ -67,6 +67,7 @@
67
67
  "VERSION",
68
68
  "autonomy/",
69
69
  "providers/",
70
+ "agents/",
70
71
  "skills/",
71
72
  "references/",
72
73
  "docs/**/*.md",
@@ -105,7 +106,8 @@
105
106
  "test:visual": "node --experimental-vm-modules node_modules/jest/bin/jest.js dashboard-ui/tests/visual-regression.test.js",
106
107
  "test:parity": "node --experimental-vm-modules dashboard-ui/scripts/check-parity.js",
107
108
  "test:parity:json": "node --experimental-vm-modules dashboard-ui/scripts/check-parity.js --json",
108
- "test:dashboard": "npm run test:visual && npm run test:parity"
109
+ "test:dashboard": "npm run test:visual && npm run test:parity",
110
+ "test:integration": "bash tests/integration/run_integration_suite.sh"
109
111
  },
110
112
  "engines": {
111
113
  "node": ">=18.0.0"