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/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-{episode_id}.json"
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
- base = memory.get("importance", 0.5)
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
- access_count = memory.get("access_count", 0)
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", memory.get("phase", "")).lower()
1189
- category = memory.get("category", "").lower()
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
- current_importance = memory.get("importance", 0.5)
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", 0) + 1
1339
+ memory["access_count"] = (memory.get("access_count") or 0) + 1
1289
1340
 
1290
- # Boost importance (with diminishing returns for high importance)
1291
- current_importance = memory.get("importance", 0.5)
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
- data = self._load_json(file_path)
1350
- if data:
1351
- original_importance = data.get("importance", 0.5)
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", 0.5) - original_importance) > 0.001:
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
- patterns_file = self._load_json(patterns_path)
1366
- if not patterns_file:
1367
- return 0
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
- patterns = patterns_file.get("patterns", [])
1370
- if not patterns:
1371
- return 0
1447
+ if not patterns_file:
1448
+ return 0
1372
1449
 
1373
- updated = 0
1374
- for pattern in patterns:
1375
- if not isinstance(pattern, dict):
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
- if updated > 0:
1383
- patterns_file["last_updated"] = datetime.now(timezone.utc).isoformat()
1384
- self._atomic_write(patterns_path, patterns_file)
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
- data = self._load_json(file_path)
1397
- if data:
1398
- original = data.get("importance", 0.5)
1399
- self.apply_decay([data], decay_rate, half_life_days)
1400
- if abs(data.get("importance", 0.5) - original) > 0.001:
1401
- self._atomic_write(file_path, data)
1402
- updated += 1
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 budget, sorted by
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
- confidence = memory.get("confidence", 0.5)
200
- usage_count = memory.get("usage_count", 0)
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 even if slightly over budget
274
- if total_tokens + item["tokens"] <= budget * 1.1:
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.63.1",
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.63.1",
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",