loki-mode 7.95.0 → 7.97.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/hooks/migration-hooks.sh +74 -10
- package/autonomy/run.sh +104 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/static/index.html +162 -156
- package/docs/INSTALLATION.md +1 -1
- package/events/bus.py +11 -1
- package/events/bus.ts +7 -1
- package/events/emit.sh +35 -10
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/mcp/learning_collector.py +18 -0
- package/mcp/server.py +18 -3
- package/memory/consolidation.py +55 -64
- package/memory/engine.py +60 -57
- package/memory/rag_injector.py +71 -6
- package/memory/retrieval.py +42 -12
- package/memory/storage.py +124 -18
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
package/docs/INSTALLATION.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The flagship product of [Autonomi](https://www.autonomi.dev/). Loki Mode is a spec-driven autonomous builder with a built-in trust layer that takes any spec to a deployed product and verifies completion with evidence (quality gates plus a completion council), not just a "done" claim. Complete installation instructions for all platforms and use cases.
|
|
4
4
|
|
|
5
|
-
**Version:** v7.
|
|
5
|
+
**Version:** v7.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()[:
|
|
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
|
-
|
|
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
|
|
123
|
-
#
|
|
124
|
-
#
|
|
125
|
-
#
|
|
126
|
-
#
|
|
127
|
-
#
|
|
128
|
-
#
|
|
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
|
|
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
|
-
|
|
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"
|
package/loki-ts/dist/loki.js
CHANGED
|
@@ -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.
|
|
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=
|
|
805
|
+
//# debugId=ABBB64E42A394DE264756E2164756E21
|
package/mcp/__init__.py
CHANGED
|
@@ -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
|
|
package/memory/consolidation.py
CHANGED
|
@@ -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
|
-
#
|
|
232
|
-
#
|
|
233
|
-
#
|
|
234
|
-
#
|
|
235
|
-
#
|
|
236
|
-
#
|
|
237
|
-
#
|
|
238
|
-
#
|
|
239
|
-
#
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
#
|
|
281
|
-
# cluster merge loop above): merge from
|
|
282
|
-
#
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
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
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
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 =
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
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 =
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
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", ""),
|
package/memory/rag_injector.py
CHANGED
|
@@ -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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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: ' +
|
|
120
|
+
section += '_Source: ' + source + '_\n'
|
|
56
121
|
|
|
57
122
|
if total_chars + len(section) > max_chars:
|
|
58
123
|
break
|