loki-mode 7.67.0 → 7.69.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 +14 -2
- package/autonomy/completion-council.sh +34 -3
- package/autonomy/loki +162 -75
- package/autonomy/run.sh +39 -54
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +57 -25
- package/docs/INSTALLATION.md +2 -2
- package/loki-ts/dist/loki.js +66 -66
- package/mcp/__init__.py +1 -1
- package/mcp/server.py +12 -0
- package/memory/consolidation.py +15 -1
- package/memory/engine.py +25 -5
- package/memory/retrieval.py +18 -1
- package/memory/storage.py +136 -0
- package/memory/token_economics.py +9 -0
- package/memory/vector_index.py +13 -0
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
package/mcp/__init__.py
CHANGED
package/mcp/server.py
CHANGED
|
@@ -1358,14 +1358,17 @@ async def loki_start_project(prd_content: str = "", prd_path: str = "") -> str:
|
|
|
1358
1358
|
try:
|
|
1359
1359
|
resolved = validate_path(prd_path, allowed_dirs=['.'])
|
|
1360
1360
|
except PathTraversalError as e:
|
|
1361
|
+
_emit_tool_event_async('loki_start_project', 'complete', result_status='error', error=str(e))
|
|
1361
1362
|
return json.dumps({"error": str(e)})
|
|
1362
1363
|
if os.path.exists(resolved) and os.path.isfile(resolved):
|
|
1363
1364
|
with open(resolved, 'r', encoding='utf-8') as f:
|
|
1364
1365
|
content = f.read()
|
|
1365
1366
|
else:
|
|
1367
|
+
_emit_tool_event_async('loki_start_project', 'complete', result_status='error', error='PRD file not found')
|
|
1366
1368
|
return json.dumps({"error": f"PRD file not found: {prd_path}"})
|
|
1367
1369
|
|
|
1368
1370
|
if not content:
|
|
1371
|
+
_emit_tool_event_async('loki_start_project', 'complete', result_status='error', error='No PRD content or path provided')
|
|
1369
1372
|
return json.dumps({"error": "No PRD content or path provided"})
|
|
1370
1373
|
|
|
1371
1374
|
# Initialize project state using safe path operations
|
|
@@ -1390,6 +1393,7 @@ async def loki_start_project(prd_content: str = "", prd_path: str = "") -> str:
|
|
|
1390
1393
|
_emit_tool_event_async('loki_start_project', 'complete', result_status='success')
|
|
1391
1394
|
return json.dumps({"success": True, **project})
|
|
1392
1395
|
except PathTraversalError as e:
|
|
1396
|
+
_emit_tool_event_async('loki_start_project', 'complete', result_status='error', error='Access denied')
|
|
1393
1397
|
return json.dumps({"error": f"Access denied: {e}"})
|
|
1394
1398
|
except Exception as e:
|
|
1395
1399
|
logger.error(f"Start project failed: {e}")
|
|
@@ -1440,6 +1444,7 @@ async def loki_project_status() -> str:
|
|
|
1440
1444
|
_emit_tool_event_async('loki_project_status', 'complete', result_status='success')
|
|
1441
1445
|
return json.dumps(status, default=str)
|
|
1442
1446
|
except PathTraversalError:
|
|
1447
|
+
_emit_tool_event_async('loki_project_status', 'complete', result_status='error', error='Access denied')
|
|
1443
1448
|
return json.dumps({"error": "Access denied"})
|
|
1444
1449
|
except Exception as e:
|
|
1445
1450
|
logger.error(f"Project status failed: {e}")
|
|
@@ -1478,6 +1483,7 @@ async def loki_agent_metrics() -> str:
|
|
|
1478
1483
|
_emit_tool_event_async('loki_agent_metrics', 'complete', result_status='success')
|
|
1479
1484
|
return json.dumps(metrics, default=str)
|
|
1480
1485
|
except PathTraversalError:
|
|
1486
|
+
_emit_tool_event_async('loki_agent_metrics', 'complete', result_status='error', error='Access denied')
|
|
1481
1487
|
return json.dumps({"error": "Access denied"})
|
|
1482
1488
|
except Exception as e:
|
|
1483
1489
|
logger.error(f"Agent metrics failed: {e}")
|
|
@@ -1500,6 +1506,7 @@ async def loki_checkpoint_restore(checkpoint_id: str = "") -> str:
|
|
|
1500
1506
|
try:
|
|
1501
1507
|
cp_dir = safe_path_join('.loki', 'state', 'checkpoints')
|
|
1502
1508
|
if not os.path.isdir(cp_dir):
|
|
1509
|
+
_emit_tool_event_async('loki_checkpoint_restore', 'complete', result_status='success')
|
|
1503
1510
|
return json.dumps({"checkpoints": [], "message": "No checkpoints directory"})
|
|
1504
1511
|
|
|
1505
1512
|
checkpoints = []
|
|
@@ -1518,6 +1525,7 @@ async def loki_checkpoint_restore(checkpoint_id: str = "") -> str:
|
|
|
1518
1525
|
# Find and restore specific checkpoint
|
|
1519
1526
|
target = next((c for c in checkpoints if c["id"] == checkpoint_id), None)
|
|
1520
1527
|
if not target:
|
|
1528
|
+
_emit_tool_event_async('loki_checkpoint_restore', 'complete', result_status='error', error='Checkpoint not found')
|
|
1521
1529
|
return json.dumps({"error": f"Checkpoint not found: {checkpoint_id}"})
|
|
1522
1530
|
|
|
1523
1531
|
# Write checkpoint state as current state, stripping the injected "id" field
|
|
@@ -1529,6 +1537,7 @@ async def loki_checkpoint_restore(checkpoint_id: str = "") -> str:
|
|
|
1529
1537
|
_emit_tool_event_async('loki_checkpoint_restore', 'complete', result_status='success')
|
|
1530
1538
|
return json.dumps({"restored": True, "checkpoint_id": checkpoint_id})
|
|
1531
1539
|
except PathTraversalError:
|
|
1540
|
+
_emit_tool_event_async('loki_checkpoint_restore', 'complete', result_status='error', error='Access denied')
|
|
1532
1541
|
return json.dumps({"error": "Access denied"})
|
|
1533
1542
|
except Exception as e:
|
|
1534
1543
|
logger.error(f"Checkpoint restore failed: {e}")
|
|
@@ -1569,6 +1578,7 @@ async def loki_quality_report() -> str:
|
|
|
1569
1578
|
_emit_tool_event_async('loki_quality_report', 'complete', result_status='success')
|
|
1570
1579
|
return json.dumps(report, default=str)
|
|
1571
1580
|
except PathTraversalError:
|
|
1581
|
+
_emit_tool_event_async('loki_quality_report', 'complete', result_status='error', error='Access denied')
|
|
1572
1582
|
return json.dumps({"error": "Access denied"})
|
|
1573
1583
|
except Exception as e:
|
|
1574
1584
|
logger.error(f"Quality report failed: {e}")
|
|
@@ -2057,6 +2067,7 @@ async def mem_get(
|
|
|
2057
2067
|
|
|
2058
2068
|
id_list = [i.strip() for i in ids.split(",") if i.strip()]
|
|
2059
2069
|
if not id_list:
|
|
2070
|
+
_emit_tool_event_async('mem_get', 'complete', result_status='error', error='No IDs provided')
|
|
2060
2071
|
return json.dumps({"entries": {}, "error": "No IDs provided"})
|
|
2061
2072
|
|
|
2062
2073
|
# Cap at 20 to prevent abuse
|
|
@@ -2401,6 +2412,7 @@ async def loki_learnings(limit: int = 50) -> str:
|
|
|
2401
2412
|
try:
|
|
2402
2413
|
path = safe_path_join('.loki', 'state', 'relevant-learnings.json')
|
|
2403
2414
|
if not os.path.exists(path):
|
|
2415
|
+
_emit_tool_event_async('loki_learnings', 'complete', result_status='success')
|
|
2404
2416
|
return json.dumps({"version": 1, "learnings": [], "total": 0})
|
|
2405
2417
|
try:
|
|
2406
2418
|
with safe_open(path, 'r') as f:
|
package/memory/consolidation.py
CHANGED
|
@@ -839,6 +839,20 @@ class ConsolidationPipeline:
|
|
|
839
839
|
if best_match is None or best_similarity < 0.5:
|
|
840
840
|
return new_pattern
|
|
841
841
|
|
|
842
|
+
# Idempotency guard (consolidation-C4): only boost confidence when the
|
|
843
|
+
# merge actually introduces NEW evidence. consolidate() reloads every
|
|
844
|
+
# episode in the since-window on each run (storage.list_episodes has no
|
|
845
|
+
# consolidated-state filter), so re-running over an unchanged episode set
|
|
846
|
+
# re-extracts identical patterns that re-match this existing pattern. A
|
|
847
|
+
# flat +0.05 every time would ratchet confidence up artificially with no
|
|
848
|
+
# new data. Comparing source_episodes (which round-trips through storage)
|
|
849
|
+
# makes the merge a no-op for confidence when no new source episode is
|
|
850
|
+
# present, while still rewarding a genuinely new similar episode.
|
|
851
|
+
new_source_episodes = (
|
|
852
|
+
set(new_pattern.source_episodes) - set(best_match.source_episodes)
|
|
853
|
+
)
|
|
854
|
+
confidence_boost = 0.05 if new_source_episodes else 0.0
|
|
855
|
+
|
|
842
856
|
# Merge patterns
|
|
843
857
|
merged = SemanticPattern(
|
|
844
858
|
id=best_match.id,
|
|
@@ -847,7 +861,7 @@ class ConsolidationPipeline:
|
|
|
847
861
|
conditions=list(set(best_match.conditions + new_pattern.conditions)),
|
|
848
862
|
correct_approach=best_match.correct_approach or new_pattern.correct_approach,
|
|
849
863
|
incorrect_approach=best_match.incorrect_approach or new_pattern.incorrect_approach,
|
|
850
|
-
confidence=min(best_match.confidence +
|
|
864
|
+
confidence=min(best_match.confidence + confidence_boost, 0.99),
|
|
851
865
|
source_episodes=list(set(best_match.source_episodes + new_pattern.source_episodes)),
|
|
852
866
|
usage_count=best_match.usage_count,
|
|
853
867
|
last_used=best_match.last_used,
|
package/memory/engine.py
CHANGED
|
@@ -244,7 +244,11 @@ class MemoryEngine:
|
|
|
244
244
|
for pattern in patterns_data.get("patterns", []):
|
|
245
245
|
if not isinstance(pattern, dict):
|
|
246
246
|
continue
|
|
247
|
-
|
|
247
|
+
# `or []` guards an explicit null source_episodes (corrupt or
|
|
248
|
+
# hand-edited record): .get(..., []) returns None on a stored
|
|
249
|
+
# null, and set.update(None) raises TypeError, crashing the whole
|
|
250
|
+
# cleanup pass. A null and an empty list are equivalent here.
|
|
251
|
+
referenced_ids.update(pattern.get("source_episodes") or [])
|
|
248
252
|
|
|
249
253
|
# Scan episodic directories
|
|
250
254
|
episodic_path = Path(self.base_path) / "episodic"
|
|
@@ -376,17 +380,28 @@ class MemoryEngine:
|
|
|
376
380
|
"last_accessed": now,
|
|
377
381
|
"relevance_score": 0.5,
|
|
378
382
|
})
|
|
379
|
-
|
|
383
|
+
# total_memories counts memories (episodes), not topics, to
|
|
384
|
+
# match the canonical rebuild_index semantics
|
|
385
|
+
# (total_memories += 1 per episode at line ~860). Previously
|
|
386
|
+
# this only incremented when a NEW topic was created, so N
|
|
387
|
+
# episodes sharing one phase reported total_memories=1 until
|
|
388
|
+
# the user ran rebuild_index, which then jumped it to N
|
|
389
|
+
# (two functions in this file disagreeing on the same field,
|
|
390
|
+
# surfaced by get_stats). Increment per distinct episode.
|
|
391
|
+
if episode_id:
|
|
392
|
+
index["total_memories"] = index.get("total_memories", 0) + 1
|
|
380
393
|
else:
|
|
381
394
|
# Only count a given episode once. On resume/checkpoint the same
|
|
382
395
|
# trace id can be re-saved; without this guard episode_count,
|
|
383
|
-
# total_cost_usd, and
|
|
384
|
-
# even though episode_ids is already
|
|
396
|
+
# total_cost_usd, total_tokens, and total_memories would inflate
|
|
397
|
+
# on every re-save even though episode_ids is already
|
|
398
|
+
# de-duplicated.
|
|
385
399
|
if episode_id and episode_id not in found.get("episode_ids", []):
|
|
386
400
|
found.setdefault("episode_ids", []).append(episode_id)
|
|
387
401
|
found["episode_count"] = found.get("episode_count", 0) + 1
|
|
388
402
|
found["total_cost_usd"] = float(found.get("total_cost_usd", 0) or 0) + cost
|
|
389
403
|
found["total_tokens"] = int(found.get("total_tokens", 0) or 0) + tokens
|
|
404
|
+
index["total_memories"] = index.get("total_memories", 0) + 1
|
|
390
405
|
merged = set(found.get("files_touched", []) or []) | set(files[:20])
|
|
391
406
|
found["files_touched"] = sorted(merged)[:50]
|
|
392
407
|
found["last_accessed"] = now
|
|
@@ -878,7 +893,12 @@ class MemoryEngine:
|
|
|
878
893
|
}
|
|
879
894
|
|
|
880
895
|
topics[phase]["token_count"] += tokens
|
|
881
|
-
|
|
896
|
+
# `or ""` on BOTH sides: an episode with a missing or
|
|
897
|
+
# explicit-null timestamp leaves last_accessed=None (set
|
|
898
|
+
# from data.get("timestamp") above), and `str > None`
|
|
899
|
+
# raises TypeError, crashing rebuild_index. A single
|
|
900
|
+
# such episode is enough to break the whole rebuild.
|
|
901
|
+
if (data.get("timestamp") or "") > (topics[phase].get("last_accessed") or ""):
|
|
882
902
|
topics[phase]["last_accessed"] = data.get("timestamp")
|
|
883
903
|
|
|
884
904
|
# Index semantic patterns
|
package/memory/retrieval.py
CHANGED
|
@@ -414,6 +414,7 @@ class MemoryRetrieval:
|
|
|
414
414
|
context: Dict[str, Any],
|
|
415
415
|
top_k: int = 5,
|
|
416
416
|
token_budget: Optional[int] = None,
|
|
417
|
+
persist_boost: bool = False,
|
|
417
418
|
) -> List[Dict[str, Any]]:
|
|
418
419
|
"""
|
|
419
420
|
Retrieve memories with task-type-aware weighting.
|
|
@@ -427,6 +428,12 @@ class MemoryRetrieval:
|
|
|
427
428
|
token_budget: Optional maximum token budget for returned memories.
|
|
428
429
|
If specified, results will be optimized to fit within
|
|
429
430
|
this budget using importance/recency/relevance scoring.
|
|
431
|
+
persist_boost: When True, persist the retrieval-time importance boost
|
|
432
|
+
to disk ("use it or lose it" reinforcement). Default
|
|
433
|
+
False so manual/on-demand retrievals (dashboard, MCP)
|
|
434
|
+
do NOT silently reinforce importance; only the autonomous
|
|
435
|
+
RARV loop opts in. The in-memory boost that shapes the
|
|
436
|
+
returned ranking is applied either way.
|
|
430
437
|
|
|
431
438
|
Returns:
|
|
432
439
|
List of memory items with source field indicating origin
|
|
@@ -476,10 +483,20 @@ class MemoryRetrieval:
|
|
|
476
483
|
# Apply recency boost
|
|
477
484
|
merged = self._apply_recency_boost(merged, boost_factor=0.1)
|
|
478
485
|
|
|
479
|
-
# Boost importance for retrieved memories (use it or lose it)
|
|
486
|
+
# Boost importance for retrieved memories (use it or lose it). The
|
|
487
|
+
# in-memory boost shapes the returned ranking; persist_boost writes the
|
|
488
|
+
# reinforcement to disk (retrieval-F1: boost_on_retrieval alone never
|
|
489
|
+
# persisted). Persistence is best-effort: a locked/missing record must
|
|
490
|
+
# never break retrieval, so failures are swallowed (mirrors other
|
|
491
|
+
# best-effort writes).
|
|
480
492
|
if hasattr(self.storage, 'boost_on_retrieval'):
|
|
481
493
|
for memory in merged[:top_k]:
|
|
482
494
|
self.storage.boost_on_retrieval(memory, boost=0.05)
|
|
495
|
+
if persist_boost and hasattr(self.storage, 'persist_boost'):
|
|
496
|
+
try:
|
|
497
|
+
self.storage.persist_boost(memory, boost=0.05)
|
|
498
|
+
except Exception:
|
|
499
|
+
pass
|
|
483
500
|
|
|
484
501
|
# Apply token budget optimization if specified
|
|
485
502
|
if token_budget is not None and token_budget > 0:
|
package/memory/storage.py
CHANGED
|
@@ -1383,6 +1383,142 @@ class MemoryStorage:
|
|
|
1383
1383
|
|
|
1384
1384
|
return memory
|
|
1385
1385
|
|
|
1386
|
+
def persist_boost(
|
|
1387
|
+
self,
|
|
1388
|
+
memory: Dict[str, Any],
|
|
1389
|
+
boost: float = 0.1,
|
|
1390
|
+
) -> bool:
|
|
1391
|
+
"""
|
|
1392
|
+
Persist a retrieval-time boost to disk ("use it or lose it").
|
|
1393
|
+
|
|
1394
|
+
boost_on_retrieval mutates an in-memory dict only; without this the
|
|
1395
|
+
stored importance/access_count never rises, so repeated retrieval can
|
|
1396
|
+
never reinforce a memory against decay (retrieval-F1). This method
|
|
1397
|
+
applies the SAME boost math to the record as it currently exists on
|
|
1398
|
+
disk, under one exclusive _file_lock spanning a FRESH read -> mutate
|
|
1399
|
+
-> _atomic_write (mirrors _decay_episodic / _decay_semantic).
|
|
1400
|
+
|
|
1401
|
+
Race-safety: the boost is applied to the freshly-read record, NOT to
|
|
1402
|
+
the passed-in `memory` dict. So a concurrent content edit landed by
|
|
1403
|
+
another writer is preserved (we only overwrite importance,
|
|
1404
|
+
access_count, last_accessed), and no retrieval-only transient fields
|
|
1405
|
+
(_score, _source, _collection) leak into the stored record. This is
|
|
1406
|
+
the lost-update-safe pattern WAVE6 established for decay.
|
|
1407
|
+
|
|
1408
|
+
Keyed by memory["id"] and the collection marker retrieval attaches
|
|
1409
|
+
(_source, falling back to _collection). Covers episodic (per-file) and
|
|
1410
|
+
semantic patterns.json. Collections without an updater degrade
|
|
1411
|
+
gracefully (return False, no crash):
|
|
1412
|
+
- skills are keyed on disk by name, not id, so an id-keyed boost
|
|
1413
|
+
cannot reliably target the file; skipped honestly.
|
|
1414
|
+
- the legacy semantic/anti-patterns.json store has NO updater
|
|
1415
|
+
anywhere in this module, so there is nothing to write back to;
|
|
1416
|
+
skipped honestly rather than fabricating a writer.
|
|
1417
|
+
|
|
1418
|
+
Args:
|
|
1419
|
+
memory: A retrieved memory dict (must carry "id" and a source
|
|
1420
|
+
marker). The dict itself is not written to disk.
|
|
1421
|
+
boost: Amount to boost importance (default 0.1).
|
|
1422
|
+
|
|
1423
|
+
Returns:
|
|
1424
|
+
True if a record was found and persisted, False otherwise.
|
|
1425
|
+
"""
|
|
1426
|
+
memory_id = memory.get("id")
|
|
1427
|
+
if not memory_id:
|
|
1428
|
+
return False
|
|
1429
|
+
|
|
1430
|
+
source = memory.get("_source") or memory.get("_collection") or ""
|
|
1431
|
+
|
|
1432
|
+
if source == "episodic":
|
|
1433
|
+
return self._persist_boost_episodic(str(memory_id), boost)
|
|
1434
|
+
if source == "semantic":
|
|
1435
|
+
return self._persist_boost_semantic(str(memory_id), boost)
|
|
1436
|
+
|
|
1437
|
+
# skills (keyed by name on disk) and the legacy anti-patterns.json
|
|
1438
|
+
# store (no updater exists in this module) cannot be safely targeted
|
|
1439
|
+
# by an id-keyed boost; skip rather than fabricate a writer.
|
|
1440
|
+
return False
|
|
1441
|
+
|
|
1442
|
+
def _persist_boost_episodic(self, memory_id: str, boost: float) -> bool:
|
|
1443
|
+
"""Apply and persist a boost to one episodic record, keyed by id.
|
|
1444
|
+
|
|
1445
|
+
Locates the per-file record (task-<id>.json across date dirs) then does
|
|
1446
|
+
a lock-spanning fresh-read -> boost -> atomic-write, mirroring
|
|
1447
|
+
_decay_episodic. The id is sanitized exactly as save_episode does so a
|
|
1448
|
+
sanitized-on-write filename is still found.
|
|
1449
|
+
"""
|
|
1450
|
+
episodic_dir = self.base_path / "episodic"
|
|
1451
|
+
if not episodic_dir.exists():
|
|
1452
|
+
return False
|
|
1453
|
+
|
|
1454
|
+
safe_id = "".join(
|
|
1455
|
+
c if c.isalnum() or c in "-_" else "_"
|
|
1456
|
+
for c in memory_id
|
|
1457
|
+
)
|
|
1458
|
+
|
|
1459
|
+
for date_dir in episodic_dir.iterdir():
|
|
1460
|
+
if not date_dir.is_dir():
|
|
1461
|
+
continue
|
|
1462
|
+
file_path = date_dir / f"task-{safe_id}.json"
|
|
1463
|
+
if not file_path.exists():
|
|
1464
|
+
continue
|
|
1465
|
+
|
|
1466
|
+
# One exclusive lock spanning read-mutate-write. boost_on_retrieval
|
|
1467
|
+
# mutates the freshly-read record in place (importance/access_count/
|
|
1468
|
+
# last_accessed only), so a concurrent content edit on disk is
|
|
1469
|
+
# preserved. _atomic_write re-enters the same reentrant lock.
|
|
1470
|
+
with self._file_lock(file_path, exclusive=True):
|
|
1471
|
+
if not file_path.exists():
|
|
1472
|
+
return False
|
|
1473
|
+
try:
|
|
1474
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
1475
|
+
data = json.load(f)
|
|
1476
|
+
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
|
|
1477
|
+
return False
|
|
1478
|
+
if not data:
|
|
1479
|
+
return False
|
|
1480
|
+
self.boost_on_retrieval(data, boost=boost)
|
|
1481
|
+
self._atomic_write(file_path, data)
|
|
1482
|
+
return True
|
|
1483
|
+
|
|
1484
|
+
return False
|
|
1485
|
+
|
|
1486
|
+
def _persist_boost_semantic(self, memory_id: str, boost: float) -> bool:
|
|
1487
|
+
"""Apply and persist a boost to one semantic pattern, keyed by id.
|
|
1488
|
+
|
|
1489
|
+
Patterns live in a single semantic/patterns.json list. Lock-spanning
|
|
1490
|
+
fresh read -> boost the matching entry -> atomic write, mirroring
|
|
1491
|
+
_decay_semantic / save_pattern.
|
|
1492
|
+
"""
|
|
1493
|
+
patterns_path = self.base_path / "semantic" / "patterns.json"
|
|
1494
|
+
if not patterns_path.exists():
|
|
1495
|
+
return False
|
|
1496
|
+
|
|
1497
|
+
with self._file_lock(patterns_path, exclusive=True):
|
|
1498
|
+
if not patterns_path.exists():
|
|
1499
|
+
return False
|
|
1500
|
+
try:
|
|
1501
|
+
with open(patterns_path, "r", encoding="utf-8") as f:
|
|
1502
|
+
patterns_file = json.load(f)
|
|
1503
|
+
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
|
|
1504
|
+
return False
|
|
1505
|
+
if not patterns_file:
|
|
1506
|
+
return False
|
|
1507
|
+
|
|
1508
|
+
patterns = patterns_file.get("patterns", [])
|
|
1509
|
+
for pattern in patterns:
|
|
1510
|
+
if not isinstance(pattern, dict):
|
|
1511
|
+
continue
|
|
1512
|
+
if pattern.get("id") == memory_id:
|
|
1513
|
+
self.boost_on_retrieval(pattern, boost=boost)
|
|
1514
|
+
patterns_file["last_updated"] = datetime.now(
|
|
1515
|
+
timezone.utc
|
|
1516
|
+
).isoformat()
|
|
1517
|
+
self._atomic_write(patterns_path, patterns_file)
|
|
1518
|
+
return True
|
|
1519
|
+
|
|
1520
|
+
return False
|
|
1521
|
+
|
|
1386
1522
|
def batch_apply_decay(
|
|
1387
1523
|
self,
|
|
1388
1524
|
collection: str = "all",
|
|
@@ -256,6 +256,15 @@ def optimize_context(
|
|
|
256
256
|
relevance = memory.get("_weighted_score", 0.5)
|
|
257
257
|
else:
|
|
258
258
|
relevance = memory.get("_score", 0.5)
|
|
259
|
+
# Guard against an explicit null score in a corrupt/hand-edited
|
|
260
|
+
# record: .get(key, default) returns None when the key is present
|
|
261
|
+
# but null, and the `relevance > 1.0` comparison below would then
|
|
262
|
+
# raise "'>' not supported between instances of 'NoneType' and
|
|
263
|
+
# 'float'". Use an is-None check (not `or`) so a legitimate stored
|
|
264
|
+
# 0.0 relevance is preserved, mirroring the confidence guard above
|
|
265
|
+
# and retrieval.py's own base_score guard.
|
|
266
|
+
if relevance is None:
|
|
267
|
+
relevance = 0.5
|
|
259
268
|
if relevance > 1.0:
|
|
260
269
|
# Normalize high scores
|
|
261
270
|
relevance = min(1.0, relevance / 10.0)
|
package/memory/vector_index.py
CHANGED
|
@@ -42,7 +42,20 @@ class VectorIndex:
|
|
|
42
42
|
Args:
|
|
43
43
|
dimension: The dimensionality of vectors. Default is 384
|
|
44
44
|
which matches MiniLM embedding size.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
ImportError: If numpy is not installed. Every operation in this
|
|
48
|
+
index (add, search, save, load) relies on numpy, so fail
|
|
49
|
+
fast with a clear message here rather than letting a later
|
|
50
|
+
call crash with an opaque ``AttributeError: 'NoneType'
|
|
51
|
+
object has no attribute ...`` once ``np`` is None. The
|
|
52
|
+
module-level NUMPY_AVAILABLE flag is what gates this check.
|
|
45
53
|
"""
|
|
54
|
+
if not NUMPY_AVAILABLE:
|
|
55
|
+
raise ImportError(
|
|
56
|
+
"numpy is required for the vector index. "
|
|
57
|
+
"Install it with: pip install numpy"
|
|
58
|
+
)
|
|
46
59
|
self.dimension = dimension
|
|
47
60
|
self.embeddings: List[np.ndarray] = []
|
|
48
61
|
self.ids: List[str] = []
|
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.69.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.69.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",
|