loki-mode 7.95.0 → 7.97.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.95.0
5
+ **Version:** v7.97.0
6
6
 
7
7
  ---
8
8
 
package/events/bus.py CHANGED
@@ -130,6 +130,16 @@ class LokiEvent:
130
130
  # dedup (_processed_ids + pending-file glob) and re-importing every flat
131
131
  # line on each call. Derive a DETERMINISTIC id from the record content
132
132
  # so repeated imports of the same line are idempotent.
133
+ #
134
+ # Width: the digest source is user-controllable (timestamp + type +
135
+ # payload). An 8-hex-char (32-bit) truncation collides at the birthday
136
+ # bound around ~77k distinct events (sqrt(2^32)); a collision makes two
137
+ # DISTINCT events share an id, so dedup (_processed_ids + pending glob)
138
+ # silently DROPS the second event. Widen to 16 hex chars (64 bits) so
139
+ # the collision bound moves to ~4 billion distinct events -- well past
140
+ # any realistic events.jsonl. This deterministic-derivation path is
141
+ # Python-only (bus.ts has no jsonl import and mints a random id when one
142
+ # is absent), so widening here needs no cross-language parity change.
133
143
  event_id = data.get('id', '')
134
144
  if not event_id:
135
145
  digest_src = '%s|%s|%s' % (
@@ -137,7 +147,7 @@ class LokiEvent:
137
147
  raw_type,
138
148
  json.dumps(payload, sort_keys=True),
139
149
  )
140
- event_id = hashlib.sha1(digest_src.encode('utf-8')).hexdigest()[:8]
150
+ event_id = hashlib.sha1(digest_src.encode('utf-8')).hexdigest()[:16]
141
151
 
