loki-mode 7.7.16 → 7.7.18

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/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.7.16'
60
+ __version__ = '7.7.18'
package/mcp/server.py CHANGED
@@ -969,6 +969,94 @@ async def loki_metrics_efficiency() -> str:
969
969
  return json.dumps({"error": str(e)})
970
970
 
971
971
 
972
+ @mcp.tool()
973
+ async def loki_memory_capture_session_summary(
974
+ goal: str,
975
+ outcome: str = "success",
976
+ files_modified: Optional[List[str]] = None,
977
+ files_read: Optional[List[str]] = None,
978
+ tool_calls_summary: Optional[str] = None,
979
+ duration_seconds: int = 0,
980
+ ) -> str:
981
+ """v7.7.18 capture wedge: store an episode for the current agent session.
982
+
983
+ Call this voluntarily at iteration close (or session end) to write a
984
+ structured Episode into the project's .loki/memory/ store. Solves
985
+ the diagnosis root cause where memory only captured during `loki
986
+ start` sessions, missing all interactive Claude Code / Cursor /
987
+ Cline / Aider work.
988
+
989
+ Args:
990
+ goal: Short description of what the session tried to accomplish
991
+ (will be truncated to 500 chars, scrubbed for secrets).
992
+ outcome: One of "success" | "failure" | "partial". Default "success".
993
+ files_modified: List of file paths that were created or edited.
994
+ files_read: List of file paths that were read for context.
995
+ tool_calls_summary: Optional free-text summary of major actions
996
+ taken (truncated to 1000 chars, scrubbed).
997
+ duration_seconds: Approximate session duration. Default 0.
998
+
999
+ Returns:
1000
+ JSON: {"episode_path": "<path>"} on success, or
1001
+ {"error": "...", "disabled": true} if LOKI_MEMORY_CAPTURE_DISABLED
1002
+ env var blocks capture, or {"error": "..."} on failure.
1003
+ """
1004
+ _emit_tool_event_async(
1005
+ 'loki_memory_capture_session_summary', 'start',
1006
+ parameters={'goal_len': len(goal or ''), 'outcome': outcome,
1007
+ 'files_modified_count': len(files_modified or []),
1008
+ 'files_read_count': len(files_read or [])}
1009
+ )
1010
+ try:
1011
+ from memory.ingest import ingest_from_summary, _capture_disabled
1012
+
1013
+ if _capture_disabled():
1014
+ _emit_tool_event_async(
1015
+ 'loki_memory_capture_session_summary', 'complete',
1016
+ result_status='skipped', error='disabled via env'
1017
+ )
1018
+ return json.dumps({
1019
+ "error": "memory capture disabled via LOKI_MEMORY_CAPTURE_DISABLED",
1020
+ "disabled": True,
1021
+ })
1022
+
1023
+ base_path = safe_path_join('.loki', 'memory')
1024
+ path = ingest_from_summary(
1025
+ base_path,
1026
+ goal=goal,
1027
+ outcome=outcome,
1028
+ files_modified=files_modified,
1029
+ files_read=files_read,
1030
+ tool_calls_summary=tool_calls_summary,
1031
+ duration_seconds=duration_seconds,
1032
+ )
1033
+ if path is None:
1034
+ _emit_tool_event_async(
1035
+ 'loki_memory_capture_session_summary', 'complete',
1036
+ result_status='error', error='ingest_from_summary returned None'
1037
+ )
1038
+ return json.dumps({"error": "ingest failed (check .loki/memory/.errors.log)"})
1039
+ _emit_tool_event_async(
1040
+ 'loki_memory_capture_session_summary', 'complete',
1041
+ result_status='success', episode_path=path
1042
+ )
1043
+ return json.dumps({"episode_path": path})
1044
+ except PathTraversalError as e:
1045
+ logger.error(f"Path traversal attempt blocked: {e}")
1046
+ _emit_tool_event_async(
1047
+ 'loki_memory_capture_session_summary', 'complete',
1048
+ result_status='error', error='Access denied'
1049
+ )
1050
+ return json.dumps({"error": "Access denied"})
1051
+ except Exception as e:
1052
+ logger.error(f"loki_memory_capture_session_summary failed: {e}")
1053
+ _emit_tool_event_async(
1054
+ 'loki_memory_capture_session_summary', 'complete',
1055
+ result_status='error', error=str(e)
1056
+ )
1057
+ return json.dumps({"error": str(e)})
1058
+
1059
+
972
1060
  @mcp.tool()
