loki-mode 7.64.0 → 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/dashboard/__init__.py +1 -1
- package/docs/INSTALLATION.md +2 -2
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- 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/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: loki-mode
|
|
|
3
3
|
description: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 8 quality gates, completion council, verified-completion evidence gate). Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Loki Mode v7.
|
|
6
|
+
# Loki Mode v7.65.0
|
|
7
7
|
|
|
8
8
|
**You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
|
|
9
9
|
|
|
@@ -406,4 +406,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
|
|
|
406
406
|
|
|
407
407
|
---
|
|
408
408
|
|
|
409
|
-
**v7.
|
|
409
|
+
**v7.65.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
7.
|
|
1
|
+
7.65.0
|
package/dashboard/__init__.py
CHANGED
package/docs/INSTALLATION.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The flagship product of [Autonomi](https://www.autonomi.dev/). Loki Mode is a spec-driven autonomous builder with a built-in trust layer that takes any spec to a deployed product and verifies completion with evidence (quality gates plus a completion council), not just a "done" claim. Complete installation instructions for all platforms and use cases.
|
|
4
4
|
|
|
5
|
-
**Version:** v7.
|
|
5
|
+
**Version:** v7.65.0
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -395,7 +395,7 @@ provider works inside the container. Provide auth with your Anthropic API key:
|
|
|
395
395
|
# Run Loki Mode in Docker (Claude provider, API-key auth)
|
|
396
396
|
docker run --rm -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
|
|
397
397
|
-v $(pwd):/workspace -w /workspace \
|
|
398
|
-
asklokesh/loki-mode:7.
|
|
398
|
+
asklokesh/loki-mode:7.65.0 start ./my-spec.md
|
|
399
399
|
```
|
|
400
400
|
|
|
401
401
|
##### docker compose + .env (no host install)
|
package/loki-ts/dist/loki.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
var r6=Object.defineProperty;var t6=($)=>$;function i6($,Q){this[$]=t6.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)r6($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:i6.bind(Q,Z)})};var P=($,Q)=>()=>($&&(Q=$($=0)),Q);var q$=import.meta.require;var D1={};b(D1,{lokiDir:()=>j,homeLokiDir:()=>a$,findRepoRootForVersion:()=>n$,REPO_ROOT:()=>g});import{resolve as a,dirname as o$}from"path";import{fileURLToPath as e6}from"url";import{existsSync as j$}from"fs";import{homedir as $Q}from"os";function QQ(){let $=S1;for(let Q=0;Q<6;Q++){if(j$(a($,"VERSION"))&&j$(a($,"autonomy/run.sh")))return $;let Z=o$($);if(Z===$)break;$=Z}return a(S1,"..","..","..")}function n$($){let Q=$;for(let Z=0;Z<6;Z++){if(j$(a(Q,"VERSION"))&&j$(a(Q,"autonomy/run.sh")))return Q;let z=o$(Q);if(z===Q)break;Q=z}return a($,"..","..","..")}function j(){return process.env.LOKI_DIR??a(process.cwd(),".loki")}function a$(){return a($Q(),".loki")}var S1,g;var C=P(()=>{S1=o$(e6(import.meta.url));g=QQ()});import{readFileSync as ZQ}from"fs";import{resolve as zQ,dirname as XQ}from"path";import{fileURLToPath as KQ}from"url";function F$(){if(Q$!==null)return Q$;let $="7.
|
|
2
|
+
var r6=Object.defineProperty;var t6=($)=>$;function i6($,Q){this[$]=t6.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)r6($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:i6.bind(Q,Z)})};var P=($,Q)=>()=>($&&(Q=$($=0)),Q);var q$=import.meta.require;var D1={};b(D1,{lokiDir:()=>j,homeLokiDir:()=>a$,findRepoRootForVersion:()=>n$,REPO_ROOT:()=>g});import{resolve as a,dirname as o$}from"path";import{fileURLToPath as e6}from"url";import{existsSync as j$}from"fs";import{homedir as $Q}from"os";function QQ(){let $=S1;for(let Q=0;Q<6;Q++){if(j$(a($,"VERSION"))&&j$(a($,"autonomy/run.sh")))return $;let Z=o$($);if(Z===$)break;$=Z}return a(S1,"..","..","..")}function n$($){let Q=$;for(let Z=0;Z<6;Z++){if(j$(a(Q,"VERSION"))&&j$(a(Q,"autonomy/run.sh")))return Q;let z=o$(Q);if(z===Q)break;Q=z}return a($,"..","..","..")}function j(){return process.env.LOKI_DIR??a(process.cwd(),".loki")}function a$(){return a($Q(),".loki")}var S1,g;var C=P(()=>{S1=o$(e6(import.meta.url));g=QQ()});import{readFileSync as ZQ}from"fs";import{resolve as zQ,dirname as XQ}from"path";import{fileURLToPath as KQ}from"url";function F$(){if(Q$!==null)return Q$;let $="7.65.0";if(typeof $==="string"&&$.length>0)return Q$=$,Q$;try{let Q=XQ(KQ(import.meta.url)),Z=n$(Q);Q$=ZQ(zQ(Z,"VERSION"),"utf-8").trim()}catch{Q$="unknown"}return Q$}var Q$=null;var s$=P(()=>{C()});var b1={};b(b1,{runOrThrow:()=>qQ,run:()=>k,commandVersion:()=>JQ,commandExists:()=>f,ShellError:()=>r$});async function k($,Q={}){let Z=Bun.spawn({cmd:[...$],stdout:"pipe",stderr:"pipe",env:Q.env?{...process.env,...Q.env}:process.env,cwd:Q.cwd}),z,X;if(Q.timeoutMs&&Q.timeoutMs>0)z=setTimeout(()=>{try{Z.kill("SIGTERM")}catch{}X=setTimeout(()=>{try{Z.kill("SIGKILL")}catch{}},2000)},Q.timeoutMs);try{let[q,K,W]=await Promise.all([new Response(Z.stdout).text(),new Response(Z.stderr).text(),Z.exited]);return{stdout:q,stderr:K,exitCode:W}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function qQ($,Q={}){let Z=await k($,Q);if(Z.exitCode!==0)throw new r$(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=VQ($),Z=await k(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function VQ($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function JQ($,Q="--version"){if(!await f($))return null;let z=await k([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var r$;var d=P(()=>{r$=class r$ extends Error{message;exitCode;stdout;stderr;constructor($,Q,Z,z){super($);this.message=$;this.exitCode=Q;this.stdout=Z;this.stderr=z;this.name="ShellError"}}});function s($){return WQ?"":$}var WQ,T,S,_,wZ,I,R,h,V;var c=P(()=>{WQ=(process.env.NO_COLOR??"").length>0;T=s("\x1B[0;31m"),S=s("\x1B[0;32m"),_=s("\x1B[1;33m"),wZ=s("\x1B[0;34m"),I=s("\x1B[0;36m"),R=s("\x1B[1m"),h=s("\x1B[2m"),V=s("\x1B[0m")});import{existsSync as wQ}from"fs";async function Z$(){if(Y$!==void 0)return Y$;let $="/opt/homebrew/bin/python3.12";if(wQ($))return Y$=$,$;let Q=await f("python3.12");if(Q)return Y$=Q,Q;let Z=await f("python3");return Y$=Z,Z}async function z$($,Q={}){let Z=await Z$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return k([Z,"-c",$],Q)}var Y$;var V$=P(()=>{d()});var e1={};b(e1,{runStatus:()=>uQ});import{existsSync as y,readFileSync as W$,readdirSync as d1,statSync as o1}from"fs";import{resolve as D,basename as DQ}from"path";import{homedir as CQ}from"os";function n1($){let Q=Math.trunc($);if(Q>=1e6)return`${(Math.trunc(Q/1e6*10)/10).toFixed(1)}M`;if(Q>=1000)return`${(Math.trunc(Q/1000*10)/10).toFixed(1)}K`;return String(Q)}function a1($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*R$/Q);if(X>R$)X=R$;let q=R$-X,K=S;if(z>=80)K=T;else if(z>=50)K=_;let W="=".repeat(Math.max(0,X))+" ".repeat(Math.max(0,q)),J=n1($),U=n1(Q);return` ${R}${Z}${V} ${K}[${W}]${V} ${z}% (${J} / ${U})`}async function hQ(){if(await f("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${V}
|
|
3
3
|
`),process.stdout.write(`Install with:
|
|
4
4
|
`),process.stdout.write(` brew install jq (macOS)
|
|
5
5
|
`),process.stdout.write(` apt install jq (Debian/Ubuntu)
|
|
@@ -791,4 +791,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
|
|
|
791
791
|
`),2}default:return process.stderr.write(`Unknown command: ${Q}
|
|
792
792
|
`),process.stderr.write(s6),2}}l1();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var KZ=await XZ(Bun.argv.slice(2));process.exit(KZ);
|
|
793
793
|
|
|
794
|
-
//# debugId=
|
|
794
|
+
//# debugId=1BFA28C250C77A2E64756E2164756E21
|
package/mcp/__init__.py
CHANGED
package/memory/consolidation.py
CHANGED
|
@@ -240,10 +240,22 @@ class ConsolidationPipeline:
|
|
|
240
240
|
self.storage.save_pattern(new_pattern)
|
|
241
241
|
new_patterns.append(new_pattern)
|
|
242
242
|
all_patterns.append(new_pattern)
|
|
243
|
+
# Add to existing_patterns so a later cluster pattern in
|
|
244
|
+
# this same run is deduped against it (mirrors the
|
|
245
|
+
# anti-pattern step below). Without this, two clusters
|
|
246
|
+
# producing >=0.8-similar patterns would both take the
|
|
247
|
+
# create branch, yielding near-duplicate patterns.
|
|
248
|
+
existing_patterns.append(new_pattern)
|
|
243
249
|
result.patterns_created += 1
|
|
244
250
|
|
|
245
251
|
# 6. Extract anti-patterns from failures
|
|
246
252
|
anti_patterns = self.extract_anti_patterns(failed_episodes)
|
|
253
|
+
# Track only anti-patterns that were persisted under their OWN id (the
|
|
254
|
+
# save_pattern branch). Merged anti-patterns are persisted under the
|
|
255
|
+
# existing pattern's id via update_pattern(merged_pattern); their own
|
|
256
|
+
# fresh uuid was never saved, so linking against it later would update
|
|
257
|
+
# a non-existent record (update_pattern -> False) and drop the links.
|
|
258
|
+
saved_anti_patterns = []
|
|
247
259
|
for anti_pattern in anti_patterns:
|
|
248
260
|
# Check if similar anti-pattern already exists
|
|
249
261
|
merged = False
|
|
@@ -264,18 +276,25 @@ class ConsolidationPipeline:
|
|
|
264
276
|
if not merged:
|
|
265
277
|
self.storage.save_pattern(anti_pattern)
|
|
266
278
|
all_patterns.append(anti_pattern)
|
|
279
|
+
saved_anti_patterns.append(anti_pattern)
|
|
267
280
|
# Add to existing_patterns so subsequent anti-patterns in this
|
|
268
281
|
# run are checked against it, preventing current-run duplicates.
|
|
269
282
|
existing_patterns.append(anti_pattern)
|
|
270
283
|
result.anti_patterns_created += 1
|
|
271
284
|
|
|
272
285
|
# 7. Create Zettelkasten links
|
|
273
|
-
|
|
286
|
+
# Only link patterns that were persisted under their own id this run
|
|
287
|
+
# (new_patterns from step 5 + saved_anti_patterns from step 6). Merged
|
|
288
|
+
# patterns already live under an existing id and were updated in place.
|
|
289
|
+
for pattern in new_patterns + saved_anti_patterns:
|
|
274
290
|
links = self.create_zettelkasten_links(pattern, all_patterns)
|
|
275
291
|
if links:
|
|
276
292
|
pattern.links.extend(links)
|
|
277
|
-
|
|
278
|
-
|
|
293
|
+
# Only count links that actually persisted. update_pattern()
|
|
294
|
+
# returns False when the target id is not on disk; counting
|
|
295
|
+
# unconditionally would inflate links_created.
|
|
296
|
+
if self.storage.update_pattern(pattern):
|
|
297
|
+
result.links_created += len(links)
|
|
279
298
|
|
|
280
299
|
# Flag vector indices as stale when patterns changed (BUG-MEM-007).
|
|
281
300
|
# Callers should rebuild vector indices when this flag is True to
|
package/memory/engine.py
CHANGED
|
@@ -332,60 +332,75 @@ class MemoryEngine:
|
|
|
332
332
|
real topics immediately after a session ends.
|
|
333
333
|
"""
|
|
334
334
|
try:
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
"
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
"
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
335
|
+
# H4 lost-update fix (wave-6): hold ONE exclusive lock spanning the
|
|
336
|
+
# full read-modify-write of index.json. _file_lock is reentrant per
|
|
337
|
+
# thread (storage._held_locks is threading.local) and cross-process
|
|
338
|
+
# safe (fcntl.flock), so the inner read_json/write_json calls -- which
|
|
339
|
+
# re-enter _file_lock on the SAME resolved path -- are no-ops and do
|
|
340
|
+
# not deadlock. The lock target is derived from storage._resolve_path
|
|
341
|
+
# so its string key is byte-identical to the one read_json/write_json
|
|
342
|
+
# compute internally (mismatched keys would self-deadlock).
|
|
343
|
+
index_lock = Path(self.storage._resolve_path("index.json"))
|
|
344
|
+
with self.storage._file_lock(index_lock, exclusive=True):
|
|
345
|
+
index = self.storage.read_json("index.json") or {
|
|
346
|
+
"version": "1.1.0",
|
|
347
|
+
"topics": [],
|
|
348
|
+
"total_memories": 0,
|
|
349
|
+
}
|
|
350
|
+
context = episode.get("context", {}) if isinstance(episode.get("context"), dict) else {}
|
|
351
|
+
phase = (context.get("phase") or episode.get("phase") or "general").lower()
|
|
352
|
+
goal = (context.get("goal") or episode.get("goal") or "")[:200]
|
|
353
|
+
# Topic id = phase. Multiple episodes in the same phase share a topic.
|
|
354
|
+
topic_id = phase or "general"
|
|
355
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
356
|
+
episode_id = episode.get("id")
|
|
357
|
+
cost = float(episode.get("cost_usd", 0) or 0)
|
|
358
|
+
tokens = int(episode.get("tokens_used", 0) or 0)
|
|
359
|
+
files = list(episode.get("files_modified", []) or [])
|
|
360
|
+
|
|
361
|
+
found = None
|
|
362
|
+
for topic in index.get("topics", []):
|
|
363
|
+
if topic.get("id") == topic_id:
|
|
364
|
+
found = topic
|
|
365
|
+
break
|
|
366
|
+
if found is None:
|
|
367
|
+
index.setdefault("topics", []).append({
|
|
368
|
+
"id": topic_id,
|
|
369
|
+
"summary": goal or f"Activity in phase {topic_id}",
|
|
370
|
+
"episode_ids": [episode_id] if episode_id else [],
|
|
371
|
+
"episode_count": 1,
|
|
372
|
+
"total_cost_usd": cost,
|
|
373
|
+
"total_tokens": tokens,
|
|
374
|
+
"files_touched": files[:20],
|
|
375
|
+
"first_seen": now,
|
|
376
|
+
"last_accessed": now,
|
|
377
|
+
"relevance_score": 0.5,
|
|
378
|
+
})
|
|
379
|
+
index["total_memories"] = index.get("total_memories", 0) + 1
|
|
380
|
+
else:
|
|
381
|
+
# Only count a given episode once. On resume/checkpoint the same
|
|
382
|
+
# trace id can be re-saved; without this guard episode_count,
|
|
383
|
+
# total_cost_usd, and total_tokens would inflate on every re-save
|
|
384
|
+
# even though episode_ids is already de-duplicated.
|
|
385
|
+
if episode_id and episode_id not in found.get("episode_ids", []):
|
|
386
|
+
found.setdefault("episode_ids", []).append(episode_id)
|
|
387
|
+
found["episode_count"] = found.get("episode_count", 0) + 1
|
|
388
|
+
found["total_cost_usd"] = float(found.get("total_cost_usd", 0) or 0) + cost
|
|
389
|
+
found["total_tokens"] = int(found.get("total_tokens", 0) or 0) + tokens
|
|
390
|
+
merged = set(found.get("files_touched", []) or []) | set(files[:20])
|
|
391
|
+
found["files_touched"] = sorted(merged)[:50]
|
|
392
|
+
found["last_accessed"] = now
|
|
393
|
+
|
|
394
|
+
index["last_updated"] = now
|
|
395
|
+
self.storage.write_json("index.json", index)
|
|
386
396
|
except Exception: # noqa: BLE001
|
|
387
|
-
# Never let index update break episode storage
|
|
388
|
-
|
|
397
|
+
# Never let index update break episode storage, but make the
|
|
398
|
+
# failure observable instead of swallowing it silently (L2).
|
|
399
|
+
logger.warning(
|
|
400
|
+
"Failed to update index.json with episode %s",
|
|
401
|
+
episode.get("id"),
|
|
402
|
+
exc_info=True,
|
|
403
|
+
)
|
|
389
404
|
|
|
390
405
|
def get_episode(self, episode_id: str) -> Optional[EpisodeTrace]:
|
|
391
406
|
"""
|
|
@@ -522,8 +537,13 @@ class MemoryEngine:
|
|
|
522
537
|
for pattern in patterns_data.get("patterns", []):
|
|
523
538
|
if not isinstance(pattern, dict):
|
|
524
539
|
continue
|
|
525
|
-
# Filter by confidence
|
|
526
|
-
|
|
540
|
+
# Filter by confidence. Guard against an explicit null confidence
|
|
541
|
+
# (corrupt/hand-edited record): None < float raises TypeError in
|
|
542
|
+
# Python 3, so treat a null as 0 (filtered out unless threshold 0).
|
|
543
|
+
pattern_confidence = pattern.get("confidence")
|
|
544
|
+
if pattern_confidence is None:
|
|
545
|
+
pattern_confidence = 0
|
|
546
|
+
if pattern_confidence < min_confidence:
|
|
527
547
|
continue
|
|
528
548
|
|
|
529
549
|
# Filter by category if specified
|
|
@@ -550,8 +570,10 @@ class MemoryEngine:
|
|
|
550
570
|
if pattern_data is None:
|
|
551
571
|
return
|
|
552
572
|
|
|
553
|
-
# Update fields
|
|
554
|
-
|
|
573
|
+
# Update fields. `or 0` guards against an explicit null usage_count
|
|
574
|
+
# (corrupt/hand-edited record) crashing the increment with a TypeError;
|
|
575
|
+
# a null and 0 are equivalent here so `or` is safe.
|
|
576
|
+
pattern_data["usage_count"] = (pattern_data.get("usage_count") or 0) + 1
|
|
555
577
|
pattern_data["last_used"] = datetime.now(timezone.utc).isoformat()
|
|
556
578
|
|
|
557
579
|
# Write back via save_pattern which holds an exclusive lock during
|
|
@@ -577,9 +599,24 @@ class MemoryEngine:
|
|
|
577
599
|
skill_id = skill_dict.get("id", f"skill-{self._generate_id()}")
|
|
578
600
|
skill_dict["id"] = skill_id
|
|
579
601
|
|
|
580
|
-
# Generate filename from skill name or ID
|
|
581
|
-
|
|
582
|
-
|
|
602
|
+
# Generate filename from skill name or ID.
|
|
603
|
+
# H3 path-traversal fix (wave-6): the previous filename derivation only
|
|
604
|
+
# replaced spaces and underscores, so a skill name like
|
|
605
|
+
# "../../../tmp/pwned" kept its "/" and ".." and escaped the memory root
|
|
606
|
+
# via the raw open(skill_path, "w") below (which bypasses _resolve_path).
|
|
607
|
+
# Sanitize to safe chars only, matching storage.save_skill's house style,
|
|
608
|
+
# and fall back to the skill id when sanitization collapses to empty.
|
|
609
|
+
skill_name = skill_dict.get("name") or skill_id
|
|
610
|
+
normalized = skill_name.lower().replace(" ", "-").replace("_", "-")
|
|
611
|
+
filename = "".join(
|
|
612
|
+
c if (c.isalnum() or c == "-") else "-"
|
|
613
|
+
for c in normalized
|
|
614
|
+
).strip("-")
|
|
615
|
+
if not filename:
|
|
616
|
+
filename = "".join(
|
|
617
|
+
c if (c.isalnum() or c == "-") else "-"
|
|
618
|
+
for c in skill_id.lower()
|
|
619
|
+
).strip("-") or "skill"
|
|
583
620
|
|
|
584
621
|
# Store as markdown
|
|
585
622
|
content = self._skill_to_markdown(skill_dict)
|
|
@@ -899,57 +936,65 @@ class MemoryEngine:
|
|
|
899
936
|
context = episode.get("context", {})
|
|
900
937
|
action_entry = {
|
|
901
938
|
"timestamp": episode.get("timestamp", datetime.now(timezone.utc).isoformat()),
|
|
902
|
-
"action": context.get("goal"
|
|
939
|
+
"action": (context.get("goal") or "Task completed")[:100],
|
|
903
940
|
"outcome": episode.get("outcome", "unknown"),
|
|
904
|
-
"topic_id": context.get("phase"
|
|
941
|
+
"topic_id": context.get("phase") or "general",
|
|
905
942
|
}
|
|
906
943
|
|
|
907
944
|
self.storage.update_timeline(action_entry)
|
|
908
945
|
|
|
909
946
|
def _update_index_with_pattern(self, pattern: Dict[str, Any]) -> None:
|
|
910
947
|
"""Update index with pattern topic."""
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
948
|
+
# H4 lost-update fix (wave-6): hold ONE exclusive lock spanning the full
|
|
949
|
+
# read-modify-write of index.json so concurrent store_pattern (and
|
|
950
|
+
# store_episode) calls cannot clobber each other. See the matching note
|
|
951
|
+
# in _update_index_with_episode for why the lock target is derived from
|
|
952
|
+
# storage._resolve_path and why the inner read_json/write_json calls do
|
|
953
|
+
# not deadlock (reentrant per-thread, cross-process safe via flock).
|
|
954
|
+
index_lock = Path(self.storage._resolve_path("index.json"))
|
|
955
|
+
with self.storage._file_lock(index_lock, exclusive=True):
|
|
956
|
+
index = self.storage.read_json("index.json") or {
|
|
957
|
+
"version": "1.0",
|
|
958
|
+
"topics": [],
|
|
959
|
+
"total_memories": 0,
|
|
960
|
+
"total_tokens_available": 0,
|
|
961
|
+
}
|
|
917
962
|
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
963
|
+
category = pattern.get("category", "general")
|
|
964
|
+
|
|
965
|
+
# An index.json that is valid JSON but missing the "topics" key (e.g.
|
|
966
|
+
# written by an older/partial writer, or hand-edited) would crash here
|
|
967
|
+
# on index["topics"] because the `or {...}` default only fires when the
|
|
968
|
+
# whole file is falsy. setdefault matches the defensive pattern used in
|
|
969
|
+
# the sibling _update_index_with_episode.
|
|
970
|
+
topics = index.setdefault("topics", [])
|
|
971
|
+
|
|
972
|
+
# Find or create topic
|
|
973
|
+
topic_found = False
|
|
974
|
+
for topic in topics:
|
|
975
|
+
if topic.get("id") == category:
|
|
976
|
+
topic["last_accessed"] = datetime.now(timezone.utc).isoformat()
|
|
977
|
+
topic["relevance_score"] = max(
|
|
978
|
+
topic.get("relevance_score", 0.5),
|
|
979
|
+
pattern.get("confidence", 0.5),
|
|
980
|
+
)
|
|
981
|
+
topic_found = True
|
|
982
|
+
break
|
|
938
983
|
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
984
|
+
if not topic_found:
|
|
985
|
+
topics.append({
|
|
986
|
+
"id": category,
|
|
987
|
+
"summary": f"Patterns for {category}",
|
|
988
|
+
"relevance_score": pattern.get("confidence", 0.5),
|
|
989
|
+
"last_accessed": datetime.now(timezone.utc).isoformat(),
|
|
990
|
+
"token_count": len(json.dumps(pattern)) // 4,
|
|
991
|
+
})
|
|
947
992
|
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
993
|
+
index["last_updated"] = datetime.now(timezone.utc).isoformat()
|
|
994
|
+
if not topic_found:
|
|
995
|
+
index["total_memories"] = index.get("total_memories", 0) + 1
|
|
951
996
|
|
|
952
|
-
|
|
997
|
+
self.storage.write_json("index.json", index)
|
|
953
998
|
|
|
954
999
|
def _search_episode(self, episode_id: str) -> Optional[EpisodeTrace]:
|
|
955
1000
|
"""Search for episode across all date directories."""
|
|
@@ -1190,9 +1235,13 @@ class MemoryEngine:
|
|
|
1190
1235
|
Detect task type from context.
|
|
1191
1236
|
Uses keyword matching based on goal, action, and phase.
|
|
1192
1237
|
"""
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1238
|
+
# M3 None-guard (wave-6): an explicit null value (e.g. {"goal": None})
|
|
1239
|
+
# makes context.get("goal", "") return None, so None.lower() crashed.
|
|
1240
|
+
# The retrieval.py copy was fixed in v7.61.0; this engine.py copy was
|
|
1241
|
+
# the missed sibling. Coalesce to "" before calling string methods.
|
|
1242
|
+
goal = (context.get("goal") or "").lower()
|
|
1243
|
+
action = (context.get("action_type") or "").lower()
|
|
1244
|
+
phase = (context.get("phase") or "").lower()
|
|
1196
1245
|
|
|
1197
1246
|
signals = {
|
|
1198
1247
|
"exploration": {
|
|
@@ -1277,7 +1326,8 @@ class MemoryEngine:
|
|
|
1277
1326
|
episodes = self.get_recent_episodes(limit=50)
|
|
1278
1327
|
for ep in episodes:
|
|
1279
1328
|
ep_dict = ep.to_dict() if hasattr(ep, "to_dict") else ep.__dict__.copy()
|
|
1280
|
-
|
|
1329
|
+
ep_context = ep_dict.get("context") or {}
|
|
1330
|
+
goal = (ep_context.get("goal") or "").lower()
|
|
1281
1331
|
score = sum(1 for kw in keywords if kw in goal)
|
|
1282
1332
|
if score > 0:
|
|
1283
1333
|
ep_dict["_score"] = score
|
|
@@ -1288,7 +1338,7 @@ class MemoryEngine:
|
|
|
1288
1338
|
patterns = self.find_patterns(min_confidence=0.3)
|
|
1289
1339
|
for pattern in patterns:
|
|
1290
1340
|
p_dict = pattern.to_dict() if hasattr(pattern, "to_dict") else pattern.__dict__.copy()
|
|
1291
|
-
pattern_text = p_dict.get("pattern"
|
|
1341
|
+
pattern_text = (p_dict.get("pattern") or "").lower()
|
|
1292
1342
|
score = sum(1 for kw in keywords if kw in pattern_text)
|
|
1293
1343
|
if score > 0:
|
|
1294
1344
|
p_dict["_score"] = score
|
|
@@ -1299,8 +1349,8 @@ class MemoryEngine:
|
|
|
1299
1349
|
skills = self.list_skills()
|
|
1300
1350
|
for skill in skills:
|
|
1301
1351
|
s_dict = skill.to_dict() if hasattr(skill, "to_dict") else skill.__dict__.copy()
|
|
1302
|
-
name = s_dict.get("name"
|
|
1303
|
-
desc = s_dict.get("description"
|
|
1352
|
+
name = (s_dict.get("name") or "").lower()
|
|
1353
|
+
desc = (s_dict.get("description") or "").lower()
|
|
1304
1354
|
score = sum(1 for kw in keywords if kw in name or kw in desc)
|
|
1305
1355
|
if score > 0:
|
|
1306
1356
|
s_dict["_score"] = score
|
package/memory/retrieval.py
CHANGED
|
@@ -940,8 +940,11 @@ class MemoryRetrieval:
|
|
|
940
940
|
Returns:
|
|
941
941
|
Weighted score incorporating importance
|
|
942
942
|
"""
|
|
943
|
-
source = result.get("_source"
|
|
944
|
-
|
|
943
|
+
source = result.get("_source") or ""
|
|
944
|
+
# _score is set internally so null is unlikely, but guard for
|
|
945
|
+
# uniformity since it feeds the arithmetic below.
|
|
946
|
+
base_score = result.get("_score")
|
|
947
|
+
base_score = 0.5 if base_score is None else base_score
|
|
945
948
|
|
|
946
949
|
# Map source to weight key
|
|
947
950
|
weight_key = source
|
|
@@ -950,11 +953,17 @@ class MemoryRetrieval:
|
|
|
950
953
|
|
|
951
954
|
weight = weights.get(weight_key, 0.0)
|
|
952
955
|
|
|
953
|
-
# Get importance score (default 0.5 if not set)
|
|
954
|
-
|
|
956
|
+
# Get importance score (default 0.5 if not set). Defensive: a
|
|
957
|
+
# corrupt/hand-edited record may carry importance=null, which would
|
|
958
|
+
# raise TypeError in the arithmetic below. Use the default only when
|
|
959
|
+
# missing/null; a legitimate 0.0 is preserved.
|
|
960
|
+
importance = result.get("importance")
|
|
961
|
+
importance = 0.5 if importance is None else importance
|
|
955
962
|
|
|
956
|
-
# Get confidence for semantic patterns
|
|
957
|
-
|
|
963
|
+
# Get confidence for semantic patterns. Same null guard; default 1.0
|
|
964
|
+
# only when missing/null, a legitimate 0.0 is preserved.
|
|
965
|
+
confidence = result.get("confidence")
|
|
966
|
+
confidence = 1.0 if confidence is None else confidence
|
|
958
967
|
|
|
959
968
|
# Combined score: relevance * task_weight * importance * confidence
|
|
960
969
|
# Importance contributes 30% of the final score
|
|
@@ -1141,17 +1150,22 @@ class MemoryRetrieval:
|
|
|
1141
1150
|
selected_memories.append(topic)
|
|
1142
1151
|
budget_remaining -= layer1_tokens
|
|
1143
1152
|
|
|
1144
|
-
# Layer 2: Expand summaries for top topics
|
|
1145
|
-
|
|
1146
|
-
|
|
1153
|
+
# Layer 2: Expand summaries for top topics.
|
|
1154
|
+
# Gate on the remaining budget (not a fraction of the layer-2 reserve)
|
|
1155
|
+
# and trim the summary set to fit via optimize_context, mirroring
|
|
1156
|
+
# Layer 3 below. Previously this admitted summaries all-or-nothing: a
|
|
1157
|
+
# set that exceeded budget_remaining was dropped entirely, and the gate
|
|
1158
|
+
# compared against layer2_budget*0.5 (a fraction of the reserve) rather
|
|
1159
|
+
# than the budget actually left.
|
|
1160
|
+
if budget_remaining > 100:
|
|
1147
1161
|
summaries = self._get_topic_summaries(relevant_topics[:5], query, weights)
|
|
1148
|
-
|
|
1162
|
+
for summary in summaries:
|
|
1163
|
+
summary["_layer"] = 2
|
|
1149
1164
|
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
budget_remaining -= layer2_tokens
|
|
1165
|
+
# Optimize to fit remaining budget (trimmed set, not all-or-nothing)
|
|
1166
|
+
optimized = optimize_context(summaries, budget_remaining)
|
|
1167
|
+
selected_memories.extend(optimized)
|
|
1168
|
+
budget_remaining -= sum(estimate_memory_tokens(s) for s in optimized)
|
|
1155
1169
|
|
|
1156
1170
|
# Layer 3: Full details for highest priority items
|
|
1157
1171
|
if budget_remaining > 100: # At least 100 tokens remaining
|
|
@@ -1189,14 +1203,36 @@ class MemoryRetrieval:
|
|
|
1189
1203
|
|
|
1190
1204
|
scored_topics = []
|
|
1191
1205
|
for topic in topics:
|
|
1192
|
-
|
|
1193
|
-
|
|
1206
|
+
if not isinstance(topic, dict):
|
|
1207
|
+
continue
|
|
1208
|
+
# The index.json writer (engine.py _stamp_topic at ~368 and
|
|
1209
|
+
# store_pattern at ~978) emits topics keyed by "id" (a phase or
|
|
1210
|
+
# category slug, e.g. "implementation", "auth") and "summary"
|
|
1211
|
+
# (prose: the goal text or "Patterns for <category>"). It does NOT
|
|
1212
|
+
# emit "topic", "type", or "last_updated". Previously this scorer
|
|
1213
|
+
# read only "topic"/"type"/"last_updated", so word overlap, type
|
|
1214
|
+
# weighting, and the recency boost were all silent no-ops on real
|
|
1215
|
+
# data. Score against the real keys (id + summary for word overlap,
|
|
1216
|
+
# id as the type/category for the strategy weight, the real recency
|
|
1217
|
+
# keys), and keep the legacy "topic"/"type"/"last_updated" keys as
|
|
1218
|
+
# fallbacks so any older-shape index still ranks.
|
|
1219
|
+
topic_text = " ".join(
|
|
1220
|
+
str(v) for v in (
|
|
1221
|
+
topic.get("summary"),
|
|
1222
|
+
topic.get("id"),
|
|
1223
|
+
topic.get("topic"),
|
|
1224
|
+
) if v
|
|
1225
|
+
).lower()
|
|
1226
|
+
# The category/phase slug doubles as the memory-type weight key
|
|
1227
|
+
# (the writer uses the category name as the id). Fall back to the
|
|
1228
|
+
# legacy "type" key for older-shape indexes.
|
|
1229
|
+
memory_type = (topic.get("id") or topic.get("type") or "").lower()
|
|
1194
1230
|
|
|
1195
1231
|
# Calculate relevance score
|
|
1196
1232
|
score = 0.0
|
|
1197
1233
|
|
|
1198
1234
|
# Word overlap
|
|
1199
|
-
topic_words = set(
|
|
1235
|
+
topic_words = set(topic_text.split())
|
|
1200
1236
|
overlap = len(query_words & topic_words)
|
|
1201
1237
|
score += overlap * 0.3
|
|
1202
1238
|
|
|
@@ -1204,8 +1240,11 @@ class MemoryRetrieval:
|
|
|
1204
1240
|
type_weight = weights.get(memory_type, 0.1)
|
|
1205
1241
|
score += type_weight
|
|
1206
1242
|
|
|
1207
|
-
# Recency boost
|
|
1208
|
-
|
|
1243
|
+
# Recency boost. The writer stamps "last_accessed"/"first_seen";
|
|
1244
|
+
# "last_updated" is the legacy key.
|
|
1245
|
+
if (topic.get("last_accessed")
|
|
1246
|
+
or topic.get("first_seen")
|
|
1247
|
+
or topic.get("last_updated")):
|
|
1209
1248
|
score += 0.1
|
|
1210
1249
|
|
|
1211
1250
|
if score > 0:
|
|
@@ -1226,8 +1265,15 @@ class MemoryRetrieval:
|
|
|
1226
1265
|
summaries = []
|
|
1227
1266
|
|
|
1228
1267
|
for topic in topics:
|
|
1229
|
-
|
|
1230
|
-
|
|
1268
|
+
if not isinstance(topic, dict):
|
|
1269
|
+
continue
|
|
1270
|
+
# Mirror _filter_relevant_topics: the writer emits "id"/"summary",
|
|
1271
|
+
# not "topic". Fall back to the legacy "topic" key so both shapes
|
|
1272
|
+
# resolve a usable name. Default type stays "episodic".
|
|
1273
|
+
topic_name = (
|
|
1274
|
+
topic.get("id") or topic.get("topic") or topic.get("summary") or ""
|
|
1275
|
+
)
|
|
1276
|
+
memory_type = topic.get("type") or "episodic"
|
|
1231
1277
|
|
|
1232
1278
|
# Try to load summary from appropriate collection
|
|
1233
1279
|
if memory_type == "episodic":
|
|
@@ -1426,7 +1472,12 @@ class MemoryRetrieval:
|
|
|
1426
1472
|
parts.append(f"action: {context['action_type']}")
|
|
1427
1473
|
|
|
1428
1474
|
if context.get("files"):
|
|
1429
|
-
|
|
1475
|
+
# Defensive: filter to str elements so a list carrying None or
|
|
1476
|
+
# non-str entries (corrupt/hand-edited record) does not raise
|
|
1477
|
+
# TypeError inside join. Mirrors the steps-join in skills search.
|
|
1478
|
+
files = [f for f in context["files"][:3] if isinstance(f, str)]
|
|
1479
|
+
if files:
|
|
1480
|
+
parts.append(f"files: {', '.join(files)}")
|
|
1430
1481
|
|
|
1431
1482
|
return " ".join(parts) if parts else ""
|
|
1432
1483
|
|
|
@@ -1458,13 +1509,16 @@ class MemoryRetrieval:
|
|
|
1458
1509
|
if not data:
|
|
1459
1510
|
continue
|
|
1460
1511
|
|
|
1461
|
-
# Score based on keyword matches in goal
|
|
1462
|
-
|
|
1463
|
-
|
|
1512
|
+
# Score based on keyword matches in goal.
|
|
1513
|
+
# Defensive: a corrupt or hand-edited record may carry
|
|
1514
|
+
# context=null or null string fields; (x or "") avoids
|
|
1515
|
+
# AttributeError on None.
|
|
1516
|
+
context = data.get("context") or {}
|
|
1517
|
+
goal = (context.get("goal") or "").lower()
|
|
1464
1518
|
score = sum(1 for kw in keywords if kw in goal)
|
|
1465
1519
|
|
|
1466
1520
|
# Also check phase
|
|
1467
|
-
phase = context.get("phase"
|
|
1521
|
+
phase = (context.get("phase") or "").lower()
|
|
1468
1522
|
score += sum(0.5 for kw in keywords if kw in phase)
|
|
1469
1523
|
|
|
1470
1524
|
if score > 0:
|
|
@@ -1487,16 +1541,21 @@ class MemoryRetrieval:
|
|
|
1487
1541
|
for pattern in patterns_data.get("patterns", []):
|
|
1488
1542
|
if not isinstance(pattern, dict):
|
|
1489
1543
|
continue
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1544
|
+
# Defensive: corrupt or hand-edited records may carry null
|
|
1545
|
+
# string fields; (x or "") avoids AttributeError on None.
|
|
1546
|
+
pattern_text = (pattern.get("pattern") or "").lower()
|
|
1547
|
+
category = (pattern.get("category") or "").lower()
|
|
1548
|
+
correct = (pattern.get("correct_approach") or "").lower()
|
|
1493
1549
|
|
|
1494
1550
|
score = sum(1 for kw in keywords if kw in pattern_text)
|
|
1495
1551
|
score += sum(0.5 for kw in keywords if kw in category)
|
|
1496
1552
|
score += sum(0.3 for kw in keywords if kw in correct)
|
|
1497
1553
|
|
|
1498
|
-
# Weight by confidence
|
|
1499
|
-
|
|
1554
|
+
# Weight by confidence. Defensive: a null confidence would make
|
|
1555
|
+
# score *= None raise TypeError. Use 0.5 only when missing/null;
|
|
1556
|
+
# a legitimate 0.0 is preserved (it correctly zeroes the score).
|
|
1557
|
+
confidence = pattern.get("confidence")
|
|
1558
|
+
confidence = 0.5 if confidence is None else confidence
|
|
1500
1559
|
score *= confidence
|
|
1501
1560
|
|
|
1502
1561
|
if score > 0:
|
|
@@ -1521,8 +1580,8 @@ class MemoryRetrieval:
|
|
|
1521
1580
|
if not data:
|
|
1522
1581
|
continue
|
|
1523
1582
|
|
|
1524
|
-
name = data.get("name"
|
|
1525
|
-
description = data.get("description"
|
|
1583
|
+
name = (data.get("name") or "").lower()
|
|
1584
|
+
description = (data.get("description") or "").lower()
|
|
1526
1585
|
steps_text = " ".join(
|
|
1527
1586
|
s for s in (data.get("steps") or []) if isinstance(s, str)
|
|
1528
1587
|
).lower()
|
|
@@ -1549,9 +1608,14 @@ class MemoryRetrieval:
|
|
|
1549
1608
|
anti_data = self.storage.read_json("semantic/anti-patterns.json") or {}
|
|
1550
1609
|
|
|
1551
1610
|
for anti in anti_data.get("anti_patterns", []):
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1611
|
+
# Defensive: mirror the sibling loop below. A corrupt or
|
|
1612
|
+
# hand-edited record may be a non-dict or carry null fields;
|
|
1613
|
+
# the isinstance guard and (x or "") avoid AttributeError.
|
|
1614
|
+
if not isinstance(anti, dict):
|
|
1615
|
+
continue
|
|
1616
|
+
what_fails = (anti.get("what_fails") or "").lower()
|
|
1617
|
+
why = (anti.get("why") or "").lower()
|
|
1618
|
+
prevention = (anti.get("prevention") or "").lower()
|
|
1555
1619
|
|
|
1556
1620
|
score = sum(2 for kw in keywords if kw in what_fails)
|
|
1557
1621
|
score += sum(1 for kw in keywords if kw in why)
|
|
@@ -1576,10 +1640,10 @@ class MemoryRetrieval:
|
|
|
1576
1640
|
continue
|
|
1577
1641
|
if pat.get("category") != "anti-pattern":
|
|
1578
1642
|
continue
|
|
1579
|
-
what_fails = (pat.get("incorrect_approach"
|
|
1580
|
-
or pat.get("pattern"
|
|
1581
|
-
why = pat.get("description"
|
|
1582
|
-
prevention = pat.get("correct_approach"
|
|
1643
|
+
what_fails = (pat.get("incorrect_approach")
|
|
1644
|
+
or pat.get("pattern") or "").lower()
|
|
1645
|
+
why = (pat.get("description") or "").lower()
|
|
1646
|
+
prevention = (pat.get("correct_approach") or "").lower()
|
|
1583
1647
|
|
|
1584
1648
|
score = sum(2 for kw in keywords if kw in what_fails)
|
|
1585
1649
|
score += sum(1 for kw in keywords if kw in why)
|
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",
|