loki-mode 7.95.0 → 7.97.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/hooks/migration-hooks.sh +74 -10
- package/autonomy/run.sh +104 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/static/index.html +162 -156
- package/docs/INSTALLATION.md +1 -1
- package/events/bus.py +11 -1
- package/events/bus.ts +7 -1
- package/events/emit.sh +35 -10
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/mcp/learning_collector.py +18 -0
- package/mcp/server.py +18 -3
- package/memory/consolidation.py +55 -64
- package/memory/engine.py +60 -57
- package/memory/rag_injector.py +71 -6
- package/memory/retrieval.py +42 -12
- package/memory/storage.py +124 -18
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
package/memory/retrieval.py
CHANGED
|
@@ -269,6 +269,7 @@ class MemoryRetrieval:
|
|
|
269
269
|
vector_indices: Optional[Dict[str, VectorIndex]] = None,
|
|
270
270
|
base_path: str = ".loki/memory",
|
|
271
271
|
namespace: Optional[str] = None,
|
|
272
|
+
include_unstamped_legacy: bool = False,
|
|
272
273
|
):
|
|
273
274
|
"""
|
|
274
275
|
Initialize the memory retrieval system.
|
|
@@ -279,12 +280,20 @@ class MemoryRetrieval:
|
|
|
279
280
|
vector_indices: Optional dict of vector indices (episodic, semantic, skills)
|
|
280
281
|
base_path: Base path for memory storage directory
|
|
281
282
|
namespace: Optional namespace for scoped retrieval
|
|
283
|
+
include_unstamped_legacy: Opt-in escape hatch. When False (the
|
|
284
|
+
secure default), legacy entries that lack a "_namespace" stamp
|
|
285
|
+
are EXCLUDED from a namespaced query. This prevents a silent
|
|
286
|
+
cross-namespace leak where one project reads another project's
|
|
287
|
+
unstamped memory. Set True only when migrating a single-project
|
|
288
|
+
store whose entries predate namespace stamping and you have
|
|
289
|
+
verified every entry belongs to the active namespace.
|
|
282
290
|
"""
|
|
283
291
|
self.storage = storage
|
|
284
292
|
self.embedding_engine = embedding_engine
|
|
285
293
|
self.vector_indices = vector_indices or {}
|
|
286
294
|
self.base_path = Path(base_path)
|
|
287
295
|
self._namespace = namespace
|
|
296
|
+
self._include_unstamped_legacy = include_unstamped_legacy
|
|
288
297
|
# Track when indices were last built to detect staleness (BUG-MEM-002).
|
|
289
298
|
# When consolidation modifies patterns, indices become stale and should
|
|
290
299
|
# be rebuilt before the next similarity search.
|
|
@@ -318,25 +327,45 @@ class MemoryRetrieval:
|
|
|
318
327
|
Behavior:
|
|
319
328
|
- If self._namespace is None, accept all (backward compat for unscoped retrieval).
|
|
320
329
|
- If result has "_namespace" matching, accept.
|
|
321
|
-
- If result lacks "_namespace" (legacy entry written before stamping)
|
|
322
|
-
|
|
323
|
-
|
|
330
|
+
- If result lacks "_namespace" (legacy entry written before stamping):
|
|
331
|
+
treat it conservatively. An unstamped entry has no provable origin,
|
|
332
|
+
so under a namespaced query it could belong to ANY namespace and
|
|
333
|
+
including it is a silent cross-namespace leak (one project reading
|
|
334
|
+
another's memory). By default (self._include_unstamped_legacy is
|
|
335
|
+
False) such entries are EXCLUDED, with a rate-limited warning telling
|
|
336
|
+
operators to re-save the entry to add a stamp. Operators who have
|
|
337
|
+
verified a single-project store predates stamping can opt back in via
|
|
338
|
+
include_unstamped_legacy=True.
|
|
339
|
+
- Otherwise (stamp present but does not match), reject.
|
|
324
340
|
"""
|
|
325
341
|
if self._namespace is None:
|
|
326
342
|
return True
|
|
327
343
|
result_ns = result.get("_namespace")
|
|
328
344
|
if result_ns is None:
|
|
329
|
-
# Legacy entry without namespace stamp
|
|
330
|
-
#
|
|
345
|
+
# Legacy entry without namespace stamp. No provable origin -> do not
|
|
346
|
+
# silently leak it across namespaces. Exclude by default; warn so
|
|
347
|
+
# operators can re-save to add a stamp (rate-limited to avoid spam).
|
|
331
348
|
if MemoryRetrieval._legacy_warned_count < MemoryRetrieval._LEGACY_WARN_LIMIT:
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
349
|
+
if self._include_unstamped_legacy:
|
|
350
|
+
logger.warning(
|
|
351
|
+
"Memory entry id=%s lacks '_namespace' stamp (legacy "
|
|
352
|
+
"entry). Including under namespace=%s because "
|
|
353
|
+
"include_unstamped_legacy is set. Re-save this entry to "
|
|
354
|
+
"stamp it and remove the opt-in.",
|
|
355
|
+
result.get("id", "<unknown>"),
|
|
356
|
+
self._namespace,
|
|
357
|
+
)
|
|
358
|
+
else:
|
|
359
|
+
logger.warning(
|
|
360
|
+
"Memory entry id=%s lacks '_namespace' stamp (legacy "
|
|
361
|
+
"entry). Excluding from namespace=%s query to prevent a "
|
|
362
|
+
"cross-namespace leak. Re-save this entry to stamp it, "
|
|
363
|
+
"or pass include_unstamped_legacy=True to opt in.",
|
|
364
|
+
result.get("id", "<unknown>"),
|
|
365
|
+
self._namespace,
|
|
366
|
+
)
|
|
338
367
|
MemoryRetrieval._legacy_warned_count += 1
|
|
339
|
-
return
|
|
368
|
+
return self._include_unstamped_legacy
|
|
340
369
|
return result_ns == self._namespace
|
|
341
370
|
|
|
342
371
|
def with_namespace(self, namespace: str) -> "MemoryRetrieval":
|
|
@@ -361,6 +390,7 @@ class MemoryRetrieval:
|
|
|
361
390
|
vector_indices=self.vector_indices,
|
|
362
391
|
base_path=str(self.base_path),
|
|
363
392
|
namespace=namespace,
|
|
393
|
+
include_unstamped_legacy=self._include_unstamped_legacy,
|
|
364
394
|
)
|
|
365
395
|
|
|
366
396
|
# -------------------------------------------------------------------------
|
package/memory/storage.py
CHANGED
|
@@ -33,6 +33,29 @@ except ImportError:
|
|
|
33
33
|
# Default namespace constant
|
|
34
34
|
DEFAULT_NAMESPACE = "default"
|
|
35
35
|
|
|
36
|
+
# Allowed namespace characters. A namespace becomes a single path segment under
|
|
37
|
+
# the memory root, so it must not contain separators, traversal, or whitespace.
|
|
38
|
+
_NAMESPACE_RE = re.compile(r'^[a-zA-Z0-9_-]+$')
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _validate_namespace_charset(namespace: str) -> None:
|
|
42
|
+
"""Reject a namespace whose characters could escape its directory.
|
|
43
|
+
|
|
44
|
+
Charset-only check, shared by ``__init__`` and ``with_namespace`` so the two
|
|
45
|
+
validation sites cannot drift. Callers are responsible for deciding whether a
|
|
46
|
+
None/empty namespace is acceptable (it is in ``__init__`` for backward
|
|
47
|
+
compat; it is rejected in ``with_namespace``); this only runs once a concrete
|
|
48
|
+
non-default namespace string is present.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
ValueError: If the namespace contains anything outside [A-Za-z0-9_-].
|
|
52
|
+
"""
|
|
53
|
+
if not _NAMESPACE_RE.match(namespace):
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f"Invalid namespace '{namespace}': "
|
|
56
|
+
"only alphanumeric characters, hyphens, and underscores are allowed"
|
|
57
|
+
)
|
|
58
|
+
|
|
36
59
|
|
|
37
60
|
class MemoryStorage:
|
|
38
61
|
"""
|
|
@@ -73,14 +96,11 @@ class MemoryStorage:
|
|
|
73
96
|
self._root_path = Path(effective_base)
|
|
74
97
|
self._namespace = namespace
|
|
75
98
|
|
|
76
|
-
# Validate namespace to prevent path traversal
|
|
99
|
+
# Validate namespace to prevent path traversal. None/empty is accepted
|
|
100
|
+
# here for backward compat (it selects the default, un-namespaced root);
|
|
101
|
+
# only a concrete non-default namespace is charset-checked.
|
|
77
102
|
if namespace and namespace != DEFAULT_NAMESPACE:
|
|
78
|
-
|
|
79
|
-
if not re.match(r'^[a-zA-Z0-9_-]+$', namespace):
|
|
80
|
-
raise ValueError(
|
|
81
|
-
f"Invalid namespace '{namespace}': "
|
|
82
|
-
"only alphanumeric characters, hyphens, and underscores are allowed"
|
|
83
|
-
)
|
|
103
|
+
_validate_namespace_charset(namespace)
|
|
84
104
|
|
|
85
105
|
# Calculate effective base path (with namespace if specified)
|
|
86
106
|
if namespace and namespace != DEFAULT_NAMESPACE:
|
|
@@ -119,15 +139,21 @@ class MemoryStorage:
|
|
|
119
139
|
New MemoryStorage instance for the specified namespace
|
|
120
140
|
|
|
121
141
|
Raises:
|
|
122
|
-
ValueError: If namespace
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
142
|
+
ValueError: If namespace is empty/None/non-string, or contains
|
|
143
|
+
characters outside [A-Za-z0-9_-] (path-traversal defense).
|
|
144
|
+
"""
|
|
145
|
+
# with_namespace is an explicit "switch to this namespace" call, so an
|
|
146
|
+
# empty, None, whitespace-only, or non-string namespace is meaningless
|
|
147
|
+
# and must be rejected rather than silently resolving to the default
|
|
148
|
+
# root (which is what __init__ would do with a falsy namespace). Reject,
|
|
149
|
+
# do not normalize: normalization would mask a caller bug.
|
|
150
|
+
if not isinstance(namespace, str) or not namespace.strip():
|
|
151
|
+
raise ValueError(
|
|
152
|
+
f"Invalid namespace {namespace!r}: "
|
|
153
|
+
"must be a non-empty string"
|
|
154
|
+
)
|
|
155
|
+
if namespace != DEFAULT_NAMESPACE:
|
|
156
|
+
_validate_namespace_charset(namespace)
|
|
131
157
|
return MemoryStorage(
|
|
132
158
|
base_path=str(self._root_path),
|
|
133
159
|
namespace=namespace,
|
|
@@ -857,6 +883,75 @@ class MemoryStorage:
|
|
|
857
883
|
|
|
858
884
|
return True
|
|
859
885
|
|
|
886
|
+
def update_pattern_with_merge(self, pattern_id: str, merge_fn) -> bool:
|
|
887
|
+
"""Atomically merge into an existing pattern under a single lock.
|
|
888
|
+
|
|
889
|
+
Closes the consolidation lost-update (BUG-MEM C1): the previous flow read
|
|
890
|
+
the pattern (load_pattern) and wrote the merged result (update_pattern) in
|
|
891
|
+
SEPARATE lock acquisitions, so a concurrent increment_pattern_usage() bump
|
|
892
|
+
landing between the read and the write was lost. Here the read of the
|
|
893
|
+
current on-disk record, the caller's merge, and the write all happen
|
|
894
|
+
inside ONE exclusive _file_lock on patterns.json -- the same path
|
|
895
|
+
increment_pattern_usage() and update_pattern() lock -- so they mutually
|
|
896
|
+
exclude and no bump is clobbered.
|
|
897
|
+
|
|
898
|
+
Args:
|
|
899
|
+
pattern_id: Id of the existing pattern to merge into.
|
|
900
|
+
merge_fn: Callable taking the current on-disk pattern dict and
|
|
901
|
+
returning the merged record (dict, or any object exposing
|
|
902
|
+
to_dict()/__dict__). It must preserve the id. The dict it
|
|
903
|
+
receives is a fresh read performed under the lock.
|
|
904
|
+
|
|
905
|
+
Returns:
|
|
906
|
+
True if the pattern was found and the merged record written, False if
|
|
907
|
+
the pattern id was not present (caller should fall back to a create).
|
|
908
|
+
"""
|
|
909
|
+
if not pattern_id:
|
|
910
|
+
return False
|
|
911
|
+
|
|
912
|
+
patterns_path = self.base_path / "semantic" / "patterns.json"
|
|
913
|
+
|
|
914
|
+
with self._file_lock(patterns_path, exclusive=True):
|
|
915
|
+
if not patterns_path.exists():
|
|
916
|
+
return False
|
|
917
|
+
|
|
918
|
+
with open(patterns_path, "r", encoding="utf-8") as f:
|
|
919
|
+
try:
|
|
920
|
+
patterns_file = json.load(f)
|
|
921
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
922
|
+
return False
|
|
923
|
+
|
|
924
|
+
patterns = patterns_file.get("patterns", [])
|
|
925
|
+
target_idx = None
|
|
926
|
+
current = None
|
|
927
|
+
for i, p in enumerate(patterns):
|
|
928
|
+
if isinstance(p, dict) and p.get("id") == pattern_id:
|
|
929
|
+
target_idx = i
|
|
930
|
+
current = p
|
|
931
|
+
break
|
|
932
|
+
|
|
933
|
+
if target_idx is None:
|
|
934
|
+
return False
|
|
935
|
+
|
|
936
|
+
# Caller merges against the fresh, lock-protected current record.
|
|
937
|
+
merged = merge_fn(current)
|
|
938
|
+
if hasattr(merged, "to_dict"):
|
|
939
|
+
merged_data = merged.to_dict()
|
|
940
|
+
elif hasattr(merged, "__dict__"):
|
|
941
|
+
merged_data = merged.__dict__.copy()
|
|
942
|
+
else:
|
|
943
|
+
merged_data = dict(merged)
|
|
944
|
+
|
|
945
|
+
# Never let a merge orphan the record by changing its id.
|
|
946
|
+
merged_data["id"] = pattern_id
|
|
947
|
+
merged_data["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
948
|
+
patterns_file["patterns"][target_idx] = merged_data
|
|
949
|
+
patterns_file["last_updated"] = datetime.now(timezone.utc).isoformat()
|
|
950
|
+
|
|
951
|
+
self._atomic_write(patterns_path, patterns_file)
|
|
952
|
+
|
|
953
|
+
return True
|
|
954
|
+
|
|
860
955
|
# -------------------------------------------------------------------------
|
|
861
956
|
# Skill Storage
|
|
862
957
|
# -------------------------------------------------------------------------
|
|
@@ -1259,9 +1354,20 @@ class MemoryStorage:
|
|
|
1259
1354
|
base = min(1.0, base + 0.05 * min(len(errors), 3))
|
|
1260
1355
|
|
|
1261
1356
|
# Access frequency boost (diminishing returns).
|
|
1262
|
-
# `or 0` guards
|
|
1263
|
-
#
|
|
1357
|
+
# `or 0` guards an explicit null access_count; the isinstance/`< 0`
|
|
1358
|
+
# clamp additionally guards a non-numeric (e.g. a stored "5") or negative
|
|
1359
|
+
# value (corrupt or hand-edited record) reaching the `> 0` comparison and
|
|
1360
|
+
# log1p() below. A bare string raises TypeError on `"5" > 0`, and a
|
|
1361
|
+
# negative <= -1 raises a math domain error in log1p; bool is excluded so
|
|
1362
|
+
# a stray True is not treated as a count of 1. All coerce to 0 (no boost)
|
|
1363
|
+
# so importance scoring never crashes the scan.
|
|
1264
1364
|
access_count = memory.get("access_count") or 0
|
|
1365
|
+
if (
|
|
1366
|
+
not isinstance(access_count, (int, float))
|
|
1367
|
+
or isinstance(access_count, bool)
|
|
1368
|
+
or access_count < 0
|
|
1369
|
+
):
|
|
1370
|
+
access_count = 0
|
|
1265
1371
|
if access_count > 0:
|
|
1266
1372
|
# Log scale boost, caps at about 0.15 for 100+ accesses
|
|
1267
1373
|
access_boost = 0.05 * math.log1p(access_count)
|
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.97.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.97.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",
|