973
1061
  async def loki_consolidate_memory(since_hours: int = 24) -> str:
974
1062
  """
@@ -0,0 +1,182 @@
1
+ """v7.7.17: structured error log for memory subsystem failures.
2
+
3
+ Replaces the silent-fail (`except Exception: pass`) pattern in
4
+ `autonomy/run.sh` memory call sites with explicit error logging to
5
+ `.loki/memory/.errors.log`. Surfaces in `loki doctor --json` so
6
+ developers see regressions early.
7
+
8
+ Record format (tab-separated, one record per line):
9
+ <iso_timestamp>\\t<function_name>\\t<error_class>\\t<message>\\t<traceback_snippet>
10
+
11
+ Rotation:
12
+ - At 10 MB the current file is renamed to `.errors.log.1`.
13
+ - Older generations shift down (`.1` -> `.2` -> `.3`).
14
+ - Up to 3 historical files retained; older ones are dropped.
15
+
16
+ Failure mode:
17
+ - Logging never raises. If the log itself is unwriteable (perms,
18
+ read-only fs, full disk) the call silently drops the record.
19
+ Observability must not break the memory pipeline.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ import traceback
25
+ from datetime import datetime, timezone
26
+ from pathlib import Path
27
+ from typing import List
28
+
29
+ MAX_LOG_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
30
+ MAX_HISTORICAL_FILES = 3
31
+ # Doctor surface and read_recent_errors only consider the tail of the file.
32
+ # Prevents an OOM on the doctor command if rotation ever fails (perms,
33
+ # disk full mid-rotation, or external writer appending).
34
+ TAIL_READ_BYTES = 64 * 1024 # 64 KB
35
+
36
+ # v7.7.17 (council fix Opus 2): scrub credential-shaped substrings from
37
+ # exception messages before writing to .errors.log. Doctor --json is
38
+ # commonly pasted into bug reports / chats; without this scrub, a stray
39
+ # `RuntimeError: Bearer sk-...` would leak.
40
+ # Mirrors the v7.7.10 USAGE.md regen scrubber (autonomy/run.sh).
41
+ _CREDENTIAL_KEYWORD_RE = re.compile(
42
+ r"(?i)(api[_-]?key|secret|password|token|private[_-]?key|credential|bearer)"
43
+ )
44
+ _HIGH_ENTROPY_TOKEN_RE = re.compile(
45
+ r"sk-[A-Za-z0-9_-]{16,}|pk_[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9]{16,}|"
46
+ r"ghs_[A-Za-z0-9]{16,}|xox[bpoa]-[A-Za-z0-9-]{16,}|AIza[A-Za-z0-9_-]{32,}|"
47
+ r"AKIA[A-Z0-9]{12,}"
48
+ )
49
+
50
+
51
+ def _scrub(text: str) -> str:
52
+ """Redact credential-shaped substrings from `text`.
53
+
54
+ Two passes:
55
+ 1. Any token containing a credential keyword: replace the whole
56
+ token (whitespace-delimited) with `[REDACTED]`.
57
+ 2. Any literal high-entropy token shape (Stripe sk-, GitHub
58
+ ghp_/ghs_, Slack xox*, GCP AIza, AWS AKIA): replace inline
59
+ with `[REDACTED]`.
60
+ """
61
+ if not text:
62
+ return text
63
+ scrubbed_tokens = []
64
+ for token in text.split():
65
+ if _CREDENTIAL_KEYWORD_RE.search(token):
66
+ scrubbed_tokens.append("[REDACTED]")
67
+ else:
68
+ scrubbed_tokens.append(_HIGH_ENTROPY_TOKEN_RE.sub("[REDACTED]", token))
69
+ return " ".join(scrubbed_tokens)
70
+
71
+
72
+ def _errors_log_path(memory_base: str) -> Path:
73
+ return Path(memory_base) / ".errors.log"
74
+
75
+
76
+ def _rotate_if_needed(path: Path) -> None:
77
+ """Rotate `path` if it has grown past MAX_LOG_SIZE_BYTES.
78
+
79
+ Sequence (for MAX_HISTORICAL_FILES=3):
80
+ 1. If `path.log.3` exists, delete it (it falls off the back).
81
+ 2. Shift `path.log.2` -> `path.log.3`, `path.log.1` -> `path.log.2`.
82
+ 3. Rename `path` -> `path.log.1`.
83
+
84
+ All failures are silently absorbed via fallback truncation. The
85
+ caller cannot afford rotation errors to propagate.
86
+ """
87
+ try:
88
+ if not path.exists() or path.stat().st_size < MAX_LOG_SIZE_BYTES:
89
+ return
90
+ # Drop oldest generation
91
+ oldest = path.with_suffix(f".log.{MAX_HISTORICAL_FILES}")
92
+ if oldest.exists():
93
+ oldest.unlink()
94
+ # Shift older -> newer (going from N-1 down to 1)
95
+ for n in range(MAX_HISTORICAL_FILES - 1, 0, -1):
96
+ src = path.with_suffix(f".log.{n}")
97
+ dst = path.with_suffix(f".log.{n + 1}")
98
+ if src.exists():
99
+ src.rename(dst)
100
+ # Move current -> .log.1
101
+ path.rename(path.with_suffix(".log.1"))
102
+ except OSError:
103
+ # Last-ditch: try to truncate the current file so future writes
104
+ # do not keep failing. Silent on failure.
105
+ try:
106
+ path.unlink()
107
+ except OSError:
108
+ pass
109
+
110
+
111
+ def log_memory_error(memory_base: str, function_name: str, exc: BaseException) -> None:
112
+ """Append a structured error record to `<memory_base>/.errors.log`.
113
+
114
+ Never raises. Falls back to silent drop if the log is unwriteable.
115
+
116
+ Args:
117
+ memory_base: Absolute or relative path to a `.loki/memory/`
118
+ directory. Will be created if missing.
119
+ function_name: Short identifier of the call site (e.g.
120
+ "store_episode_trace", "auto_capture_episode").
121
+ exc: The caught exception to record.
122
+ """
123
+ try:
124
+ memory_dir = Path(memory_base)
125
+ memory_dir.mkdir(parents=True, exist_ok=True)
126
+ log_path = _errors_log_path(memory_base)
127
+ _rotate_if_needed(log_path)
128
+ # Tab-separated single-line record. Replace embedded tabs/newlines
129
+ # in user-provided strings so the format stays parseable.
130
+ tb_snippet = "".join(
131
+ traceback.format_exception_only(type(exc), exc)
132
+ ).strip()[:200].replace("\t", " ").replace("\n", " ")
133
+ msg = str(exc).replace("\t", " ").replace("\n", " ")[:200]
134
+ # v7.7.17 council fix (Opus 2): scrub credentials BEFORE writing
135
+ # to disk so doctor --json (often pasted in bug reports) cannot
136
+ # leak Bearer tokens, API keys, etc. embedded in exception text.
137
+ msg = _scrub(msg)
138
+ tb_snippet = _scrub(tb_snippet)
139
+ record = "\t".join([
140
+ datetime.now(timezone.utc).isoformat(),
141
+ function_name,
142
+ type(exc).__name__,
143
+ msg,
144
+ tb_snippet,
145
+ ])
146
+ with open(log_path, "a", encoding="utf-8") as f:
147
+ f.write(record + "\n")
148
+ except Exception:
149
+ return
150
+
151
+
152
+ def read_recent_errors(memory_base: str, limit: int = 5) -> List[str]:
153
+ """Read the last `limit` error records as raw lines (oldest-to-newest).
154
+
155
+ Returns an empty list if the log does not exist or cannot be read.
156
+ Never raises. The current file only is read (historical rotated
157
+ files are not consulted; recent enough for the doctor surface).
158
+
159
+ v7.7.17 council fix (Opus 2): seeks from end and reads at most
160
+ TAIL_READ_BYTES so an oversize log (rotation failed, disk-full
161
+ mid-rotate, external writer) cannot OOM the doctor command.
162
+ """
163
+ log_path = _errors_log_path(memory_base)
164
+ if not log_path.exists():
165
+ return []
166
+ try:
167
+ with open(log_path, "rb") as f:
168
+ f.seek(0, 2) # end
169
+ size = f.tell()
170
+ offset = max(0, size - TAIL_READ_BYTES)
171
+ f.seek(offset)
172
+ chunk = f.read()
173
+ text = chunk.decode("utf-8", errors="replace")
174
+ # If we seeked into the middle of a line, drop the (possibly
175
+ # corrupt) first partial line.
176
+ lines = text.split("\n")
177
+ if offset > 0 and lines:
178
+ lines = lines[1:]
179
+ lines = [ln.strip() for ln in lines if ln.strip()]
180
+ return lines[-limit:]
181
+ except OSError:
182
+ return []