loki-mode 7.64.0 → 7.66.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 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.64.0
6
+ # Loki Mode v7.66.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.64.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
409
+ **v7.66.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.64.0
1
+ 7.66.0
@@ -75,6 +75,12 @@ COUNCIL_CONSECUTIVE_NO_CHANGE=0
75
75
  COUNCIL_DONE_SIGNALS=0
76
76
  COUNCIL_TOTAL_DONE_SIGNALS=0
77
77
  COUNCIL_LAST_DIFF_HASH=""
78
+ # bash-F1: distinguishes a genuine council approval from a force-stop safety
79
+ # valve (stagnation / done-signal flood). council_should_stop sets this to 1
80
+ # ONLY on the force-stop paths; run.sh reads it (same sourced shell) to avoid
81
+ # reporting a non-approved force-stop as a verified-complete product. Guarded
82
+ # default so set -u never trips before the function runs.
83
+ : "${COUNCIL_FORCE_STOPPED:=0}"
78
84
 
79
85
  #===============================================================================
80
86
  # v6.83.0 Phase 1: Managed Agents memory augmentation (opt-in).
@@ -2051,27 +2057,32 @@ ISSUES: CRITICAL:description (optional, one per line per issue)"
2051
2057
  # no VOTE and default a real APPROVE to REJECT. Capture the full
2052
2058
  # output; the downstream parse already greps VOTE/REASON/ISSUES.
2053
2059
  # CAVEMAN_DEFAULT_MODE=off suppression is preserved (see above).
2054
- verdict=$(echo "$prompt" | env CAVEMAN_DEFAULT_MODE=off claude "${_cm_argv[@]}" -p 2>/dev/null)
2060
+ # bash-F3: timeout-guard the provider subcall so a hung CLI can
2061
+ # not stall the whole council. Default 600s matches the Bun route
2062
+ # (council.ts LOKI_COUNCIL_TIMEOUT_MS=600000). A timeout yields
2063
+ # empty output, which the [ -z "$verdict" ] fallback below turns
2064
+ # into a conservative heuristic review.
2065
+ verdict=$(echo "$prompt" | timeout "${LOKI_COUNCIL_REVIEW_TIMEOUT:-600}" env CAVEMAN_DEFAULT_MODE=off claude "${_cm_argv[@]}" -p 2>/dev/null)
2055
2066
  fi
2056
2067
  ;;
2057
2068
  codex)
2058
2069
  if command -v codex &>/dev/null; then
2059
- verdict=$(codex exec --sandbox workspace-write "$prompt" 2>/dev/null)
2070
+ verdict=$(timeout "${LOKI_COUNCIL_REVIEW_TIMEOUT:-600}" codex exec --sandbox workspace-write "$prompt" 2>/dev/null)
2060
2071
  fi
2061
2072
  ;;
2062
2073
  gemini)
2063
2074
  if command -v gemini &>/dev/null; then
2064
- verdict=$(echo "$prompt" | gemini 2>/dev/null)
2075
+ verdict=$(echo "$prompt" | timeout "${LOKI_COUNCIL_REVIEW_TIMEOUT:-600}" gemini 2>/dev/null)
2065
2076
  fi
2066
2077
  ;;
2067
2078
  cline)
2068
2079
  if command -v cline &>/dev/null; then
2069
- verdict=$(cline -y "$prompt" 2>/dev/null)
2080
+ verdict=$(timeout "${LOKI_COUNCIL_REVIEW_TIMEOUT:-600}" cline -y "$prompt" 2>/dev/null)
2070
2081
  fi
2071
2082
  ;;
2072
2083
  aider)
2073
2084
  if command -v aider &>/dev/null; then
2074
- verdict=$(aider --message "$prompt" --yes-always --no-auto-commits --no-git 2>/dev/null)
2085
+ verdict=$(timeout "${LOKI_COUNCIL_REVIEW_TIMEOUT:-600}" aider --message "$prompt" --yes-always --no-auto-commits --no-git 2>/dev/null)
2075
2086
  fi
2076
2087
  ;;
2077
2088
  esac
@@ -2152,27 +2163,30 @@ REASON: your reasoning"
2152
2163
  # Inlined on `claude` only (does not cross the pipe). No-op absent.
