loki-mode 7.63.1 → 7.65.0
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/app-runner.sh +110 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +81 -18
- package/docs/INSTALLATION.md +2 -2
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/mcp/server.py +27 -3
- package/memory/consolidation.py +22 -3
- package/memory/engine.py +157 -107
- package/memory/retrieval.py +105 -41
- package/memory/storage.py +131 -40
- package/memory/token_economics.py +38 -9
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
package/memory/storage.py
CHANGED
|
@@ -10,6 +10,7 @@ Supports namespace-based project isolation (v5.19.0).
|
|
|
10
10
|
import json
|
|
11
11
|
import math
|
|
12
12
|
import os
|
|
13
|
+
import re
|
|
13
14
|
import tempfile
|
|
14
15
|
import shutil
|
|
15
16
|
import fcntl
|
|
@@ -168,6 +169,28 @@ class MemoryStorage:
|
|
|
168
169
|
file_mtime = lock_file.stat().st_mtime
|
|
169
170
|
age_seconds = now_real - file_mtime
|
|
170
171
|
if age_seconds > stale_seconds:
|
|
172
|
+
# mtime alone is not proof the lock is abandoned: a
|
|
173
|
+
# long-running (>5min) writer still holds it. Unlinking
|
|
174
|
+
# it creates a new inode so a fresh writer can flock the
|
|
175
|
+
# new file while the old holder keeps writing the old
|
|
176
|
+
# one (two concurrent writers). Only remove it if we can
|
|
177
|
+
# take the lock ourselves (i.e. nobody holds it).
|
|
178
|
+
probe_fd = None
|
|
179
|
+
try:
|
|
180
|
+
probe_fd = open(lock_file, "a")
|
|
181
|
+
fcntl.flock(probe_fd.fileno(),
|
|
182
|
+
fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
183
|
+
except (OSError, BlockingIOError):
|
|
184
|
+
# Held by a live process -- leave it alone.
|
|
185
|
+
continue
|
|
186
|
+
finally:
|
|
187
|
+
if probe_fd is not None:
|
|
188
|
+
try:
|
|
189
|
+
fcntl.flock(probe_fd.fileno(),
|
|
190
|
+
fcntl.LOCK_UN)
|
|
191
|
+
except OSError:
|
|
192
|
+
pass
|
|
193
|
+
probe_fd.close()
|
|
171
194
|
lock_file.unlink()
|
|
172
195
|
except OSError:
|
|
173
196
|
pass
|
|
@@ -436,10 +459,25 @@ class MemoryStorage:
|
|
|
436
459
|
else:
|
|
437
460
|
date_str = timestamp.strftime("%Y-%m-%d")
|
|
438
461
|
|
|
462
|
+
# Path-traversal defense: a poisoned/round-tripped episode whose
|
|
463
|
+
# timestamp is e.g. "../../../../tmp/evil" would otherwise escape the
|
|
464
|
+
# memory root because the path is built straight from the field. Only
|
|
465
|
+
# an exact YYYY-MM-DD date string is allowed as the directory; anything
|
|
466
|
+
# else falls back to today's UTC date. The episode_id is also
|
|
467
|
+
# sanitized (mirrors save_skill) so separators and "." segments cannot
|
|
468
|
+
# leak into the filename.
|
|
469
|
+
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date_str):
|
|
470
|
+
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
471
|
+
|
|
472
|
+
safe_episode_id = "".join(
|
|
473
|
+
c if c.isalnum() or c in "-_" else "_"
|
|
474
|
+
for c in str(episode_id)
|
|
475
|
+
)
|
|
476
|
+
|
|
439
477
|
date_dir = self.base_path / "episodic" / date_str
|
|
440
478
|
date_dir.mkdir(parents=True, exist_ok=True)
|
|
441
479
|
|
|
442
|
-
file_path = date_dir / f"task-{
|
|
480
|
+
file_path = date_dir / f"task-{safe_episode_id}.json"
|
|
443
481
|
self._atomic_write(file_path, episode_data)
|
|
444
482
|
|
|
445
483
|
return episode_id
|
|
@@ -1153,7 +1191,12 @@ class MemoryStorage:
|
|
|
1153
1191
|
Returns:
|
|
1154
1192
|
Calculated importance score between 0.0 and 1.0
|
|
1155
1193
|
"""
|
|
1156
|
-
|
|
1194
|
+
# Guard against an explicit null importance (corrupt or hand-edited
|
|
1195
|
+
# record) crashing the arithmetic below with a TypeError. Use an is-None
|
|
1196
|
+
# check (not `or`) so a legitimate stored importance of 0.0 is preserved
|
|
1197
|
+
# rather than silently promoted to 0.5.
|
|
1198
|
+
base = memory.get("importance")
|
|
1199
|
+
base = 0.5 if base is None else base
|
|
1157
1200
|
|
|
1158
1201
|
# Outcome adjustment for episodes
|
|
1159
1202
|
outcome = memory.get("outcome", "")
|
|
@@ -1169,8 +1212,10 @@ class MemoryStorage:
|
|
|
1169
1212
|
if outcome == "success":
|
|
1170
1213
|
base = min(1.0, base + 0.05 * min(len(errors), 3))
|
|
1171
1214
|
|
|
1172
|
-
# Access frequency boost (diminishing returns)
|
|
1173
|
-
|
|
1215
|
+
# Access frequency boost (diminishing returns).
|
|
1216
|
+
# `or 0` guards against an explicit null access_count crashing the
|
|
1217
|
+
# comparison and log1p call below.
|
|
1218
|
+
access_count = memory.get("access_count") or 0
|
|
1174
1219
|
if access_count > 0:
|
|
1175
1220
|
# Log scale boost, caps at about 0.15 for 100+ accesses
|
|
1176
1221
|
access_boost = 0.05 * math.log1p(access_count)
|
|
@@ -1184,9 +1229,9 @@ class MemoryStorage:
|
|
|
1184
1229
|
|
|
1185
1230
|
# Task type relevance boost
|
|
1186
1231
|
if task_type:
|
|
1187
|
-
context = memory.get("context"
|
|
1188
|
-
phase = context.get("phase"
|
|
1189
|
-
category = memory.get("category"
|
|
1232
|
+
context = memory.get("context") or {}
|
|
1233
|
+
phase = (context.get("phase") or memory.get("phase") or "").lower()
|
|
1234
|
+
category = (memory.get("category") or "").lower()
|
|
1190
1235
|
|
|
1191
1236
|
task_type_lower = task_type.lower()
|
|
1192
1237
|
|
|
@@ -1254,7 +1299,12 @@ class MemoryStorage:
|
|
|
1254
1299
|
continue
|
|
1255
1300
|
|
|
1256
1301
|
# Apply exponential decay
|
|
1257
|
-
|
|
1302
|
+
# Use an is-None check (not get(..., 0.5) or `or`) so a record with
|
|
1303
|
+
# an explicit null importance (corrupt/hand-edited file) falls back
|
|
1304
|
+
# to the default instead of crashing the arithmetic, while a
|
|
1305
|
+
# legitimate stored 0.0 is preserved (it then floors at 0.01 below).
|
|
1306
|
+
current_importance = memory.get("importance")
|
|
1307
|
+
current_importance = 0.5 if current_importance is None else current_importance
|
|
1258
1308
|
decay_factor = math.exp(-decay_rate * days_elapsed / half_life_days)
|
|
1259
1309
|
decayed_importance = current_importance * decay_factor
|
|
1260
1310
|
|
|
@@ -1283,12 +1333,17 @@ class MemoryStorage:
|
|
|
1283
1333
|
"""
|
|
1284
1334
|
now = datetime.now(timezone.utc)
|
|
1285
1335
|
|
|
1286
|
-
# Update access tracking
|
|
1336
|
+
# Update access tracking. `or 0` guards against an explicit null
|
|
1337
|
+
# access_count (corrupt/hand-edited record) crashing the increment.
|
|
1287
1338
|
memory["last_accessed"] = now.isoformat()
|
|
1288
|
-
memory["access_count"] = memory.get("access_count"
|
|
1339
|
+
memory["access_count"] = (memory.get("access_count") or 0) + 1
|
|
1289
1340
|
|
|
1290
|
-
# Boost importance (with diminishing returns for high importance)
|
|
1291
|
-
|
|
1341
|
+
# Boost importance (with diminishing returns for high importance).
|
|
1342
|
+
# Use an is-None check (not `or`) so an explicit null importance
|
|
1343
|
+
# (corrupt/hand-edited record) falls back to the default without
|
|
1344
|
+
# crashing, while a legitimate stored 0.0 is preserved.
|
|
1345
|
+
current_importance = memory.get("importance")
|
|
1346
|
+
current_importance = 0.5 if current_importance is None else current_importance
|
|
1292
1347
|
|
|
1293
1348
|
# Diminishing returns: boost is reduced as importance approaches 1.0
|
|
1294
1349
|
effective_boost = boost * (1.0 - current_importance)
|
|
@@ -1346,11 +1401,23 @@ class MemoryStorage:
|
|
|
1346
1401
|
continue
|
|
1347
1402
|
|
|
1348
1403
|
for file_path in date_dir.glob("task-*.json"):
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1404
|
+
# Hold one exclusive lock spanning the read-mutate-write so a
|
|
1405
|
+
# concurrent writer cannot clobber the decayed record (lost
|
|
1406
|
+
# update). Raw open/json.load inside the lock mirrors
|
|
1407
|
+
# save_pattern; _atomic_write re-enters the same lock (no-op).
|
|
1408
|
+
with self._file_lock(file_path, exclusive=True):
|
|
1409
|
+
if not file_path.exists():
|
|
1410
|
+
continue
|
|
1411
|
+
try:
|
|
1412
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
1413
|
+
data = json.load(f)
|
|
1414
|
+
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
|
|
1415
|
+
continue
|
|
1416
|
+
if not data:
|
|
1417
|
+
continue
|
|
1418
|
+
original_importance = data.get("importance") or 0.5
|
|
1352
1419
|
memories = self.apply_decay([data], decay_rate, half_life_days)
|
|
1353
|
-
if abs(memories[0].get("importance"
|
|
1420
|
+
if abs((memories[0].get("importance") or 0.5) - original_importance) > 0.001:
|
|
1354
1421
|
self._atomic_write(file_path, memories[0])
|
|
1355
1422
|
updated += 1
|
|
1356
1423
|
|
|
@@ -1362,26 +1429,40 @@ class MemoryStorage:
|
|
|
1362
1429
|
if not patterns_path.exists():
|
|
1363
1430
|
return 0
|
|
1364
1431
|
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1432
|
+
# Hold ONE exclusive lock spanning the read-mutate-write. Previously
|
|
1433
|
+
# the read (_load_json) and write (_atomic_write) each took a separate
|
|
1434
|
+
# lock scope, so a concurrent save_pattern/update_pattern between them
|
|
1435
|
+
# was clobbered (stale-snapshot lost update). Mirror save_pattern:
|
|
1436
|
+
# raw open/json.load inside the lock for the read; _atomic_write
|
|
1437
|
+
# re-enters the same reentrant lock (no-op) for the write.
|
|
1438
|
+
with self._file_lock(patterns_path, exclusive=True):
|
|
1439
|
+
if not patterns_path.exists():
|
|
1440
|
+
return 0
|
|
1441
|
+
try:
|
|
1442
|
+
with open(patterns_path, "r", encoding="utf-8") as f:
|
|
1443
|
+
patterns_file = json.load(f)
|
|
1444
|
+
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
|
|
1445
|
+
return 0
|
|
1368
1446
|
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
return 0
|
|
1447
|
+
if not patterns_file:
|
|
1448
|
+
return 0
|
|
1372
1449
|
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
continue
|
|
1377
|
-
original = pattern.get("importance", 0.5)
|
|
1378
|
-
self.apply_decay([pattern], decay_rate, half_life_days)
|
|
1379
|
-
if abs(pattern.get("importance", 0.5) - original) > 0.001:
|
|
1380
|
-
updated += 1
|
|
1450
|
+
patterns = patterns_file.get("patterns", [])
|
|
1451
|
+
if not patterns:
|
|
1452
|
+
return 0
|
|
1381
1453
|
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1454
|
+
updated = 0
|
|
1455
|
+
for pattern in patterns:
|
|
1456
|
+
if not isinstance(pattern, dict):
|
|
1457
|
+
continue
|
|
1458
|
+
original = pattern.get("importance") or 0.5
|
|
1459
|
+
self.apply_decay([pattern], decay_rate, half_life_days)
|
|
1460
|
+
if abs((pattern.get("importance") or 0.5) - original) > 0.001:
|
|
1461
|
+
updated += 1
|
|
1462
|
+
|
|
1463
|
+
if updated > 0:
|
|
1464
|
+
patterns_file["last_updated"] = datetime.now(timezone.utc).isoformat()
|
|
1465
|
+
self._atomic_write(patterns_path, patterns_file)
|
|
1385
1466
|
|
|
1386
1467
|
return updated
|
|
1387
1468
|
|
|
@@ -1393,13 +1474,23 @@ class MemoryStorage:
|
|
|
1393
1474
|
return 0
|
|
1394
1475
|
|
|
1395
1476
|
for file_path in skills_dir.glob("*.json"):
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
if
|
|
1401
|
-
|
|
1402
|
-
|
|
1477
|
+
# Hold one exclusive lock spanning the read-mutate-write so a
|
|
1478
|
+
# concurrent writer cannot clobber the decayed record (lost
|
|
1479
|
+
# update). Mirrors _decay_semantic / save_pattern.
|
|
1480
|
+
with self._file_lock(file_path, exclusive=True):
|
|
1481
|
+
if not file_path.exists():
|
|
1482
|
+
continue
|
|
1483
|
+
try:
|
|
1484
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
1485
|
+
data = json.load(f)
|
|
1486
|
+
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
|
|
1487
|
+
continue
|
|
1488
|
+
if data:
|
|
1489
|
+
original = data.get("importance") or 0.5
|
|
1490
|
+
self.apply_decay([data], decay_rate, half_life_days)
|
|
1491
|
+
if abs((data.get("importance") or 0.5) - original) > 0.001:
|
|
1492
|
+
self._atomic_write(file_path, data)
|
|
1493
|
+
updated += 1
|
|
1403
1494
|
|
|
1404
1495
|
return updated
|
|
1405
1496
|
|
|
@@ -153,6 +153,7 @@ def optimize_context(
|
|
|
153
153
|
importance_weight: float = 0.4,
|
|
154
154
|
recency_weight: float = 0.3,
|
|
155
155
|
relevance_weight: float = 0.3,
|
|
156
|
+
slack_ratio: float = 0.0,
|
|
156
157
|
) -> list:
|
|
157
158
|
"""
|
|
158
159
|
Optimize memory selection to fit within token budget.
|
|
@@ -166,6 +167,19 @@ def optimize_context(
|
|
|
166
167
|
first, expanding to layer 2 (summary) and layer 3 (full) only if
|
|
167
168
|
budget allows.
|
|
168
169
|
|
|
170
|
+
Budget adherence is strict by default: the returned memories never
|
|
171
|
+
exceed `budget` total tokens. This matters because callers chain the
|
|
172
|
+
result (for example, layered retrieval subtracts each layer's tokens
|
|
173
|
+
from a running budget), so any overshoot here leaks into the overall
|
|
174
|
+
context budget and can blow the model's context window.
|
|
175
|
+
|
|
176
|
+
A caller that deliberately wants a greedy fill (admit one more small
|
|
177
|
+
item that nearly fits) can opt in via `slack_ratio`. The effective
|
|
178
|
+
cap is then `int(budget * (1.0 + slack_ratio))`, and only an item
|
|
179
|
+
whose own size is under 10% of `budget` is eligible for the slack so
|
|
180
|
+
a single large item can never consume the slack. With the default
|
|
181
|
+
`slack_ratio=0.0` the cap equals `budget` exactly (no overage).
|
|
182
|
+
|
|
169
183
|
Args:
|
|
170
184
|
memories: List of memory dictionaries with optional fields:
|
|
171
185
|
- _score: relevance score from retrieval
|
|
@@ -178,10 +192,14 @@ def optimize_context(
|
|
|
178
192
|
importance_weight: Weight for importance scoring (default 0.4)
|
|
179
193
|
recency_weight: Weight for recency scoring (default 0.3)
|
|
180
194
|
relevance_weight: Weight for relevance scoring (default 0.3)
|
|
195
|
+
slack_ratio: Optional fractional overage allowed above `budget`
|
|
196
|
+
for small items only (default 0.0 = strict, never exceed
|
|
197
|
+
`budget`). Negative values are clamped to 0.0.
|
|
181
198
|
|
|
182
199
|
Returns:
|
|
183
|
-
List of memories that fit within the token
|
|
184
|
-
combined score.
|
|
200
|
+
List of memories that fit within the (slack-adjusted) token
|
|
201
|
+
budget, sorted by combined score. With the default
|
|
202
|
+
slack_ratio=0.0 the total never exceeds `budget`.
|
|
185
203
|
"""
|
|
186
204
|
from datetime import datetime, timezone
|
|
187
205
|
|
|
@@ -195,9 +213,14 @@ def optimize_context(
|
|
|
195
213
|
now = datetime.now(timezone.utc)
|
|
196
214
|
|
|
197
215
|
for memory in memories:
|
|
198
|
-
# Calculate importance score (0-1)
|
|
199
|
-
|
|
200
|
-
|
|
216
|
+
# Calculate importance score (0-1). Guard against explicit null fields
|
|
217
|
+
# (corrupt/hand-edited record): .get(key, default) returns None when the
|
|
218
|
+
# key is present but null, which would crash the arithmetic below. Use an
|
|
219
|
+
# is-None check for confidence (not `or`) so a legitimate stored 0.0 is
|
|
220
|
+
# preserved; usage_count of None and 0 are equivalent so `or 0` is fine.
|
|
221
|
+
confidence = memory.get("confidence")
|
|
222
|
+
confidence = 0.5 if confidence is None else confidence
|
|
223
|
+
usage_count = memory.get("usage_count") or 0
|
|
201
224
|
# Normalize usage count with diminishing returns
|
|
202
225
|
usage_score = min(1.0, usage_count / 10.0) if usage_count > 0 else 0.0
|
|
203
226
|
importance = (confidence + usage_score) / 2.0
|
|
@@ -261,7 +284,13 @@ def optimize_context(
|
|
|
261
284
|
# Sort by score (highest first)
|
|
262
285
|
scored_memories.sort(key=lambda x: x["score"], reverse=True)
|
|
263
286
|
|
|
264
|
-
# Select memories that fit within budget
|
|
287
|
+
# Select memories that fit within budget.
|
|
288
|
+
# Strict by default (slack_ratio=0.0 -> hard_cap == budget): the total
|
|
289
|
+
# never exceeds `budget`. A positive slack_ratio opts into a bounded
|
|
290
|
+
# greedy fill for small items only.
|
|
291
|
+
slack = max(0.0, slack_ratio)
|
|
292
|
+
hard_cap = int(budget * (1.0 + slack))
|
|
293
|
+
|
|
265
294
|
selected = []
|
|
266
295
|
total_tokens = 0
|
|
267
296
|
|
|
@@ -269,9 +298,9 @@ def optimize_context(
|
|
|
269
298
|
if total_tokens + item["tokens"] <= budget:
|
|
270
299
|
selected.append(item["memory"])
|
|
271
300
|
total_tokens += item["tokens"]
|
|
272
|
-
elif item["tokens"] < budget * 0.1:
|
|
273
|
-
# Allow small memories
|
|
274
|
-
if total_tokens + item["tokens"] <=
|
|
301
|
+
elif slack > 0.0 and item["tokens"] < budget * 0.1:
|
|
302
|
+
# Allow small memories into the explicit, bounded slack region.
|
|
303
|
+
if total_tokens + item["tokens"] <= hard_cap:
|
|
275
304
|
selected.append(item["memory"])
|
|
276
305
|
total_tokens += item["tokens"]
|
|
277
306
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loki-mode",
|
|
3
3
|
"mcpName": "io.github.asklokesh/loki-mode",
|
|
4
|
-
"version": "7.
|
|
4
|
+
"version": "7.65.0",
|
|
5
5
|
"description": "Loki Mode by Autonomi. Autonomous spec-to-product system: takes a PRD, GitHub issue, OpenAPI/JSON/YAML, or one-line brief to a deployed app via the RARV-C closure loop with 8 quality gates. Provider-agnostic (Claude Code, OpenAI Codex, Cline, Aider).",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"agent",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "loki-mode",
|
|
4
4
|
"displayName": "Loki Mode",
|
|
5
|
-
"version": "7.
|
|
5
|
+
"version": "7.65.0",
|
|
6
6
|
"description": "Autonomous spec-to-product build system with a built-in trust layer (RARV-C closure loop, 8 quality gates, completion council). Ships Loki's spec-hardening, drift-detection, and deterministic PR verification commands plus the Loki MCP server.",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "Autonomi",
|