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/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/loki +120 -0
- package/autonomy/run.sh +22 -4
- package/dashboard/__init__.py +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/loki-ts/dist/loki.js +145 -144
- package/mcp/__init__.py +1 -1
- package/mcp/server.py +88 -0
- package/memory/error_log.py +182 -0
- package/memory/ingest.py +450 -0
- package/package.json +1 -1
package/mcp/__init__.py
CHANGED
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 []
|