loki-mode 6.82.0 → 6.83.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,120 @@
1
+ """
2
+ Loki Managed Agents Memory - FakeManagedClient for CI tests (v6.83.0 Phase 1).
3
+
4
+ Implements the same surface as ManagedClient but keeps state in memory and
5
+ returns deterministic responses keyed on input path. Used by
6
+ tests/managed_memory/test_*_mock.py so CI does not call the real API.
7
+
8
+ This file is importable without the `anthropic` SDK installed.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ from typing import Any, Dict, List, Optional
15
+
16
+
17
+ class _Conflict(Exception):
18
+ """Simulated 409 raised by memory_create on sha256 mismatch."""
19
+
20
+ status_code = 409
21
+
22
+
23
+ class FakeManagedClient:
24
+ """In-memory fake matching ManagedClient's public interface."""
25
+
26
+ def __init__(self) -> None:
27
+ self.stores: Dict[str, Dict[str, Any]] = {}
28
+ # memories keyed by (store_id, path) -> dict(content, sha, version)
29
+ self.memories: Dict[tuple, Dict[str, Any]] = {}
30
+ self.calls: List[Dict[str, Any]] = []
31
+
32
+ # ---------- stores --------------------------------------------------
33
+
34
+ def stores_list(self) -> List[Dict[str, Any]]:
35
+ self.calls.append({"op": "stores_list"})
36
+ return list(self.stores.values())
37
+
38
+ def stores_get_or_create(
39
+ self, name: str, description: str = "", scope: str = "project"
40
+ ) -> Dict[str, Any]:
41
+ self.calls.append({"op": "stores_get_or_create", "name": name})
42
+ for s in self.stores.values():
43
+ if s.get("name") == name:
44
+ return s
45
+ store_id = f"store_{len(self.stores) + 1:04d}"
46
+ store = {
47
+ "id": store_id,
48
+ "name": name,
49
+ "description": description,
50
+ "scope": scope,
51
+ }
52
+ self.stores[store_id] = store
53
+ return store
54
+
55
+ # ---------- memories ------------------------------------------------
56
+
57
+ def memory_create(
58
+ self,
59
+ store_id: str,
60
+ path: str,
61
+ content: str,
62
+ sha256_precondition: Optional[str] = None,
63
+ ) -> Dict[str, Any]:
64
+ self.calls.append(
65
+ {
66
+ "op": "memory_create",
67
+ "store_id": store_id,
68
+ "path": path,
69
+ "sha256_precondition": sha256_precondition,
70
+ }
71
+ )
72
+ key = (store_id, path)
73
+ sha = hashlib.sha256(content.encode("utf-8")).hexdigest()
74
+ existing = self.memories.get(key)
75
+ if existing is not None and sha256_precondition is not None:
76
+ if existing["sha"] != sha256_precondition:
77
+ # Simulated 409: caller must re-read + merge + retry.
78
+ raise _Conflict(
79
+ f"sha256 mismatch for {path}: "
80
+ f"have={existing['sha']}, want={sha256_precondition}"
81
+ )
82
+ version = (existing.get("version", 0) + 1) if existing else 1
83
+ entry = {
84
+ "id": f"mem_{abs(hash(key)) % 10**8:08d}",
85
+ "store_id": store_id,
86
+ "path": path,
87
+ "content": content,
88
+ "sha": sha,
89
+ "version": version,
90
+ }
91
+ self.memories[key] = entry
92
+ return entry
93
+
94
+ def memory_read(self, store_id: str, memory_id: str) -> Dict[str, Any]:
95
+ self.calls.append({"op": "memory_read", "memory_id": memory_id})
96
+ for entry in self.memories.values():
97
+ if entry.get("id") == memory_id:
98
+ return entry
99
+ raise KeyError(memory_id)
100
+
101
+ def memories_list(
102
+ self, store_id: str, path_prefix: Optional[str] = None
103
+ ) -> List[Dict[str, Any]]:
104
+ self.calls.append(
105
+ {"op": "memories_list", "store_id": store_id, "path_prefix": path_prefix}
106
+ )
107
+ out = []
108
+ for (sid, path), entry in self.memories.items():
109
+ if sid != store_id:
110
+ continue
111
+ if path_prefix and not path.startswith(path_prefix):
112
+ continue
113
+ out.append(entry)
114
+ return out
115
+
116
+
117
+ # Helper exported for tests that need to simulate the 409 without
118
+ # importing the private _Conflict class directly.
119
+ def make_conflict_error() -> Exception:
120
+ return _Conflict("forced conflict for tests")
@@ -0,0 +1,347 @@
1
+ """
2
+ Loki Managed Agents Memory - Retrieve path (v6.83.0 Phase 1).
3
+
4
+ Used by the REASON phase (retrieve_memory_context in autonomy/run.sh) and by
5
+ the completion council (augment prompts with prior verdicts). Also provides
6
+ hydrate_patterns() which pulls semantic patterns updated after a local mtime
7
+ floor and merges them into .loki/memory/semantic/patterns.json via the
8
+ existing MemoryStorage._atomic_write.
9
+
10
+ CLI:
11
+ python3 -m memory.managed_memory.retrieve --query <str> [--top-k N] \\
12
+ [--store-id <id>]
13
+
14
+ python3 -m memory.managed_memory.retrieve --hydrate [--since-seconds N]
15
+
16
+ Never raises: on any error the CLI prints nothing and exits 0. Bash callers
17
+ apply a 5s hard timeout; this module keeps its own 10s SDK timeout.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import json
24
+ import logging
25
+ import os
26
+ import sys
27
+ import time
28
+ from pathlib import Path
29
+ from typing import Any, Dict, List, Optional
30
+
31
+ from . import ManagedDisabled, is_enabled
32
+ from .events import emit_managed_event
33
+
34
+ _LOG = logging.getLogger("loki.managed_memory.retrieve")
35
+
36
+ _DEFAULT_STORE_NAME = "loki-rarv-c-learnings"
37
+
38
+
39
+ def _store_name() -> str:
40
+ return os.environ.get("LOKI_MANAGED_STORE_NAME", _DEFAULT_STORE_NAME)
41
+
42
+
43
+ def _warn_once(msg: str) -> None:
44
+ sys.stderr.write(f"WARN [managed_memory] {msg}\n")
45
+
46
+
47
+ def _get_client():
48
+ from .client import get_client
49
+
50
+ return get_client()
51
+
52
+
53
+ def _summarize(content: str, limit: int = 240) -> str:
54
+ """Return a short single-line summary for prompt injection."""
55
+ s = (content or "").strip().replace("\n", " ")
56
+ if len(s) > limit:
57
+ s = s[: limit - 3] + "..."
58
+ return s
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Retrieve related verdicts (used by REASON and completion council)
63
+ # ---------------------------------------------------------------------------
64
+
65
+
66
+ def retrieve_related_verdicts(
67
+ query: str,
68
+ top_k: int = 3,
69
+ store_id: Optional[str] = None,
70
+ ) -> List[Dict[str, Any]]:
71
+ """
72
+ Return up to `top_k` recent verdicts most-related to `query`.
73
+
74
+ Phase 1 implementation: fetch memories under `verdicts/` and
75
+ prefix-match. This is naive but honest -- true similarity retrieval
76
+ ships in Phase 2 when the beta exposes a semantic search endpoint.
77
+ Returns [] on any error.
78
+ """
79
+ if not is_enabled():
80
+ return []
81
+
82
+ start = time.monotonic()
83
+ try:
84
+ client = _get_client()
85
+ except ManagedDisabled as e:
86
+ emit_managed_event(
87
+ "managed_agents_fallback",
88
+ {"reason": "client_unavailable", "detail": str(e), "op": "retrieve"},
89
+ )
90
+ return []
91
+ except Exception as e: # pragma: no cover - defensive
92
+ emit_managed_event(
93
+ "managed_agents_fallback",
94
+ {"reason": "client_error", "detail": str(e), "op": "retrieve"},
95
+ )
96
+ return []
97
+
98
+ try:
99
+ if not store_id:
100
+ store = client.stores_get_or_create(
101
+ name=_store_name(),
102
+ description="Loki Mode RARV-C shadow-write store (v6.83.0)",
103
+ scope="project",
104
+ )
105
+ store_id = store.get("id") or store.get("store_id")
106
+ if not store_id:
107
+ return []
108
+ entries = client.memories_list(store_id=store_id, path_prefix="verdicts/")
109
+ except Exception as e:
110
+ _warn_once(f"memories_list failed ({e}); returning empty")
111
+ emit_managed_event(
112
+ "managed_agents_fallback",
113
+ {"reason": "list_error", "detail": str(e), "op": "retrieve"},
114
+ )
115
+ return []
116
+
117
+ # Naive ranking: keep entries whose content contains any query token.
118
+ tokens = [t.lower() for t in query.split() if len(t) > 2]
119
+ scored: List[tuple] = []
120
+ for e in entries:
121
+ content = e.get("content") or ""
122
+ content_lc = content.lower()
123
+ score = sum(1 for t in tokens if t in content_lc)
124
+ scored.append((score, e))
125
+ scored.sort(key=lambda pair: pair[0], reverse=True)
126
+
127
+ out: List[Dict[str, Any]] = []
128
+ for score, e in scored[:top_k]:
129
+ out.append(
130
+ {
131
+ "path": e.get("path"),
132
+ "content_summary": _summarize(e.get("content") or ""),
133
+ "version_id": e.get("version") or e.get("version_id"),
134
+ "score": score,
135
+ }
136
+ )
137
+
138
+ emit_managed_event(
139
+ "managed_memory_retrieve",
140
+ {
141
+ "query_tokens": len(tokens),
142
+ "hits": len(out),
143
+ "total_candidates": len(entries),
144
+ "elapsed_ms": int((time.monotonic() - start) * 1000),
145
+ },
146
+ )
147
+ return out
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # Hydrate semantic patterns
152
+ # ---------------------------------------------------------------------------
153
+
154
+
155
+ def hydrate_patterns(
156
+ local_mtime_floor: float,
157
+ target_dir: Optional[str] = None,
158
+ ) -> int:
159
+ """
160
+ Pull semantic patterns from the managed store and merge them into
161
+ .loki/memory/semantic/patterns.json. Returns the number of patterns
162
+ merged in. Returns 0 on disabled / error.
163
+
164
+ Only patterns whose remote version timestamp is newer than
165
+ `local_mtime_floor` are merged. The merge is additive: existing local
166
+ patterns with the same pattern_id are kept unchanged.
167
+ """
168
+ if not is_enabled():
169
+ return 0
170
+
171
+ try:
172
+ client = _get_client()
173
+ except ManagedDisabled as e:
174
+ emit_managed_event(
175
+ "managed_agents_fallback",
176
+ {"reason": "client_unavailable", "detail": str(e), "op": "hydrate"},
177
+ )
178
+ return 0
179
+ except Exception as e: # pragma: no cover
180
+ emit_managed_event(
181
+ "managed_agents_fallback",
182
+ {"reason": "client_error", "detail": str(e), "op": "hydrate"},
183
+ )
184
+ return 0
185
+
186
+ try:
187
+ store = client.stores_get_or_create(
188
+ name=_store_name(),
189
+ description="Loki Mode RARV-C shadow-write store (v6.83.0)",
190
+ scope="project",
191
+ )
192
+ store_id = store.get("id") or store.get("store_id")
193
+ if not store_id:
194
+ return 0
195
+ entries = client.memories_list(store_id=store_id, path_prefix="patterns/")
196
+ except Exception as e:
197
+ emit_managed_event(
198
+ "managed_agents_fallback",
199
+ {"reason": "list_error", "detail": str(e), "op": "hydrate"},
200
+ )
201
+ return 0
202
+
203
+ target_dir = target_dir or os.environ.get("LOKI_TARGET_DIR") or os.getcwd()
204
+ patterns_path = Path(target_dir) / ".loki" / "memory" / "semantic" / "patterns.json"
205
+
206
+ # Read existing local patterns file (may not exist).
207
+ existing: Dict[str, Any] = {"patterns": {}}
208
+ if patterns_path.exists():
209
+ try:
210
+ with open(patterns_path, "r", encoding="utf-8") as f:
211
+ existing = json.load(f)
212
+ except (OSError, json.JSONDecodeError):
213
+ existing = {"patterns": {}}
214
+ if "patterns" not in existing or not isinstance(existing["patterns"], dict):
215
+ existing["patterns"] = {}
216
+
217
+ merged = 0
218
+ for e in entries:
219
+ content = e.get("content")
220
+ if not content:
221
+ continue
222
+ try:
223
+ pat = json.loads(content)
224
+ except (TypeError, json.JSONDecodeError):
225
+ continue
226
+ pid = pat.get("pattern_id") or pat.get("id")
227
+ if not pid:
228
+ continue
229
+ if pid in existing["patterns"]:
230
+ # Local wins on duplicate ids (Phase 1 policy).
231
+ continue
232
+ # Optional mtime gate: skip if the remote entry has a timestamp and
233
+ # it predates the floor.
234
+ ts = pat.get("updated_at") or pat.get("created_at")
235
+ if ts and local_mtime_floor:
236
+ try:
237
+ # Very loose timestamp parse: accept both epoch and ISO.
238
+ if isinstance(ts, (int, float)) and float(ts) < local_mtime_floor:
239
+ continue
240
+ except (TypeError, ValueError):
241
+ pass
242
+ existing["patterns"][pid] = pat
243
+ merged += 1
244
+
245
+ if merged == 0:
246
+ emit_managed_event(
247
+ "managed_memory_hydrate", {"merged": 0, "candidates": len(entries)}
248
+ )
249
+ return 0
250
+
251
+ # Use existing MemoryStorage._atomic_write where possible; fall back to
252
+ # tempfile+rename if MemoryStorage is not importable in this process.
253
+ try:
254
+ # Late import to avoid module-load cycles.
255
+ from memory.storage import MemoryStorage # type: ignore
256
+
257
+ storage = MemoryStorage(str(patterns_path.parent.parent))
258
+ storage._atomic_write(patterns_path, existing)
259
+ except Exception:
260
+ # Fallback: inline atomic write.
261
+ import tempfile
262
+
263
+ patterns_path.parent.mkdir(parents=True, exist_ok=True)
264
+ fd, tmp = tempfile.mkstemp(
265
+ dir=str(patterns_path.parent), prefix=".tmp_", suffix=".json"
266
+ )
267
+ try:
268
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
269
+ json.dump(existing, f, indent=2, default=str)
270
+ os.replace(tmp, patterns_path)
271
+ except Exception as e:
272
+ if os.path.exists(tmp):
273
+ os.unlink(tmp)
274
+ emit_managed_event(
275
+ "managed_agents_fallback",
276
+ {"reason": "atomic_write_failed", "detail": str(e), "op": "hydrate"},
277
+ )
278
+ return 0
279
+
280
+ emit_managed_event(
281
+ "managed_memory_hydrate",
282
+ {"merged": merged, "candidates": len(entries)},
283
+ )
284
+ return merged
285
+
286
+
287
+ # ---------------------------------------------------------------------------
288
+ # Module CLI
289
+ # ---------------------------------------------------------------------------
290
+
291
+
292
+ def _main(argv: Optional[list] = None) -> int:
293
+ # Silent no-op if flags are off; bash callers rely on exit 0 + empty stdout.
294
+ if not is_enabled():
295
+ return 0
296
+
297
+ parser = argparse.ArgumentParser(
298
+ prog="python3 -m memory.managed_memory.retrieve",
299
+ description="Retrieve related verdicts / hydrate patterns from the managed store.",
300
+ )
301
+ parser.add_argument("--query", help="Retrieval query string")
302
+ parser.add_argument("--top-k", type=int, default=3)
303
+ parser.add_argument("--store-id", default=None)
304
+ parser.add_argument(
305
+ "--hydrate",
306
+ action="store_true",
307
+ help="Hydrate semantic patterns into .loki/memory/semantic/",
308
+ )
309
+ parser.add_argument(
310
+ "--since-seconds",
311
+ type=int,
312
+ default=0,
313
+ help="Hydrate: only merge patterns newer than NOW - since_seconds",
314
+ )
315
+ args = parser.parse_args(argv)
316
+
317
+ try:
318
+ if args.hydrate:
319
+ floor = 0.0
320
+ if args.since_seconds and args.since_seconds > 0:
321
+ floor = time.time() - args.since_seconds
322
+ hydrate_patterns(local_mtime_floor=floor)
323
+ return 0
324
+
325
+ query = args.query or ""
326
+ if not query:
327
+ return 0
328
+ results = retrieve_related_verdicts(
329
+ query=query, top_k=args.top_k, store_id=args.store_id
330
+ )
331
+ # Print one line per hit, suitable for pasting into a prompt section.
332
+ for r in results:
333
+ path = r.get("path", "")
334
+ summary = r.get("content_summary", "")
335
+ print(f"- [managed] {path}: {summary}")
336
+ return 0
337
+ except Exception as e: # pragma: no cover - defensive
338
+ _warn_once(f"retrieve CLI unexpected error: {e}")
339
+ emit_managed_event(
340
+ "managed_agents_fallback",
341
+ {"reason": "cli_unexpected", "detail": str(e), "op": "retrieve"},
342
+ )
343
+ return 0
344
+
345
+
346
+ if __name__ == "__main__":
347
+ sys.exit(_main())