2153
2164
  # v7.41.3 BUG A: full capture, no tail-truncation (see member
2154
2165
  # subcall note). CAVEMAN_DEFAULT_MODE=off suppression preserved.
2155
- verdict=$(echo "$prompt" | env CAVEMAN_DEFAULT_MODE=off claude "${_co_argv[@]}" -p 2>/dev/null)
2166
+ # bash-F3: timeout-guard the devil's-advocate subcall (same 600s
2167
+ # default as the member subcalls / Bun council.ts). An empty
2168
+ # verdict on timeout hits the conservative REJECT fallback below.
2169
+ verdict=$(echo "$prompt" | timeout "${LOKI_COUNCIL_REVIEW_TIMEOUT:-600}" env CAVEMAN_DEFAULT_MODE=off claude "${_co_argv[@]}" -p 2>/dev/null)
2156
2170
  fi
2157
2171
  ;;
2158
2172
  codex)
2159
2173
  if command -v codex &>/dev/null; then
2160
- verdict=$(codex exec --sandbox workspace-write "$prompt" 2>/dev/null)
2174
+ verdict=$(timeout "${LOKI_COUNCIL_REVIEW_TIMEOUT:-600}" codex exec --sandbox workspace-write "$prompt" 2>/dev/null)
2161
2175
  fi
2162
2176
  ;;
2163
2177
  gemini)
2164
2178
  if command -v gemini &>/dev/null; then
2165
- verdict=$(echo "$prompt" | gemini 2>/dev/null)
2179
+ verdict=$(echo "$prompt" | timeout "${LOKI_COUNCIL_REVIEW_TIMEOUT:-600}" gemini 2>/dev/null)
2166
2180
  fi
2167
2181
  ;;
2168
2182
  cline)
2169
2183
  if command -v cline &>/dev/null; then
2170
- verdict=$(cline -y "$prompt" 2>/dev/null)
2184
+ verdict=$(timeout "${LOKI_COUNCIL_REVIEW_TIMEOUT:-600}" cline -y "$prompt" 2>/dev/null)
2171
2185
  fi
2172
2186
  ;;
2173
2187
  aider)
2174
2188
  if command -v aider &>/dev/null; then
2175
- verdict=$(aider --message "$prompt" --yes-always --no-auto-commits --no-git 2>/dev/null)
2189
+ verdict=$(timeout "${LOKI_COUNCIL_REVIEW_TIMEOUT:-600}" aider --message "$prompt" --yes-always --no-auto-commits --no-git 2>/dev/null)
2176
2190
  fi
2177
2191
  ;;
2178
2192
  esac