142
152
  return cls(
143
153
  id=event_id,
package/events/bus.ts CHANGED
@@ -292,7 +292,13 @@ export class EventBus {
292
292
 
293
293
  if (archive) {
294
294
  try {
295
- const files = fs.readdirSync(this.pendingDir).filter((f) => f.includes(event.id));
295
+ // Match the exact id at the filename boundary. Emitted files are named
296
+ // `${timestamp}_${id}.json`, so a substring match (f.includes(event.id))
297
+ // would also match a DIFFERENT event whose id contains this id as a
298
+ // substring, archiving/marking the wrong event file (event loss). The
299
+ // Python impl already uses the precise `*_${id}.json` glob; mirror it.
300
+ const suffix = `_${event.id}.json`;
301
+ const files = fs.readdirSync(this.pendingDir).filter((f) => f.endsWith(suffix));
296
302
  for (const file of files) {
297
303
  const src = path.join(this.pendingDir, file);
298
304
  const dst = path.join(this.archiveDir, file);
package/events/emit.sh CHANGED
@@ -119,16 +119,27 @@ fi
119
119
 
120
120
  # JSON escape helper: handles \, ", and control characters including newlines
121
121
  #
122
- # The sed pass escapes the named short forms (\\ \" \t \r \b \f); the first awk
123
- # pass collapses embedded newlines to \n. Any OTHER C0 control byte
124
- # (0x01-0x07, 0x0B, 0x0E-0x1F) is invalid raw inside a JSON string and is
125
- # escaped as \uXXXX by the final awk pass -- otherwise json.loads / JSON.parse
126
- # reject the line and consumers (dashboard _read_events, learning aggregator)
127
- # silently drop it. The named short forms are already two-char ASCII by the
128
- # time the final pass runs, so they are never re-escaped.
122
+ # The sed pass escapes backslash, double-quote, tab and carriage-return to
123
+ # their named short forms (\\ \" \t \r); the first awk pass collapses embedded
124
+ # newlines to \n. Every REMAINING C0 control byte (0x01-0x08, 0x0B-0x0C,
125
+ # 0x0E-0x1F -- which includes backspace 0x08 and form-feed 0x0C) is invalid raw
126
+ # inside a JSON string and is escaped as \uXXXX by the final awk pass (its map
127
+ # covers all of i=1..31). Without that escaping json.loads / JSON.parse reject
128
+ # the line and consumers (dashboard _read_events, learning aggregator) silently
129
+ # drop it. The named short forms emitted by sed are already two-char ASCII by
130
+ # the time the final pass runs, so they are never re-escaped.
131
+ #
132
+ # NOTE: backspace/form-feed are deliberately NOT escaped in the sed pass.
133
+ # Doing so requires embedding the raw control bytes in the sed pattern, which
134
+ # editors silently strip -- that left the two empty-pattern sed no-ops this
135
+ # helper previously carried. An empty sed regex reuses the LAST applied regex,
136
+ # so those entries were dead AND a latent corruption hazard. The final awk
137
+ # pass already escapes every byte in i=1..31 (backspace and form-feed included)
138
+ # to the six-char uXXXX unicode escape, producing valid JSON, so the
139
+ # named-short-form variants are unnecessary.
129
140
  json_escape() {
130
141
  printf '%s' "$1" \
131
- | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g; s/\r/\\r/g; s//\\b/g; s//\\f/g' \
142
+ | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g; s/\r/\\r/g' \
132
143
  | awk '{if(NR>1) printf "\\n"; printf "%s", $0}' \
133
144
  | awk 'BEGIN{for(i=1;i<=31;i++)m[sprintf("%c",i)]=sprintf("\\u%04x",i)}
134
145
  {s=""; n=length($0);
@@ -163,9 +174,23 @@ EVENT=$(cat <<EOF
163
174
  EOF
164
175
  )
165
176
 
166
- # Write event file
177
+ # Write event file atomically.
178
+ #
179
+ # A bare `echo "$EVENT" > "$EVENT_FILE"` is NON-atomic: a reader (bus.py
180
+ # get_pending_events / from_dict, dashboard) that globs `*.json` mid-write sees
181
+ # a truncated/partial file and either fails json.loads (event silently dropped)
182
+ # or, worse, reads a half-written record. Write to a sibling temp file in the
183
+ # SAME directory (so rename(2) is an atomic same-filesystem operation) and then
184
+ # rename into place; a `.tmp` suffix keeps the in-progress file out of the
185
+ # `*.json` glob readers use. Clean up the temp on any failure so partials never
186
+ # linger.
167
187
  EVENT_FILE="$EVENTS_DIR/${TIMESTAMP//:/-}_$EVENT_ID.json"
168
- echo "$EVENT" > "$EVENT_FILE"
188
+ EVENT_TMP="$EVENT_FILE.tmp"
189
+ if printf '%s\n' "$EVENT" > "$EVENT_TMP" && mv -f "$EVENT_TMP" "$EVENT_FILE"; then
190
+ :
191
+ else
192
+ rm -f "$EVENT_TMP" 2>/dev/null || true
193
+ fi
169
194
 
170
195
  # Rotate events.jsonl if it exceeds 50MB (keep 1 backup)
171
196
  EVENTS_LOG="$LOKI_DIR/events.jsonl"
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- var z8=Object.defineProperty;var X8=($)=>$;function K8($,Q){this[$]=X8.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)z8($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:K8.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var J$=import.meta.require;var y1={};b(y1,{lokiDir:()=>P,homeLokiDir:()=>T$,findRepoRootForVersion:()=>e$,REPO_ROOT:()=>h});import{resolve as t,dirname as i$}from"path";import{fileURLToPath as q8}from"url";import{existsSync as E$}from"fs";import{homedir as J8}from"os";function V8(){let $=h1;for(let Q=0;Q<6;Q++){if(E$(t($,"VERSION"))&&E$(t($,"autonomy/run.sh")))return $;let Z=i$($);if(Z===$)break;$=Z}return t(h1,"..","..","..")}function e$($){let Q=$;for(let Z=0;Z<6;Z++){if(E$(t(Q,"VERSION"))&&E$(t(Q,"autonomy/run.sh")))return Q;let z=i$(Q);if(z===Q)break;Q=z}return t($,"..","..","..")}function P(){return process.env.LOKI_DIR??t(process.cwd(),".loki")}function T$(){return t(J8(),".loki")}var h1,h;var C=L(()=>{h1=i$(q8(import.meta.url));h=V8()});import{readFileSync as W8}from"fs";import{resolve as U8,dirname as H8}from"path";import{fileURLToPath as G8}from"url";function N$(){if(Q$!==null)return Q$;let $="7.95.0";if(typeof $==="string"&&$.length>0)return Q$=$,Q$;try{let Q=H8(G8(import.meta.url)),Z=e$(Q);Q$=W8(U8(Z,"VERSION"),"utf-8").trim()}catch{Q$="unknown"}return Q$}var Q$=null;var $1=L(()=>{C()});var u1={};b(u1,{runOrThrow:()=>x8,run:()=>F,readStreamCapped:()=>f1,commandVersion:()=>N8,commandExists:()=>f,ShellError:()=>Q1,MAX_STDOUT_BYTES:()=>m1});async function f1($,Q=m1){let Z=$.getReader(),z=new TextDecoder,X="",q=0;try{while(q<Q){let{done:K,value:W}=await Z.read();if(K)break;if(!W)continue;if(q+=W.byteLength,q>Q){let V=W.byteLength-(q-Q);X+=z.decode(W.subarray(0,V),{stream:!0});break}X+=z.decode(W,{stream:!0})}X+=z.decode()}finally{try{await Z.cancel()}catch{}Z.releaseLock()}return X}async function F($,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([f1(Z.stdout),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 x8($,Q={}){let Z=await F($,Q);if(Z.exitCode!==0)throw new Q1(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=E8($),Z=await F(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function E8($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function N8($,Q="--version"){if(!await f($))return null;let z=await F([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var m1=16777216,Q1;var d=L(()=>{Q1=class Q1 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 r($){return S8?"":$}var S8,T,S,_,lZ,I,k,y,J;var c=L(()=>{S8=(process.env.NO_COLOR??"").length>0;T=r("\x1B[0;31m"),S=r("\x1B[0;32m"),_=r("\x1B[1;33m"),lZ=r("\x1B[0;34m"),I=r("\x1B[0;36m"),k=r("\x1B[1m"),y=r("\x1B[2m"),J=r("\x1B[0m")});import{existsSync as u8}from"fs";async function Z$(){if(A$!==void 0)return A$;let $="/opt/homebrew/bin/python3.12";if(u8($))return A$=$,$;let Q=await f("python3.12");if(Q)return A$=Q,Q;let Z=await f("python3");return A$=Z,Z}async function z$($,Q={}){let Z=await Z$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return F([Z,"-c",$],Q)}var A$;var V$=L(()=>{d()});var q0={};b(q0,{runStatus:()=>W3});import{existsSync as v,readFileSync as U$,readdirSync as i1,statSync as e1}from"fs";import{resolve as D,basename as $3}from"path";import{homedir as Q3}from"os";function $0($){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 Q0($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*D$/Q);if(X>D$)X=D$;let q=D$-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)),V=$0($),U=$0(Q);return` ${k}${Z}${J} ${K}[${W}]${J} ${z}% (${V} / ${U})`}async function z3(){if(await f("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${J}
2
+ var z8=Object.defineProperty;var X8=($)=>$;function K8($,Q){this[$]=X8.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)z8($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:K8.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var J$=import.meta.require;var y1={};b(y1,{lokiDir:()=>P,homeLokiDir:()=>T$,findRepoRootForVersion:()=>e$,REPO_ROOT:()=>h});import{resolve as t,dirname as i$}from"path";import{fileURLToPath as q8}from"url";import{existsSync as E$}from"fs";import{homedir as J8}from"os";function V8(){let $=h1;for(let Q=0;Q<6;Q++){if(E$(t($,"VERSION"))&&E$(t($,"autonomy/run.sh")))return $;let Z=i$($);if(Z===$)break;$=Z}return t(h1,"..","..","..")}function e$($){let Q=$;for(let Z=0;Z<6;Z++){if(E$(t(Q,"VERSION"))&&E$(t(Q,"autonomy/run.sh")))return Q;let z=i$(Q);if(z===Q)break;Q=z}return t($,"..","..","..")}function P(){return process.env.LOKI_DIR??t(process.cwd(),".loki")}function T$(){return t(J8(),".loki")}var h1,h;var C=L(()=>{h1=i$(q8(import.meta.url));h=V8()});import{readFileSync as W8}from"fs";import{resolve as U8,dirname as H8}from"path";import{fileURLToPath as G8}from"url";function N$(){if(Q$!==null)return Q$;let $="7.97.0";if(typeof $==="string"&&$.length>0)return Q$=$,Q$;try{let Q=H8(G8(import.meta.url)),Z=e$(Q);Q$=W8(U8(Z,"VERSION"),"utf-8").trim()}catch{Q$="unknown"}return Q$}var Q$=null;var $1=L(()=>{C()});var u1={};b(u1,{runOrThrow:()=>x8,run:()=>F,readStreamCapped:()=>f1,commandVersion:()=>N8,commandExists:()=>f,ShellError:()=>Q1,MAX_STDOUT_BYTES:()=>m1});async function f1($,Q=m1){let Z=$.getReader(),z=new TextDecoder,X="",q=0;try{while(q<Q){let{done:K,value:W}=await Z.read();if(K)break;if(!W)continue;if(q+=W.byteLength,q>Q){let V=W.byteLength-(q-Q);X+=z.decode(W.subarray(0,V),{stream:!0});break}X+=z.decode(W,{stream:!0})}X+=z.decode()}finally{try{await Z.cancel()}catch{}Z.releaseLock()}return X}async function F($,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([f1(Z.stdout),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 x8($,Q={}){let Z=await F($,Q);if(Z.exitCode!==0)throw new Q1(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=E8($),Z=await F(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function E8($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function N8($,Q="--version"){if(!await f($))return null;let z=await F([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var m1=16777216,Q1;var d=L(()=>{Q1=class Q1 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 r($){return S8?"":$}var S8,T,S,_,lZ,I,k,y,J;var c=L(()=>{S8=(process.env.NO_COLOR??"").length>0;T=r("\x1B[0;31m"),S=r("\x1B[0;32m"),_=r("\x1B[1;33m"),lZ=r("\x1B[0;34m"),I=r("\x1B[0;36m"),k=r("\x1B[1m"),y=r("\x1B[2m"),J=r("\x1B[0m")});import{existsSync as u8}from"fs";async function Z$(){if(A$!==void 0)return A$;let $="/opt/homebrew/bin/python3.12";if(u8($))return A$=$,$;let Q=await f("python3.12");if(Q)return A$=Q,Q;let Z=await f("python3");return A$=Z,Z}async function z$($,Q={}){let Z=await Z$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return F([Z,"-c",$],Q)}var A$;var V$=L(()=>{d()});var q0={};b(q0,{runStatus:()=>W3});import{existsSync as v,readFileSync as U$,readdirSync as i1,statSync as e1}from"fs";import{resolve as D,basename as $3}from"path";import{homedir as Q3}from"os";function $0($){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 Q0($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*D$/Q);if(X>D$)X=D$;let q=D$-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)),V=$0($),U=$0(Q);return` ${k}${Z}${J} ${K}[${W}]${J} ${z}% (${V} / ${U})`}async function z3(){if(await f("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${J}
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)
@@ -802,4 +802,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
802
802
  `),2}default:return process.stderr.write(`Unknown command: ${Q}
803
803
  `),process.stderr.write(Z8),2}}r1();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var FZ=await jZ(Bun.argv.slice(2));process.exit(FZ);
804
804
 
805
- //# debugId=184A380A5462265664756E2164756E21
805
+ //# debugId=ABBB64E42A394DE264756E2164756E21
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.95.0'
60
+ __version__ = '7.97.0'
@@ -512,6 +512,24 @@ def get_mcp_learning_collector(
512
512
  MCPLearningCollector instance
513
513
  """
514
514
  global _collector
515
+ # Recreate when the requested loki_dir differs from the cached collector's
516
+ # directory. The module-level singleton would otherwise return a collector
517
+ # bound to a PREVIOUS project's .loki dir on cwd change, contaminating one
518
+ # project's learnings into another in a multi-project session. Compare by
519
+ # realpath so symlinked/relative paths to the same dir do not churn.
520
+ if _collector is not None and loki_dir is not None:
521
+ try:
522
+ cached = os.path.realpath(str(getattr(_collector, "loki_dir", "")))
523
+ requested = os.path.realpath(str(loki_dir))
524
+ if cached != requested:
525
+ close = getattr(_collector, "close", None)
526
+ if callable(close):
527
+ close()
528
+ _collector = None
529
+ except Exception:
530
+ # On any path-resolution error, fall through and reuse the cache
531
+ # rather than risk losing the collector.
532
+ pass
515
533
  if _collector is None:
516
534
  _collector = MCPLearningCollector(loki_dir=loki_dir)
517
535
  return _collector
package/mcp/server.py CHANGED
@@ -64,13 +64,28 @@ _learning_collector = None
64
64
 
65
65
 
66
66
  def _get_learning_collector():
67
- """Get or create the LearningCollector instance for MCP server."""
67
+ """Get or create the LearningCollector instance for MCP server.
68
+
69
+ Revalidates the cached collector against the current cwd-derived .loki dir
70
+ (mirrors the StateManager realpath-compare-recreate guard in
71
+ _get_mcp_state_manager). A multi-project MCP session can change cwd between
72
+ calls; without this, the cached collector stays bound to a PREVIOUS
73
+ project's .loki dir and contaminates one project's learnings into another.
74
+ """
68
75
  global _learning_collector
69
76
  if not LEARNING_COLLECTOR_AVAILABLE:
70
77
  return None
78
+ from pathlib import Path
79
+ loki_dir = Path(os.getcwd()) / '.loki'
80
+ if _learning_collector is not None:
81
+ existing_dir = getattr(_learning_collector, 'loki_dir', None)
82
+ if existing_dir and os.path.realpath(str(existing_dir)) != os.path.realpath(str(loki_dir)):
83
+ # Project directory changed, recreate
84
+ close = getattr(_learning_collector, 'close', None)
85
+ if callable(close):
86
+ close()
87
+ _learning_collector = None
71
88
  if _learning_collector is None:
72
- from pathlib import Path
73
- loki_dir = Path(os.getcwd()) / '.loki'
74
89
  _learning_collector = get_mcp_learning_collector(loki_dir=loki_dir)
75
90
  return _learning_collector
76
91
 
@@ -228,28 +228,34 @@ class ConsolidationPipeline:
228
228
  merged = False
229
229
  for idx, existing in enumerate(existing_patterns):
230
230
  if self._patterns_similar(new_pattern, existing):
231
- # Re-read the target pattern fresh immediately before
232
- # merging (BUG-MEM C1, lost-update). The whole-run
233
- # snapshot at step 4 can be stale by now: a concurrent
234
- # storage.increment_pattern_usage() (atomic read-mutate-
235
- # write under one exclusive lock) may have bumped
236
- # usage_count/last_used AFTER the snapshot. merge_with_existing() builds the
237
- # merged record from best_match.usage_count/last_used,
238
- # so merging from the stale snapshot clobbers that bump.
239
- # Re-reading narrows the window to this single write.
240
- merge_base = self._reload_pattern(existing)
241
- merged_pattern = self.merge_with_existing(new_pattern, [merge_base])
242
- self.storage.update_pattern(merged_pattern)
243
- # Refresh the in-memory copy so a later new pattern in
244
- # this same run that also merges into this existing
245
- # pattern builds on the just-merged state. Without this,
246
- # the second merge reads the stale pre-merge base and its
247
- # update_pattern() overwrites storage, silently dropping
248
- # the first merge's conditions/source_episodes/confidence.
249
- existing_patterns[idx] = merged_pattern
250
- result.patterns_merged += 1
251
- merged = True
252
- break
231
+ # Atomic read-merge-write (BUG-MEM C1, lost-update).
232
+ # The whole-run snapshot at step 4 can be stale by now:
233
+ # a concurrent storage.increment_pattern_usage()
234
+ # (atomic read-mutate-write under one exclusive lock)
235
+ # may have bumped usage_count/last_used AFTER the
236
+ # snapshot, and merge_with_existing() builds the merged
237
+ # record from the base's usage_count/last_used. The
238
+ # merge now runs INSIDE update_pattern_with_merge's
239
+ # single lock on patterns.json (the same path
240
+ # increment_pattern_usage locks), so the merge base is
241
+ # the live on-disk record and the bump cannot be lost.
242
+ merged_holder = {}
243
+
244
+ def _merge(current, _np=new_pattern, _holder=merged_holder):
245
+ base = SemanticPattern.from_dict(current)
246
+ m = self.merge_with_existing(_np, [base])
247
+ _holder["pattern"] = m
248
+ return m
249
+
250
+ if self.storage.update_pattern_with_merge(existing.id, _merge):
251
+ merged_pattern = merged_holder["pattern"]
252
+ # Refresh the in-memory copy so a later new pattern
253
+ # in this same run that also merges into this
254
+ # existing pattern builds on the just-merged state.
255
+ existing_patterns[idx] = merged_pattern
256
+ result.patterns_merged += 1
257
+ merged = True
258
+ break
253
259
 
254
260
  if not merged:
255
261
  self.storage.save_pattern(new_pattern)
@@ -277,20 +283,27 @@ class ConsolidationPipeline:
277
283
  for idx, existing in enumerate(existing_patterns):
278
284
  if (existing.incorrect_approach and
279
285
  self._patterns_similar(anti_pattern, existing, threshold=0.6)):
280
- # Re-read fresh before merge (same C1 lost-update guard as the
281
- # cluster merge loop above): merge from current on-disk state,
282
- # not the potentially-stale step-4 snapshot.
283
- merge_base = self._reload_pattern(existing)
284
- merged_pattern = self.merge_with_existing(anti_pattern, [merge_base])
285
- self.storage.update_pattern(merged_pattern)
286
- # Refresh in-memory copy (same data-loss guard as the cluster
287
- # merge loop above): a later anti-pattern merging into this same
288
- # existing pattern must build on the just-merged state, not the
289
- # stale pre-merge base.
290
- existing_patterns[idx] = merged_pattern
291
- result.patterns_merged += 1
292
- merged = True
293
- break
286
+ # Atomic read-merge-write (same C1 lost-update guard as the
287
+ # cluster merge loop above): merge from the live on-disk record
288
+ # inside update_pattern_with_merge's single lock so a concurrent
289
+ # usage bump cannot be clobbered.
290
+ merged_holder = {}
291
+
292
+ def _merge(current, _ap=anti_pattern, _holder=merged_holder):
293
+ base = SemanticPattern.from_dict(current)
294
+ m = self.merge_with_existing(_ap, [base])
295
+ _holder["pattern"] = m
296
+ return m
297
+
298
+ if self.storage.update_pattern_with_merge(existing.id, _merge):
299
+ merged_pattern = merged_holder["pattern"]
300
+ # Refresh in-memory copy: a later anti-pattern merging into
301
+ # this same existing pattern must build on the just-merged
302
+ # state, not the stale pre-merge base.
303
+ existing_patterns[idx] = merged_pattern
304
+ result.patterns_merged += 1
305
+ merged = True
306
+ break
294
307
 
295
308
  if not merged:
296
309
  self.storage.save_pattern(anti_pattern)
@@ -324,34 +337,12 @@ class ConsolidationPipeline:
324
337
  result.duration_seconds = time.time() - start_time
325
338
  return result
326
339
 
327
- def _reload_pattern(self, fallback: SemanticPattern) -> SemanticPattern:
328
- """Re-read a pattern fresh from storage immediately before merging.
329
-
330
- Used by the merge branches to avoid the C1 lost-update: the step-4
331
- snapshot may be stale (a concurrent usage bump can land after it), and
332
- merge_with_existing() copies usage_count/last_used from the base. Reading
333
- the current on-disk record makes the merge build on live state.
334
-
335
- Mirrors the snapshot's dict/object handling. If load_pattern returns
336
- nothing (e.g. the record vanished), fall back to the in-memory copy so the
337
- merge still proceeds rather than crashing.
338
-
339
- Residual limitation (honest): load_pattern and update_pattern are SEPARATE
340
- lock acquisitions, so a bump landing between this re-read and the write is
341
- still lost. This narrows the race window from the whole run to a single
342
- write; it is a mitigation, not cross-process atomicity. A full fix needs a
343
- compare-and-set or merge-callback update in storage, which is out of scope
344
- for this file (storage.py is frozen this batch).
345
- """
346
- try:
347
- fresh = self.storage.load_pattern(fallback.id)
348
- except Exception:
349
- return fallback
350
- if not fresh:
351
- return fallback
352
- if isinstance(fresh, dict):
353
- return SemanticPattern.from_dict(fresh)
354
- return fresh
340
+ # NOTE: The former _reload_pattern() helper (a partial C1 lost-update
341
+ # mitigation that re-read the pattern in a SEPARATE lock from the write) was
342
+ # removed once the merge moved fully inside storage.update_pattern_with_merge,
343
+ # which performs the read, merge, and write under ONE exclusive lock on
344
+ # patterns.json. That closes the lost-update race cross-process; no residual
345
+ # narrow window remains.
355
346
 
356
347
  # -------------------------------------------------------------------------
357
348
  # Clustering Methods
package/memory/engine.py CHANGED
@@ -1034,6 +1034,39 @@ class MemoryEngine:
1034
1034
 
1035
1035
  return None
1036
1036
 
1037
+ @staticmethod
1038
+ def _parse_optional_datetime(
1039
+ raw: Any, *, record_id: str = "<unknown>", field: str = "last_accessed"
1040
+ ) -> Optional[datetime]:
1041
+ """Tolerantly parse an optional ISO datetime field off a stored record.
1042
+
1043
+ A corrupt/non-ISO value on ONE record must not raise out of the
1044
+ _dict_to_* converters and crash the whole batch list-comp in
1045
+ get_recent_episodes / find_patterns / list_skills (which would drop EVERY
1046
+ item in the scan, not just the bad one) on the RARV retrieval hot path.
1047
+ An unparseable value falls back to None (treated as never-accessed) so
1048
+ the record is still returned and retrievable.
1049
+
1050
+ Returns a tz-aware datetime (UTC assumed when naive), or None when the
1051
+ value is missing/empty/unparseable.
1052
+ """
1053
+ if not raw:
1054
+ return None
1055
+ if isinstance(raw, datetime):
1056
+ return raw.replace(tzinfo=timezone.utc) if raw.tzinfo is None else raw
1057
+ if isinstance(raw, str):
1058
+ value = raw[:-1] if raw.endswith("Z") else raw
1059
+ try:
1060
+ parsed = datetime.fromisoformat(value)
1061
+ except ValueError:
1062
+ logger.warning(
1063
+ "Record %s has unparseable %s %r; treating as never-accessed",
1064
+ record_id, field, raw,
1065
+ )
1066
+ return None
1067
+ return parsed.replace(tzinfo=timezone.utc) if parsed.tzinfo is None else parsed
1068
+ return None
1069
+
1037
1070
  def _dict_to_episode(self, data: Dict[str, Any]) -> EpisodeTrace:
1038
1071
  """Convert dictionary to EpisodeTrace."""
1039
1072
  # Parse timestamp string to datetime
@@ -1081,20 +1114,13 @@ class MemoryEngine:
1081
1114
  for e in errors_raw
1082
1115
  ]
1083
1116
 
1084
- # Parse last_accessed datetime
1085
- last_accessed = None
1086
- last_accessed_raw = data.get("last_accessed")
1087
- if last_accessed_raw:
1088
- if isinstance(last_accessed_raw, str):
1089
- if last_accessed_raw.endswith("Z"):
1090
- last_accessed_raw = last_accessed_raw[:-1]
1091
- last_accessed = datetime.fromisoformat(last_accessed_raw)
1092
- if last_accessed.tzinfo is None:
1093
- last_accessed = last_accessed.replace(tzinfo=timezone.utc)
1094
- elif isinstance(last_accessed_raw, datetime):
1095
- last_accessed = last_accessed_raw
1096
- if last_accessed.tzinfo is None:
1097
- last_accessed = last_accessed.replace(tzinfo=timezone.utc)
1117
+ # Parse last_accessed datetime (tolerant: a corrupt value falls back to
1118
+ # None so a single bad episode never crashes the retrieval batch).
1119
+ last_accessed = self._parse_optional_datetime(
1120
+ data.get("last_accessed"),
1121
+ record_id=data.get("id", "<unknown>"),
1122
+ field="last_accessed",
1123
+ )
1098
1124
 
1099
1125
  return EpisodeTrace(
1100
1126
  id=data.get("id", ""),
@@ -1119,21 +1145,14 @@ class MemoryEngine:
1119
1145
 
1120
1146
  def _dict_to_pattern(self, data: Dict[str, Any]) -> SemanticPattern:
1121
1147
  """Convert dictionary to SemanticPattern."""
1122
- # Parse last_used string to datetime or None
1123
- last_used = None
1124
- last_used_raw = data.get("last_used")
1125
- if last_used_raw:
1126
- if isinstance(last_used_raw, str):
1127
- # Handle ISO format with Z suffix
1128
- if last_used_raw.endswith("Z"):
1129
- last_used_raw = last_used_raw[:-1]
1130
- last_used = datetime.fromisoformat(last_used_raw)
1131
- if last_used.tzinfo is None:
1132
- last_used = last_used.replace(tzinfo=timezone.utc)
1133
- elif isinstance(last_used_raw, datetime):
1134
- last_used = last_used_raw
1135
- if last_used.tzinfo is None:
1136
- last_used = last_used.replace(tzinfo=timezone.utc)
1148
+ # Parse last_used string to datetime or None (tolerant: a corrupt value
1149
+ # on one pattern must not crash the find_patterns retrieval batch; it
1150
+ # shares the same hot path and crash mechanism as last_accessed below).
1151
+ last_used = self._parse_optional_datetime(
1152
+ data.get("last_used"),
1153
+ record_id=data.get("id", "<unknown>"),
1154
+ field="last_used",
1155
+ )
1137
1156
 
1138
1157
  # Convert links dicts to Link objects
1139
1158
  links_raw = data.get("links", [])
@@ -1142,20 +1161,12 @@ class MemoryEngine:
1142
1161
  for link in links_raw
1143
1162
  ]
1144
1163
 
1145
- # Parse last_accessed datetime
1146
- last_accessed = None
1147
- last_accessed_raw = data.get("last_accessed")
1148
- if last_accessed_raw:
1149
- if isinstance(last_accessed_raw, str):
1150
- if last_accessed_raw.endswith("Z"):
1151
- last_accessed_raw = last_accessed_raw[:-1]
1152
- last_accessed = datetime.fromisoformat(last_accessed_raw)
1153
- if last_accessed.tzinfo is None:
1154
- last_accessed = last_accessed.replace(tzinfo=timezone.utc)
1155
- elif isinstance(last_accessed_raw, datetime):
1156
- last_accessed = last_accessed_raw
1157
- if last_accessed.tzinfo is None:
1158
- last_accessed = last_accessed.replace(tzinfo=timezone.utc)
1164
+ # Parse last_accessed datetime (tolerant: see _dict_to_episode).
1165
+ last_accessed = self._parse_optional_datetime(
1166
+ data.get("last_accessed"),
1167
+ record_id=data.get("id", "<unknown>"),
1168
+ field="last_accessed",
1169
+ )
1159
1170
 
1160
1171
  return SemanticPattern(
1161
1172
  id=data.get("id", ""),
@@ -1182,20 +1193,12 @@ class MemoryEngine:
1182
1193
  for e in raw_errors
1183
1194
  ]
1184
1195
 
1185
- # Parse last_accessed datetime
1186
- last_accessed = None
1187
- last_accessed_raw = data.get("last_accessed")
1188
- if last_accessed_raw:
1189
- if isinstance(last_accessed_raw, str):
1190
- if last_accessed_raw.endswith("Z"):
1191
- last_accessed_raw = last_accessed_raw[:-1]
1192
- last_accessed = datetime.fromisoformat(last_accessed_raw)
1193
- if last_accessed.tzinfo is None:
1194
- last_accessed = last_accessed.replace(tzinfo=timezone.utc)
1195
- elif isinstance(last_accessed_raw, datetime):
1196
- last_accessed = last_accessed_raw
1197
- if last_accessed.tzinfo is None:
1198
- last_accessed = last_accessed.replace(tzinfo=timezone.utc)
1196
+ # Parse last_accessed datetime (tolerant: see _dict_to_episode).
1197
+ last_accessed = self._parse_optional_datetime(
1198
+ data.get("last_accessed"),
1199
+ record_id=data.get("id", "<unknown>"),
1200
+ field="last_accessed",
1201
+ )
1199
1202
 
1200
1203
  return ProceduralSkill(
1201
1204
  id=data.get("id", ""),
@@ -9,10 +9,66 @@ for injection into the RARV prompt cycle.
9
9
 
10
10
  import argparse
11
11
  import json
12
+ import re
12
13
  import sys
13
14
  from pathlib import Path
14
15
 
15
16
 
17
+ # Per-field hard cap so a single oversized/garbage memory entry cannot dominate
18
+ # the injected context or push real signal out of the token budget.
19
+ _MAX_FIELD_CHARS = 600
20
+
21
+ # Control characters (excluding ordinary whitespace handled separately) that
22
+ # must never reach the prompt: they can corrupt rendering or smuggle hidden
23
+ # directives. Covers C0 controls and DEL.
24
+ _CONTROL_CHARS_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
25
+
26
+
27
+ def _sanitize_field(value, max_chars=_MAX_FIELD_CHARS):
28
+ """Neutralize a single field from stored memory before it enters a prompt.
29
+
30
+ Memory entries are untrusted input: a malicious or garbage pattern could
31
+ contain prompt-injection payloads (fake instructions, fabricated section
32
+ headers, code-fence breakers, or directives that try to override the RARV
33
+ prompt). This collapses each field to a single safe inline line so it can
34
+ only ever read as data, never as structure or instructions.
35
+
36
+ Steps:
37
+ - Coerce non-strings to str (a dict/list value would otherwise break
38
+ formatting or interpolate unexpected structure).
39
+ - Drop control characters (keeps the visible text intact).
40
+ - Collapse ALL newlines/tabs/carriage-returns to single spaces so the
41
+ field cannot add new lines, fake "### " section headers, close the
42
+ knowledge block, or open/close a markdown code fence.
43
+ - Defang leading structural markdown markers (#, >, -, *, backticks) so the
44
+ field cannot pose as a heading/list/fence even after collapsing.
45
+ - Truncate to a bounded length.
46
+ """
47
+ if value is None:
48
+ return ''
49
+ if not isinstance(value, str):
50
+ value = str(value)
51
+ # Remove control characters first.
52
+ value = _CONTROL_CHARS_RE.sub('', value)
53
+ # Collapse any vertical whitespace and tabs into single spaces. This is the
54
+ # core injection defense: without newlines the field cannot introduce new
55
+ # markdown structure or standalone instruction lines.
56
+ value = re.sub(r'[\r\n\t]+', ' ', value)
57
+ # Collapse runs of spaces left behind.
58
+ value = re.sub(r' {2,}', ' ', value).strip()
59
+ # Defang leading structural markdown so the field cannot masquerade as a
60
+ # heading, blockquote, list item, or code fence.
61
+ value = re.sub(r'^[#>\-*`]+\s*', '', value)
62
+ # Defang markdown header markers anywhere in the (now single-line) value so
63
+ # a collapsed payload cannot reintroduce a "### " section marker inline.
64
+ # Backticks are neutralized too so a field cannot open/close a code fence.
65
+ value = re.sub(r'#{1,6}\s', '', value)
66
+ value = value.replace('`', '')
67
+ if len(value) > max_chars:
68
+ value = value[:max_chars].rstrip() + '...'
69
+ return value
70
+
71
+
16
72
  def build_rag_context(query, max_tokens=2000, knowledge_dir=None):
17
73
  """Build RAG context from the knowledge graph.
18
74
 
@@ -39,11 +95,20 @@ def build_rag_context(query, max_tokens=2000, knowledge_dir=None):
39
95
  total_chars = 0
40
96
 
41
97
  for p in patterns:
42
- # Support both 'name'/'pattern' and 'description' fields
43
- name = p.get('name', p.get('pattern', 'Unknown Pattern'))
44
- desc = p.get('description', p.get('correct_approach', ''))
45
- category = p.get('category', '')
46
- source = p.get('_source_project', '')
98
+ # Support both 'name'/'pattern' and 'description' fields.
99
+ # Every field is sanitized: memory entries are untrusted input and must
100
+ # not be able to inject instructions or break the prompt structure.
101
+ name = _sanitize_field(p.get('name', p.get('pattern', 'Unknown Pattern')))
102
+ desc = _sanitize_field(p.get('description', p.get('correct_approach', '')))
103
+ category = _sanitize_field(p.get('category', ''))
104
+ source_raw = p.get('_source_project', '')
105
+ # Path().name strips any directory traversal; sanitize the basename too.
106
+ source = _sanitize_field(Path(str(source_raw)).name) if source_raw else ''
107
+
108
+ # name may be empty after sanitization (e.g. a field that was only
109
+ # markdown markers); fall back so the heading is never blank.
110
+ if not name:
111
+ name = 'Unknown Pattern'
47
112
 
48
113
  section = '### ' + name
49
114
  if category:
@@ -52,7 +117,7 @@ def build_rag_context(query, max_tokens=2000, knowledge_dir=None):
52
117
  if desc:
53
118
  section += desc + '\n'
54
119
  if source:
55
- section += '_Source: ' + Path(source).name + '_\n'
120
+ section += '_Source: ' + source + '_\n'
56
121
 
57
122
  if total_chars + len(section) > max_chars:
58
123
  break