@@ -2753,7 +2767,13 @@ council_evaluate() {
2753
2767
  fi
2754
2768
  fi
2755
2769
  if [ -z "$aggregate_result" ]; then
2756
- aggregate_result=$(council_aggregate_votes)
2770
+ # bash-F2: council_aggregate_votes emits log_info/log_warn lines on
2771
+ # stdout (run.sh log_* helpers do not redirect to stderr) before its
2772
+ # terminal `echo "$verdict"`. Capturing the whole stream and exact-
2773
+ # matching "COMPLETE" never succeeds, so the heuristic path could never
2774
+ # return COMPLETE. Take only the last line (the verdict token); any
2775
+ # degenerate empty output falls through to the safe not-COMPLETE default.
2776
+ aggregate_result=$(council_aggregate_votes | tail -n1)
2757
2777
  fi
2758
2778
 
2759
2779
  if [ "$aggregate_result" = "COMPLETE" ]; then
@@ -3011,6 +3031,11 @@ PYEOF
3011
3031
  #===============================================================================
3012
3032
 
3013
3033
  council_should_stop() {
3034
+ # bash-F1: reset the force-stop sentinel at entry. A return-0 from the
3035
+ # genuine approval path leaves this 0; only the safety-valve paths below
3036
+ # set it to 1 before their return-0.
3037
+ COUNCIL_FORCE_STOPPED=0
3038
+
3014
3039
  if [ "$COUNCIL_ENABLED" != "true" ]; then
3015
3040
  return 1 # Council disabled, don't stop
3016
3041
  fi
@@ -3101,6 +3126,7 @@ council_should_stop() {
3101
3126
  if [ "$COUNCIL_CONSECUTIVE_NO_CHANGE" -ge "$safety_limit" ]; then
3102
3127
  log_error "Safety valve: ${COUNCIL_CONSECUTIVE_NO_CHANGE} iterations with no changes exceeds safety limit ($safety_limit)"
3103
3128
  log_error "Forcing stop to prevent resource waste"
3129
+ COUNCIL_FORCE_STOPPED=1 # bash-F1: force-stop, NOT a council approval
3104
3130
  return 0 # FORCE STOP
3105
3131
  fi
3106
3132
  fi
@@ -3109,6 +3135,7 @@ council_should_stop() {
3109
3135
  if [ "$COUNCIL_TOTAL_DONE_SIGNALS" -ge "$COUNCIL_DONE_SIGNAL_LIMIT" ]; then
3110
3136
  log_error "Safety valve: Agent signaled 'done' $COUNCIL_TOTAL_DONE_SIGNALS times (limit: $COUNCIL_DONE_SIGNAL_LIMIT)"
3111
3137
  log_error "Forcing stop - agent believes work is complete"
3138
+ COUNCIL_FORCE_STOPPED=1 # bash-F1: force-stop, NOT a council approval
3112
3139
  return 0 # FORCE STOP
3113
3140
  fi
3114
3141
 
package/autonomy/run.sh CHANGED
@@ -15728,6 +15728,23 @@ else:
15728
15728
  ensure_completion_test_evidence || true
15729
15729
  fi
15730
15730
  if type council_should_stop &>/dev/null && council_should_stop; then
15731
+ # bash-F1: council_should_stop returns 0 from a genuine approval
15732
+ # AND from two force-stop safety valves (stagnation flood /
15733
+ # repeated done-signals). A force-stop is NOT a verified-complete
15734
+ # product, so it must not claim "PROJECT COMPLETE" or open a PR.
15735
+ # The sentinel set inside council_should_stop disambiguates.
15736
+ if [ "${COUNCIL_FORCE_STOPPED:-0}" = "1" ]; then
15737
+ echo ""
15738
+ log_header "COMPLETION COUNCIL: STOPPED WITHOUT APPROVAL"
15739
+ log_warn "Council force-stopped (stagnation or repeated done-signals); work is NOT verified-complete"
15740
+ log_info "Running memory consolidation..."
15741
+ run_memory_consolidation
15742
+ # No on_run_complete: a force-stop must never open a "done" PR.
15743
+ emit_completion_summary force_stopped
15744
+ save_state $retry "force_stopped" 0
15745
+ rm -f "$iter_output" 2>/dev/null
15746
+ return 0
15747
+ fi
15731
15748
  echo ""
15732
15749
  log_header "COMPLETION COUNCIL: PROJECT COMPLETE"
15733
15750
  log_info "Council voted to stop (convergence detected + requirements verified)"
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.64.0"
10
+ __version__ = "7.66.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -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.62.0
5
+ **Version:** v7.66.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.62.0 start ./my-spec.md
398
+ asklokesh/loki-mode:7.66.0 start ./my-spec.md
399
399
  ```
400
400
 
401
401
  ##### docker compose + .env (no host install)
@@ -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.64.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}
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.66.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=377AD6A602B0171564756E2164756E21
794
+ //# debugId=7AC7B5725643161B64756E2164756E21
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.64.0'
60
+ __version__ = '7.66.0'
@@ -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
- for pattern in new_patterns + anti_patterns:
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
- self.storage.update_pattern(pattern)
278
- result.links_created += len(links)
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
- index = self.storage.read_json("index.json") or {
336
- "version": "1.1.0",
337
- "topics": [],
338
- "total_memories": 0,
339
- }
340
- context = episode.get("context", {}) if isinstance(episode.get("context"), dict) else {}
341
- phase = (context.get("phase") or episode.get("phase") or "general").lower()
342
- goal = (context.get("goal") or episode.get("goal") or "")[:200]
343
- # Topic id = phase. Multiple episodes in the same phase share a topic.
344
- topic_id = phase or "general"
345
- now = datetime.now(timezone.utc).isoformat()
346
- episode_id = episode.get("id")
347
- cost = float(episode.get("cost_usd", 0) or 0)
348
- tokens = int(episode.get("tokens_used", 0) or 0)
349
- files = list(episode.get("files_modified", []) or [])
350
-
351
- found = None
352
- for topic in index.get("topics", []):
353
- if topic.get("id") == topic_id:
354
- found = topic
355
- break
356
- if found is None:
357
- index.setdefault("topics", []).append({
358
- "id": topic_id,
359
- "summary": goal or f"Activity in phase {topic_id}",
360
- "episode_ids": [episode_id] if episode_id else [],
361
- "episode_count": 1,
362
- "total_cost_usd": cost,
363
- "total_tokens": tokens,
364
- "files_touched": files[:20],
365
- "first_seen": now,
366
- "last_accessed": now,
367
- "relevance_score": 0.5,
368
- })
369
- index["total_memories"] = index.get("total_memories", 0) + 1
370
- else:
371
- # Only count a given episode once. On resume/checkpoint the same
372
- # trace id can be re-saved; without this guard episode_count,
373
- # total_cost_usd, and total_tokens would inflate on every re-save
374
- # even though episode_ids is already de-duplicated.
375
- if episode_id and episode_id not in found.get("episode_ids", []):
376
- found.setdefault("episode_ids", []).append(episode_id)
377
- found["episode_count"] = found.get("episode_count", 0) + 1
378
- found["total_cost_usd"] = float(found.get("total_cost_usd", 0) or 0) + cost
379
- found["total_tokens"] = int(found.get("total_tokens", 0) or 0) + tokens
380
- merged = set(found.get("files_touched", []) or []) | set(files[:20])
381
- found["files_touched"] = sorted(merged)[:50]
382
- found["last_accessed"] = now
383
-
384
- index["last_updated"] = now
385
- self.storage.write_json("index.json", index)
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
- pass
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
- if pattern.get("confidence", 0) < min_confidence:
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
- pattern_data["usage_count"] = pattern_data.get("usage_count", 0) + 1
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
- skill_name = skill_dict.get("name", skill_id)
582
- filename = skill_name.lower().replace(" ", "-").replace("_", "-")
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", "Task completed")[:100],
939
+ "action": (context.get("goal") or "Task completed")[:100],
903
940
  "outcome": episode.get("outcome", "unknown"),
904
- "topic_id": context.get("phase", "general"),
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
- index = self.storage.read_json("index.json") or {
912
- "version": "1.0",
913
- "topics": [],
914
- "total_memories": 0,
915
- "total_tokens_available": 0,
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
- category = pattern.get("category", "general")
919
-
920
- # An index.json that is valid JSON but missing the "topics" key (e.g.
921
- # written by an older/partial writer, or hand-edited) would crash here
922
- # on index["topics"] because the `or {...}` default only fires when the
923
- # whole file is falsy. setdefault matches the defensive pattern used in
924
- # the sibling _update_index_with_episode.
925
- topics = index.setdefault("topics", [])
926
-
927
- # Find or create topic
928
- topic_found = False
929
- for topic in topics:
930
- if topic.get("id") == category:
931
- topic["last_accessed"] = datetime.now(timezone.utc).isoformat()
932
- topic["relevance_score"] = max(
933
- topic.get("relevance_score", 0.5),
934
- pattern.get("confidence", 0.5),
935
- )
936
- topic_found = True
937
- break
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
- if not topic_found:
940
- topics.append({
941
- "id": category,
942
- "summary": f"Patterns for {category}",
943
- "relevance_score": pattern.get("confidence", 0.5),
944
- "last_accessed": datetime.now(timezone.utc).isoformat(),
945
- "token_count": len(json.dumps(pattern)) // 4,
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
- index["last_updated"] = datetime.now(timezone.utc).isoformat()
949
- if not topic_found:
950
- index["total_memories"] = index.get("total_memories", 0) + 1
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
- self.storage.write_json("index.json", index)
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
- goal = context.get("goal", "").lower()
1194
- action = context.get("action_type", "").lower()
1195
- phase = context.get("phase", "").lower()
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
- goal = ep_dict.get("context", {}).get("goal", "").lower()
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", "").lower()
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", "").lower()
1303
- desc = s_dict.get("description", "").lower()
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
@@ -940,8 +940,11 @@ class MemoryRetrieval:
940
940
  Returns:
941
941
  Weighted score incorporating importance
942
942
  """
943
- source = result.get("_source", "")
944
- base_score = result.get("_score", 0.5)
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
- importance = result.get("importance", 0.5)
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
- confidence = result.get("confidence", 1.0)
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
- layer2_budget = int(token_budget * 0.4) # Reserve 40% for summaries
1146
- if budget_remaining > layer2_budget * 0.5:
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
- layer2_tokens = sum(estimate_memory_tokens(s) for s in summaries)
1162
+ for summary in summaries:
1163
+ summary["_layer"] = 2
1149
1164
 
1150
- if layer2_tokens <= budget_remaining:
1151
- for summary in summaries:
1152
- summary["_layer"] = 2
1153
- selected_memories.append(summary)
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
- topic_name = topic.get("topic", "").lower()
1193
- memory_type = topic.get("type", "").lower()
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(topic_name.split())
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
- if topic.get("last_updated"):
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
- topic_name = topic.get("topic", "")
1230
- memory_type = topic.get("type", "episodic")
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
- parts.append(f"files: {', '.join(context['files'][:3])}")
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
- context = data.get("context", {})
1463
- goal = context.get("goal", "").lower()
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", "").lower()
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
- pattern_text = pattern.get("pattern", "").lower()
1491
- category = pattern.get("category", "").lower()
1492
- correct = pattern.get("correct_approach", "").lower()
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
- confidence = pattern.get("confidence", 0.5)
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", "").lower()
1525
- description = data.get("description", "").lower()
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
- what_fails = anti.get("what_fails", "").lower()
1553
- why = anti.get("why", "").lower()
1554
- prevention = anti.get("prevention", "").lower()
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", "")).lower()
1581
- why = pat.get("description", "").lower()
1582
- prevention = pat.get("correct_approach", "").lower()
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-{episode_id}.json"
480
+ file_path = date_dir / f"task-{safe_episode_id}.json"
443
481
  self._atomic_write(file_path, episode_data)
444
482
 
445
483
  return episode_id
@@ -1153,7 +1191,12 @@ class MemoryStorage:
1153
1191
  Returns:
1154
1192
  Calculated importance score between 0.0 and 1.0
1155
1193
  """
1156
- base = memory.get("importance", 0.5)
1194
+ # Guard against an explicit null importance (corrupt or hand-edited
1195
+ # record) crashing the arithmetic below with a TypeError. Use an is-None
1196
+ # check (not `or`) so a legitimate stored importance of 0.0 is preserved
1197
+ # rather than silently promoted to 0.5.
1198
+ base = memory.get("importance")
1199
+ base = 0.5 if base is None else base
1157
1200
 
1158
1201
  # Outcome adjustment for episodes
1159
1202
  outcome = memory.get("outcome", "")
@@ -1169,8 +1212,10 @@ class MemoryStorage:
1169
1212
  if outcome == "success":
1170
1213
  base = min(1.0, base + 0.05 * min(len(errors), 3))
1171
1214
 
1172
- # Access frequency boost (diminishing returns)
1173
- access_count = memory.get("access_count", 0)
1215
+ # Access frequency boost (diminishing returns).
1216
+ # `or 0` guards against an explicit null access_count crashing the
1217
+ # comparison and log1p call below.
1218
+ access_count = memory.get("access_count") or 0
1174
1219
  if access_count > 0:
1175
1220
  # Log scale boost, caps at about 0.15 for 100+ accesses
1176
1221
  access_boost = 0.05 * math.log1p(access_count)
@@ -1184,9 +1229,9 @@ class MemoryStorage:
1184
1229
 
1185
1230
  # Task type relevance boost
1186
1231
  if task_type:
1187
- context = memory.get("context", {})
1188
- phase = context.get("phase", memory.get("phase", "")).lower()
1189
- category = memory.get("category", "").lower()
1232
+ context = memory.get("context") or {}
1233
+ phase = (context.get("phase") or memory.get("phase") or "").lower()
1234
+ category = (memory.get("category") or "").lower()
1190
1235
 
1191
1236
  task_type_lower = task_type.lower()
1192
1237
 
@@ -1254,7 +1299,12 @@ class MemoryStorage:
1254
1299
  continue
1255
1300
 
1256
1301
  # Apply exponential decay
1257
- current_importance = memory.get("importance", 0.5)
1302
+ # Use an is-None check (not get(..., 0.5) or `or`) so a record with
1303
+ # an explicit null importance (corrupt/hand-edited file) falls back
1304
+ # to the default instead of crashing the arithmetic, while a
1305
+ # legitimate stored 0.0 is preserved (it then floors at 0.01 below).
1306
+ current_importance = memory.get("importance")
1307
+ current_importance = 0.5 if current_importance is None else current_importance
1258
1308
  decay_factor = math.exp(-decay_rate * days_elapsed / half_life_days)
1259
1309
  decayed_importance = current_importance * decay_factor
1260
1310
 
@@ -1283,12 +1333,17 @@ class MemoryStorage:
1283
1333
  """
1284
1334
  now = datetime.now(timezone.utc)
1285
1335
 
1286
- # Update access tracking
1336
+ # Update access tracking. `or 0` guards against an explicit null
1337
+ # access_count (corrupt/hand-edited record) crashing the increment.
1287
1338
  memory["last_accessed"] = now.isoformat()
1288
- memory["access_count"] = memory.get("access_count", 0) + 1
1339
+ memory["access_count"] = (memory.get("access_count") or 0) + 1
1289
1340
 
1290
- # Boost importance (with diminishing returns for high importance)
1291
- current_importance = memory.get("importance", 0.5)
1341
+ # Boost importance (with diminishing returns for high importance).
1342
+ # Use an is-None check (not `or`) so an explicit null importance
1343
+ # (corrupt/hand-edited record) falls back to the default without
1344
+ # crashing, while a legitimate stored 0.0 is preserved.
1345
+ current_importance = memory.get("importance")
1346
+ current_importance = 0.5 if current_importance is None else current_importance
1292
1347
 
1293
1348
  # Diminishing returns: boost is reduced as importance approaches 1.0
1294
1349
  effective_boost = boost * (1.0 - current_importance)
@@ -1346,11 +1401,23 @@ class MemoryStorage:
1346
1401
  continue
1347
1402
 
1348
1403
  for file_path in date_dir.glob("task-*.json"):
1349
- data = self._load_json(file_path)
1350
- if data:
1351
- original_importance = data.get("importance", 0.5)
1404
+ # Hold one exclusive lock spanning the read-mutate-write so a
1405
+ # concurrent writer cannot clobber the decayed record (lost
1406
+ # update). Raw open/json.load inside the lock mirrors
1407
+ # save_pattern; _atomic_write re-enters the same lock (no-op).
1408
+ with self._file_lock(file_path, exclusive=True):
1409
+ if not file_path.exists():
1410
+ continue
1411
+ try:
1412
+ with open(file_path, "r", encoding="utf-8") as f:
1413
+ data = json.load(f)
1414
+ except (json.JSONDecodeError, OSError, UnicodeDecodeError):
1415
+ continue
1416
+ if not data:
1417
+ continue
1418
+ original_importance = data.get("importance") or 0.5
1352
1419
  memories = self.apply_decay([data], decay_rate, half_life_days)
1353
- if abs(memories[0].get("importance", 0.5) - original_importance) > 0.001:
1420
+ if abs((memories[0].get("importance") or 0.5) - original_importance) > 0.001:
1354
1421
  self._atomic_write(file_path, memories[0])
1355
1422
  updated += 1
1356
1423
 
@@ -1362,26 +1429,40 @@ class MemoryStorage:
1362
1429
  if not patterns_path.exists():
1363
1430
  return 0
1364
1431
 
1365
- patterns_file = self._load_json(patterns_path)
1366
- if not patterns_file:
1367
- return 0
1432
+ # Hold ONE exclusive lock spanning the read-mutate-write. Previously
1433
+ # the read (_load_json) and write (_atomic_write) each took a separate
1434
+ # lock scope, so a concurrent save_pattern/update_pattern between them
1435
+ # was clobbered (stale-snapshot lost update). Mirror save_pattern:
1436
+ # raw open/json.load inside the lock for the read; _atomic_write
1437
+ # re-enters the same reentrant lock (no-op) for the write.
1438
+ with self._file_lock(patterns_path, exclusive=True):
1439
+ if not patterns_path.exists():
1440
+ return 0
1441
+ try:
1442
+ with open(patterns_path, "r", encoding="utf-8") as f:
1443
+ patterns_file = json.load(f)
1444
+ except (json.JSONDecodeError, OSError, UnicodeDecodeError):
1445
+ return 0
1368
1446
 
1369
- patterns = patterns_file.get("patterns", [])
1370
- if not patterns:
1371
- return 0
1447
+ if not patterns_file:
1448
+ return 0
1372
1449
 
1373
- updated = 0
1374
- for pattern in patterns:
1375
- if not isinstance(pattern, dict):
1376
- continue
1377
- original = pattern.get("importance", 0.5)
1378
- self.apply_decay([pattern], decay_rate, half_life_days)
1379
- if abs(pattern.get("importance", 0.5) - original) > 0.001:
1380
- updated += 1
1450
+ patterns = patterns_file.get("patterns", [])
1451
+ if not patterns:
1452
+ return 0
1381
1453
 
1382
- if updated > 0:
1383
- patterns_file["last_updated"] = datetime.now(timezone.utc).isoformat()
1384
- self._atomic_write(patterns_path, patterns_file)
1454
+ updated = 0
1455
+ for pattern in patterns:
1456
+ if not isinstance(pattern, dict):
1457
+ continue
1458
+ original = pattern.get("importance") or 0.5
1459
+ self.apply_decay([pattern], decay_rate, half_life_days)
1460
+ if abs((pattern.get("importance") or 0.5) - original) > 0.001:
1461
+ updated += 1
1462
+
1463
+ if updated > 0:
1464
+ patterns_file["last_updated"] = datetime.now(timezone.utc).isoformat()
1465
+ self._atomic_write(patterns_path, patterns_file)
1385
1466
 
1386
1467
  return updated
1387
1468
 
@@ -1393,13 +1474,23 @@ class MemoryStorage:
1393
1474
  return 0
1394
1475
 
1395
1476
  for file_path in skills_dir.glob("*.json"):
1396
- data = self._load_json(file_path)
1397
- if data:
1398
- original = data.get("importance", 0.5)
1399
- self.apply_decay([data], decay_rate, half_life_days)
1400
- if abs(data.get("importance", 0.5) - original) > 0.001:
1401
- self._atomic_write(file_path, data)
1402
- updated += 1
1477
+ # Hold one exclusive lock spanning the read-mutate-write so a
1478
+ # concurrent writer cannot clobber the decayed record (lost
1479
+ # update). Mirrors _decay_semantic / save_pattern.
1480
+ with self._file_lock(file_path, exclusive=True):
1481
+ if not file_path.exists():
1482
+ continue
1483
+ try:
1484
+ with open(file_path, "r", encoding="utf-8") as f:
1485
+ data = json.load(f)
1486
+ except (json.JSONDecodeError, OSError, UnicodeDecodeError):
1487
+ continue
1488
+ if data:
1489
+ original = data.get("importance") or 0.5
1490
+ self.apply_decay([data], decay_rate, half_life_days)
1491
+ if abs((data.get("importance") or 0.5) - original) > 0.001:
1492
+ self._atomic_write(file_path, data)
1493
+ updated += 1
1403
1494
 
1404
1495
  return updated
1405
1496
 
@@ -153,6 +153,7 @@ def optimize_context(
153
153
  importance_weight: float = 0.4,
154
154
  recency_weight: float = 0.3,
155
155
  relevance_weight: float = 0.3,
156
+ slack_ratio: float = 0.0,
156
157
  ) -> list:
157
158
  """
158
159
  Optimize memory selection to fit within token budget.
@@ -166,6 +167,19 @@ def optimize_context(
166
167
  first, expanding to layer 2 (summary) and layer 3 (full) only if
167
168
  budget allows.
168
169
 
170
+ Budget adherence is strict by default: the returned memories never
171
+ exceed `budget` total tokens. This matters because callers chain the
172
+ result (for example, layered retrieval subtracts each layer's tokens
173
+ from a running budget), so any overshoot here leaks into the overall
174
+ context budget and can blow the model's context window.
175
+
176
+ A caller that deliberately wants a greedy fill (admit one more small
177
+ item that nearly fits) can opt in via `slack_ratio`. The effective
178
+ cap is then `int(budget * (1.0 + slack_ratio))`, and only an item
179
+ whose own size is under 10% of `budget` is eligible for the slack so
180
+ a single large item can never consume the slack. With the default
181
+ `slack_ratio=0.0` the cap equals `budget` exactly (no overage).
182
+
169
183
  Args:
170
184
  memories: List of memory dictionaries with optional fields:
171
185
  - _score: relevance score from retrieval
@@ -178,10 +192,14 @@ def optimize_context(
178
192
  importance_weight: Weight for importance scoring (default 0.4)
179
193
  recency_weight: Weight for recency scoring (default 0.3)
180
194
  relevance_weight: Weight for relevance scoring (default 0.3)
195
+ slack_ratio: Optional fractional overage allowed above `budget`
196
+ for small items only (default 0.0 = strict, never exceed
197
+ `budget`). Negative values are clamped to 0.0.
181
198
 
182
199
  Returns:
183
- List of memories that fit within the token budget, sorted by
184
- combined score.
200
+ List of memories that fit within the (slack-adjusted) token
201
+ budget, sorted by combined score. With the default
202
+ slack_ratio=0.0 the total never exceeds `budget`.
185
203
  """
186
204
  from datetime import datetime, timezone
187
205
 
@@ -195,9 +213,14 @@ def optimize_context(
195
213
  now = datetime.now(timezone.utc)
196
214
 
197
215
  for memory in memories:
198
- # Calculate importance score (0-1)
199
- confidence = memory.get("confidence", 0.5)
200
- usage_count = memory.get("usage_count", 0)
216
+ # Calculate importance score (0-1). Guard against explicit null fields
217
+ # (corrupt/hand-edited record): .get(key, default) returns None when the
218
+ # key is present but null, which would crash the arithmetic below. Use an
219
+ # is-None check for confidence (not `or`) so a legitimate stored 0.0 is
220
+ # preserved; usage_count of None and 0 are equivalent so `or 0` is fine.
221
+ confidence = memory.get("confidence")
222
+ confidence = 0.5 if confidence is None else confidence
223
+ usage_count = memory.get("usage_count") or 0
201
224
  # Normalize usage count with diminishing returns
202
225
  usage_score = min(1.0, usage_count / 10.0) if usage_count > 0 else 0.0
203
226
  importance = (confidence + usage_score) / 2.0
@@ -261,7 +284,13 @@ def optimize_context(
261
284
  # Sort by score (highest first)
262
285
  scored_memories.sort(key=lambda x: x["score"], reverse=True)
263
286
 
264
- # Select memories that fit within budget
287
+ # Select memories that fit within budget.
288
+ # Strict by default (slack_ratio=0.0 -> hard_cap == budget): the total
289
+ # never exceeds `budget`. A positive slack_ratio opts into a bounded
290
+ # greedy fill for small items only.
291
+ slack = max(0.0, slack_ratio)
292
+ hard_cap = int(budget * (1.0 + slack))
293
+
265
294
  selected = []
266
295
  total_tokens = 0
267
296
 
@@ -269,9 +298,9 @@ def optimize_context(
269
298
  if total_tokens + item["tokens"] <= budget:
270
299
  selected.append(item["memory"])
271
300
  total_tokens += item["tokens"]
272
- elif item["tokens"] < budget * 0.1:
273
- # Allow small memories even if slightly over budget
274
- if total_tokens + item["tokens"] <= budget * 1.1:
301
+ elif slack > 0.0 and item["tokens"] < budget * 0.1:
302
+ # Allow small memories into the explicit, bounded slack region.
303
+ if total_tokens + item["tokens"] <= hard_cap:
275
304
  selected.append(item["memory"])
276
305
  total_tokens += item["tokens"]
277
306
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "loki-mode",
3
3
  "mcpName": "io.github.asklokesh/loki-mode",
4
- "version": "7.64.0",
4
+ "version": "7.66.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.64.0",
5
+ "version": "7.66.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",