loki-mode 7.7.20 → 7.7.22

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: Multi-agent autonomous startup system. Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v7.7.20
6
+ # Loki Mode v7.7.22
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -381,4 +381,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
381
381
 
382
382
  ---
383
383
 
384
- **v7.7.20 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
384
+ **v7.7.22 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.7.20
1
+ 7.7.22
package/autonomy/loki CHANGED
@@ -15054,6 +15054,7 @@ except Exception as e:
15054
15054
  echo ""
15055
15055
  echo " loki memory ingest --from-claude-transcript <path> # v7.7.18"
15056
15056
  echo " loki memory ingest --from-stdin # v7.7.18"
15057
+ echo " loki memory replay <episode-id> [--json] # v7.7.22"
15057
15058
  echo " loki memory crossproject --for 'build api' # v7.7.20"
15058
15059
  echo " loki memory graph --export graph.json # v7.7.20"
15059
15060
  echo " loki memory graph rebuild # v7.7.20"
@@ -15138,6 +15139,41 @@ print(json.dumps({'episode_path': path}))
15138
15139
  fi
15139
15140
  ;;
15140
15141
 
15142
+ replay)
15143
+ # v7.7.22 wow feature: READ-ONLY deterministic session replay.
15144
+ # Renders a past episode's action timeline + current state of
15145
+ # touched files. Does NOT re-execute (LLM tool_uses are
15146
+ # non-deterministic + re-running edits could clobber work;
15147
+ # --apply deferred to a future release).
15148
+ # Usage: loki memory replay <episode-id> [--json]
15149
+ shift # drop "replay"
15150
+ local _replay_id=""
15151
+ local _replay_json="false"
15152
+ while [ $# -gt 0 ]; do
15153
+ case "$1" in
15154
+ --json) _replay_json="true"; shift ;;
15155
+ -h|--help) echo "Usage: loki memory replay <episode-id> [--json]"; return 0 ;;
15156
+ *) [ -z "$_replay_id" ] && _replay_id="$1"; shift ;;
15157
+ esac
15158
+ done
15159
+ if [ -z "$_replay_id" ]; then
15160
+ echo -e "${RED}Usage: loki memory replay <episode-id> [--json]${NC}"
15161
+ exit 1
15162
+ fi
15163
+ local _replay_mem
15164
+ _replay_mem="$(pwd)/.loki/memory"
15165
+ PYTHONPATH="${SKILL_DIR:-$(pwd)}" _LOKI_REPLAY_JSON="$_replay_json" python3 -c "
15166
+ import sys, os, json
15167
+ from memory.replay import replay_episode, render_markdown
15168
+ report = replay_episode(sys.argv[1], sys.argv[2])
15169
+ if os.environ.get('_LOKI_REPLAY_JSON') == 'true':
15170
+ print(json.dumps(report, indent=2, default=str))
15171
+ else:
15172
+ print(render_markdown(report))
15173
+ sys.exit(0 if report.get('found') else 1)
15174
+ " "$_replay_id" "$_replay_mem"
15175
+ ;;
15176
+
15141
15177
  crossproject)
15142
15178
  # v7.7.20: surface cross-project knowledge-graph patterns
15143
15179
  # (wakes the previously-dead memory/knowledge_graph.py).
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.7.20"
10
+ __version__ = "7.7.22"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -2638,16 +2638,115 @@ async def get_skill(skill_id: str):
2638
2638
  raise HTTPException(status_code=404, detail="Skill not found")
2639
2639
 
2640
2640
 
2641
- @app.get("/api/memory/economics")
2641
+ @app.get("/api/memory/economics", dependencies=[Depends(auth.require_scope("read"))])
2642
2642
  async def get_token_economics():
2643
- """Get token usage economics."""
2644
- econ_file = _get_loki_dir() / "memory" / "token_economics.json"
2643
+ """Get token usage economics (v7.7.21: normalized + hit_rate + top_patterns).
2644
+
2645
+ Excellence bar 5: per-retrieval cost + hit rate + top patterns visible.
2646
+ Reads token_economics.json (written by memory.token_economics.save())
2647
+ which has shape {session_id, metrics:{discovery_tokens, read_tokens,
2648
+ cache_hits, cache_misses, ...}, ratio, savings_percent}. Computes a
2649
+ cache hit_rate + surfaces the most-accessed episodes/patterns. The
2650
+ pre-v7.7.21 endpoint returned camelCase keys that did not match the
2651
+ snake_case file; the `raw` field preserves the original document for
2652
+ backward compat while the top-level fields are normalized.
2653
+ """
2654
+ loki_dir = _get_loki_dir()
2655
+ econ_file = loki_dir / "memory" / "token_economics.json"
2656
+ raw = {}
2645
2657
  if econ_file.exists():
2646
2658
  try:
2647
- return json.loads(econ_file.read_text())
2659
+ raw = json.loads(econ_file.read_text())
2648
2660
  except Exception:
2649
- pass
2650
- return {"discoveryTokens": 0, "readTokens": 0, "savingsPercent": 0}
2661
+ raw = {}
2662
+
2663
+ metrics = raw.get("metrics", {}) if isinstance(raw, dict) else {}
2664
+ cache_hits = int(metrics.get("cache_hits", 0) or 0)
2665
+ cache_misses = int(metrics.get("cache_misses", 0) or 0)
2666
+ cache_total = cache_hits + cache_misses
2667
+ hit_rate = round(cache_hits / cache_total, 4) if cache_total > 0 else 0.0
2668
+ discovery_tokens = int(metrics.get("discovery_tokens", 0) or 0)
2669
+ read_tokens = int(metrics.get("read_tokens", 0) or 0)
2670
+
2671
+ # Top-accessed memories: scan episodic + semantic, rank by access_count
2672
+ # then importance.
2673
+ # v7.7.21 council fix (Opus 1 + Opus 2):
2674
+ # - os.walk(followlinks=False) instead of recursive glob: does NOT
2675
+ # descend symlinked dirs (prevents traversal/exfil + DoS-amplify
2676
+ # via a symlink to a huge tree).
2677
+ # - realpath containment: every candidate file must resolve to a
2678
+ # path under mem_root (mirrors the sibling get_skill endpoint).
2679
+ # - hard cap on files SCANNED (not just surfaced): stop after
2680
+ # MAX_SCAN files per subdir so a large store cannot make this
2681
+ # request unboundedly slow even with the 30s auto-refresh.
2682
+ top_patterns = []
2683
+ try:
2684
+ import os as _os
2685
+ mem_root = (loki_dir / "memory").resolve()
2686
+ MAX_SCAN = 300
2687
+ candidates = []
2688
+ for sub in ("episodic", "semantic"):
2689
+ sub_root = mem_root / sub
2690
+ if not sub_root.is_dir():
2691
+ continue
2692
+ scanned = 0
2693
+ stop = False
2694
+ for dirpath, dirnames, filenames in _os.walk(str(sub_root), followlinks=False):
2695
+ if stop:
2696
+ break
2697
+ for fn in filenames:
2698
+ if not fn.endswith(".json"):
2699
+ continue
2700
+ fp = _os.path.join(dirpath, fn)
2701
+ # Containment: resolved path must stay under mem_root.
2702
+ try:
2703
+ rp = _os.path.realpath(fp)
2704
+ if _os.path.commonpath([rp, str(mem_root)]) != str(mem_root):
2705
+ continue
2706
+ except (OSError, ValueError):
2707
+ continue
2708
+ try:
2709
+ with open(fp) as fh:
2710
+ d = json.load(fh)
2711
+ candidates.append({
2712
+ "id": d.get("id", ""),
2713
+ "kind": sub,
2714
+ "access_count": int(d.get("access_count", 0) or 0),
2715
+ "importance": float(d.get("importance", 0.0) or 0.0),
2716
+ "summary": str(
2717
+ d.get("summary")
2718
+ or d.get("pattern")
2719
+ or d.get("context", {}).get("goal", "")
2720
+ )[:160],
2721
+ })
2722
+ except Exception:
2723
+ continue
2724
+ scanned += 1
2725
+ if scanned >= MAX_SCAN:
2726
+ stop = True
2727
+ break
2728
+ candidates.sort(key=lambda c: (c["access_count"], c["importance"]), reverse=True)
2729
+ top_patterns = candidates[:10]
2730
+ except Exception:
2731
+ top_patterns = []
2732
+
2733
+ return {
2734
+ "session_id": raw.get("session_id"),
2735
+ "discovery_tokens": discovery_tokens,
2736
+ "read_tokens": read_tokens,
2737
+ "total_tokens": discovery_tokens + read_tokens,
2738
+ "cache_hits": cache_hits,
2739
+ "cache_misses": cache_misses,
2740
+ "hit_rate": hit_rate,
2741
+ "ratio": raw.get("ratio", 0.0),
2742
+ "savings_percent": raw.get("savings_percent", 0.0),
2743
+ "top_patterns": top_patterns,
2744
+ # Backward-compat aliases (pre-v7.7.21 camelCase consumers)
2745
+ "discoveryTokens": discovery_tokens,
2746
+ "readTokens": read_tokens,
2747
+ "savingsPercent": raw.get("savings_percent", 0.0),
2748
+ "raw": raw,
2749
+ }
2651
2750
 
2652
2751
 
2653
2752
  @app.post("/api/memory/consolidate", dependencies=[Depends(auth.require_scope("control"))])
@@ -632,6 +632,55 @@
632
632
  <div>
633
633
  <h3 style="font-family: 'DM Serif Display', Georgia, serif; font-size: 1.15rem; font-weight: 400; color: var(--loki-text-primary); margin-bottom: 12px;">Memory</h3>
634
634
  <loki-memory-browser id="memory-browser" tab="summary"></loki-memory-browser>
635
+ <!-- v7.7.21 token economics tile: hit rate + tokens + top patterns -->
636
+ <div id="memory-economics-tile" style="margin-top: 12px; background: var(--loki-bg-card, #1a1a1a); border: 1px solid var(--loki-border, #333); border-radius: 5px; padding: 12px;">
637
+ <div style="font-size: 11px; color: var(--loki-text-muted, #888); margin-bottom: 8px;">Token Economics</div>
638
+ <div id="memory-economics-metrics" style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; font-size: 13px;">
639
+ <div><span style="color: var(--loki-text-muted, #888);">Hit rate</span><br><strong id="econ-hit-rate">--</strong></div>
640
+ <div><span style="color: var(--loki-text-muted, #888);">Total tokens</span><br><strong id="econ-total-tokens">--</strong></div>
641
+ <div><span style="color: var(--loki-text-muted, #888);">Savings</span><br><strong id="econ-savings">--</strong></div>
642
+ </div>
643
+ <div id="memory-economics-top" style="margin-top: 10px; font-size: 12px; color: var(--loki-text-muted, #888);"></div>
644
+ </div>
645
+ <script>
646
+ (function(){
647
+ function loadEconomics(){
648
+ fetch('/api/memory/economics').then(function(r){ return r.json(); }).then(function(j){
649
+ var hr = document.getElementById('econ-hit-rate');
650
+ var tt = document.getElementById('econ-total-tokens');
651
+ var sv = document.getElementById('econ-savings');
652
+ var top = document.getElementById('memory-economics-top');
653
+ if (hr) hr.textContent = ((j.hit_rate || 0) * 100).toFixed(1) + '%';
654
+ if (tt) tt.textContent = (j.total_tokens || 0).toLocaleString();
655
+ if (sv) sv.textContent = (j.savings_percent || 0).toFixed(1) + '%';
656
+ if (top) {
657
+ var patterns = j.top_patterns || [];
658
+ // v7.7.21 council fix (Opus 1): build DOM with
659
+ // textContent (NOT innerHTML single-char escape) so
660
+ // agent/PRD-derived summaries cannot inject markup.
661
+ while (top.firstChild) top.removeChild(top.firstChild);
662
+ if (patterns.length === 0) {
663
+ top.textContent = 'No retrieval patterns yet. Run sessions to accumulate.';
664
+ } else {
665
+ var header = document.createElement('div');
666
+ header.style.marginBottom = '4px';
667
+ header.textContent = 'Top retrieved:';
668
+ top.appendChild(header);
669
+ patterns.slice(0, 5).forEach(function(p){
670
+ var row = document.createElement('div');
671
+ // textContent escapes everything; no markup injection.
672
+ row.textContent = (p.access_count || 0) + 'x · ' +
673
+ (p.summary || p.id || '');
674
+ top.appendChild(row);
675
+ });
676
+ }
677
+ }
678
+ }).catch(function(){ /* endpoint not available; tile stays at -- */ });
679
+ }
680
+ loadEconomics();
681
+ setInterval(loadEconomics, 30000);
682
+ })();
683
+ </script>
635
684
  </div>
636
685
  <div>
637
686
  <h3 style="font-family: 'DM Serif Display', Georgia, serif; font-size: 1.15rem; font-weight: 400; color: var(--loki-text-primary); margin-bottom: 12px;">Memory Files</h3>
@@ -2,7 +2,7 @@
2
2
 
3
3
  The flagship product of [Autonomi](https://www.autonomi.dev/). Complete installation instructions for all platforms and use cases.
4
4
 
5
- **Version:** v7.7.20
5
+ **Version:** v7.7.22
6
6
 
7
7
  ---
8
8
 
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- var _7=Object.defineProperty;var I7=(K)=>K;function P7(K,$){this[K]=I7.bind(null,$)}var v=(K,$)=>{for(var Q in $)_7(K,Q,{get:$[Q],enumerable:!0,configurable:!0,set:P7.bind($,Q)})};var R=(K,$)=>()=>(K&&($=K(K=0)),$);var t=import.meta.require;var e1={};v(e1,{lokiDir:()=>P,homeLokiDir:()=>k1,findRepoRootForVersion:()=>N1,REPO_ROOT:()=>p});import{resolve as u,dirname as S1}from"path";import{fileURLToPath as L7}from"url";import{existsSync as J1}from"fs";import{homedir as R7}from"os";function E7(){let K=i1;for(let $=0;$<6;$++){if(J1(u(K,"VERSION"))&&J1(u(K,"autonomy/run.sh")))return K;let Q=S1(K);if(Q===K)break;K=Q}return u(i1,"..","..","..")}function N1(K){let $=K;for(let Q=0;Q<6;Q++){if(J1(u($,"VERSION"))&&J1(u($,"autonomy/run.sh")))return $;let X=S1($);if(X===$)break;$=X}return u(K,"..","..","..")}function P(){return process.env.LOKI_DIR??u(process.cwd(),".loki")}function k1(){return u(R7(),".loki")}var i1,p;var g=R(()=>{i1=S1(L7(import.meta.url));p=E7()});import{readFileSync as F7}from"fs";import{resolve as w7,dirname as x7}from"path";import{fileURLToPath as S7}from"url";function G1(){if(o!==null)return o;let K="7.7.20";if(typeof K==="string"&&K.length>0)return o=K,o;try{let $=x7(S7(import.meta.url)),Q=N1($);o=F7(w7(Q,"VERSION"),"utf-8").trim()}catch{o="unknown"}return o}var o=null;var D1=R(()=>{g()});var $0={};v($0,{runOrThrow:()=>N7,run:()=>k,commandVersion:()=>D7,commandExists:()=>h,ShellError:()=>C1});async function k(K,$={}){let Q=Bun.spawn({cmd:[...K],stdout:"pipe",stderr:"pipe",env:$.env?{...process.env,...$.env}:process.env,cwd:$.cwd}),X,Z;if($.timeoutMs&&$.timeoutMs>0)X=setTimeout(()=>{try{Q.kill("SIGTERM")}catch{}Z=setTimeout(()=>{try{Q.kill("SIGKILL")}catch{}},2000)},$.timeoutMs);try{let[W,z,q]=await Promise.all([new Response(Q.stdout).text(),new Response(Q.stderr).text(),Q.exited]);return{stdout:W,stderr:z,exitCode:q}}finally{if(X)clearTimeout(X);if(Z)clearTimeout(Z)}}async function N7(K,$={}){let Q=await k(K,$);if(Q.exitCode!==0)throw new C1(`command failed (${Q.exitCode}): ${K.join(" ")}`,Q.exitCode,Q.stdout,Q.stderr);return Q}async function h(K){let $=k7(K),Q=await k(["sh","-c",`command -v ${$}`],{timeoutMs:5000});if(Q.exitCode===0)return Q.stdout.trim()||null;return null}function k7(K){if(!/^[A-Za-z0-9._/-]+$/.test(K))throw Error(`refused to shell-escape suspect token: ${K}`);return K}async function D7(K,$="--version"){if(!await h(K))return null;let X=await k([K,$],{timeoutMs:5000});if(X.exitCode!==0)return null;return((X.stdout||X.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var C1;var n=R(()=>{C1=class C1 extends Error{message;exitCode;stdout;stderr;constructor(K,$,Q,X){super(K);this.message=K;this.exitCode=$;this.stdout=Q;this.stderr=X;this.name="ShellError"}}});function c(K){return C7?"":K}var C7,E,b,F,T6,O,D,w,H;var a=R(()=>{C7=(process.env.NO_COLOR??"").length>0;E=c("\x1B[0;31m"),b=c("\x1B[0;32m"),F=c("\x1B[1;33m"),T6=c("\x1B[0;34m"),O=c("\x1B[0;36m"),D=c("\x1B[1m"),w=c("\x1B[2m"),H=c("\x1B[0m")});import{existsSync as c7}from"fs";async function i(){if(X1!==void 0)return X1;let K="/opt/homebrew/bin/python3.12";if(c7(K))return X1=K,K;let $=await h("python3.12");if($)return X1=$,$;let Q=await h("python3");return X1=Q,Q}async function s(K,$={}){let Q=await i();if(!Q)return{stdout:"",stderr:"python3 not found",exitCode:127};return k([Q,"-c",K],$)}var X1;var Z1=R(()=>{n()});var G0={};v(G0,{runStatus:()=>Q5});import{existsSync as N,readFileSync as W1,readdirSync as W0,statSync as H0}from"fs";import{resolve as x,basename as a7}from"path";async function r7(){if(await h("jq"))return!0;return process.stdout.write(`${E}Error: jq is required but not installed.${H}
2
+ var _7=Object.defineProperty;var I7=(K)=>K;function P7(K,$){this[K]=I7.bind(null,$)}var v=(K,$)=>{for(var Q in $)_7(K,Q,{get:$[Q],enumerable:!0,configurable:!0,set:P7.bind($,Q)})};var R=(K,$)=>()=>(K&&($=K(K=0)),$);var t=import.meta.require;var e1={};v(e1,{lokiDir:()=>P,homeLokiDir:()=>k1,findRepoRootForVersion:()=>N1,REPO_ROOT:()=>p});import{resolve as u,dirname as S1}from"path";import{fileURLToPath as L7}from"url";import{existsSync as J1}from"fs";import{homedir as R7}from"os";function E7(){let K=i1;for(let $=0;$<6;$++){if(J1(u(K,"VERSION"))&&J1(u(K,"autonomy/run.sh")))return K;let Q=S1(K);if(Q===K)break;K=Q}return u(i1,"..","..","..")}function N1(K){let $=K;for(let Q=0;Q<6;Q++){if(J1(u($,"VERSION"))&&J1(u($,"autonomy/run.sh")))return $;let X=S1($);if(X===$)break;$=X}return u(K,"..","..","..")}function P(){return process.env.LOKI_DIR??u(process.cwd(),".loki")}function k1(){return u(R7(),".loki")}var i1,p;var g=R(()=>{i1=S1(L7(import.meta.url));p=E7()});import{readFileSync as F7}from"fs";import{resolve as w7,dirname as x7}from"path";import{fileURLToPath as S7}from"url";function G1(){if(o!==null)return o;let K="7.7.22";if(typeof K==="string"&&K.length>0)return o=K,o;try{let $=x7(S7(import.meta.url)),Q=N1($);o=F7(w7(Q,"VERSION"),"utf-8").trim()}catch{o="unknown"}return o}var o=null;var D1=R(()=>{g()});var $0={};v($0,{runOrThrow:()=>N7,run:()=>k,commandVersion:()=>D7,commandExists:()=>h,ShellError:()=>C1});async function k(K,$={}){let Q=Bun.spawn({cmd:[...K],stdout:"pipe",stderr:"pipe",env:$.env?{...process.env,...$.env}:process.env,cwd:$.cwd}),X,Z;if($.timeoutMs&&$.timeoutMs>0)X=setTimeout(()=>{try{Q.kill("SIGTERM")}catch{}Z=setTimeout(()=>{try{Q.kill("SIGKILL")}catch{}},2000)},$.timeoutMs);try{let[W,z,q]=await Promise.all([new Response(Q.stdout).text(),new Response(Q.stderr).text(),Q.exited]);return{stdout:W,stderr:z,exitCode:q}}finally{if(X)clearTimeout(X);if(Z)clearTimeout(Z)}}async function N7(K,$={}){let Q=await k(K,$);if(Q.exitCode!==0)throw new C1(`command failed (${Q.exitCode}): ${K.join(" ")}`,Q.exitCode,Q.stdout,Q.stderr);return Q}async function h(K){let $=k7(K),Q=await k(["sh","-c",`command -v ${$}`],{timeoutMs:5000});if(Q.exitCode===0)return Q.stdout.trim()||null;return null}function k7(K){if(!/^[A-Za-z0-9._/-]+$/.test(K))throw Error(`refused to shell-escape suspect token: ${K}`);return K}async function D7(K,$="--version"){if(!await h(K))return null;let X=await k([K,$],{timeoutMs:5000});if(X.exitCode!==0)return null;return((X.stdout||X.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var C1;var n=R(()=>{C1=class C1 extends Error{message;exitCode;stdout;stderr;constructor(K,$,Q,X){super(K);this.message=K;this.exitCode=$;this.stdout=Q;this.stderr=X;this.name="ShellError"}}});function c(K){return C7?"":K}var C7,E,b,F,T6,O,D,w,H;var a=R(()=>{C7=(process.env.NO_COLOR??"").length>0;E=c("\x1B[0;31m"),b=c("\x1B[0;32m"),F=c("\x1B[1;33m"),T6=c("\x1B[0;34m"),O=c("\x1B[0;36m"),D=c("\x1B[1m"),w=c("\x1B[2m"),H=c("\x1B[0m")});import{existsSync as c7}from"fs";async function i(){if(X1!==void 0)return X1;let K="/opt/homebrew/bin/python3.12";if(c7(K))return X1=K,K;let $=await h("python3.12");if($)return X1=$,$;let Q=await h("python3");return X1=Q,Q}async function s(K,$={}){let Q=await i();if(!Q)return{stdout:"",stderr:"python3 not found",exitCode:127};return k([Q,"-c",K],$)}var X1;var Z1=R(()=>{n()});var G0={};v(G0,{runStatus:()=>Q5});import{existsSync as N,readFileSync as W1,readdirSync as W0,statSync as H0}from"fs";import{resolve as x,basename as a7}from"path";async function r7(){if(await h("jq"))return!0;return process.stdout.write(`${E}Error: jq is required but not installed.${H}
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)
@@ -585,4 +585,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
585
585
  `),2}default:return process.stderr.write(`Unknown command: ${$}
586
586
  `),process.stderr.write(j7),2}}process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var X6=await Q6(Bun.argv.slice(2));process.exit(X6);
587
587
 
588
- //# debugId=E25419B4237E69C764756E2164756E21
588
+ //# debugId=67ACBDA1E9391E4564756E2164756E21
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.7.20'
60
+ __version__ = '7.7.22'
@@ -0,0 +1,192 @@
1
+ """v7.7.22: deterministic READ-ONLY session replay.
2
+
3
+ Wow feature 1 from the memory excellence bar: no competitor offers
4
+ session replay. `loki memory replay <episode-id>` re-renders a past
5
+ episode's recorded action sequence as a timeline, annotated with the
6
+ CURRENT state of each touched file (still exists? changed since the
7
+ episode? present in git?). This answers "what did that session do, and
8
+ what has changed since" without re-executing anything.
9
+
10
+ DESIGN DECISION (v7.7.22): replay is READ-ONLY. It does NOT re-run the
11
+ recorded tool_use sequence. LLM tool_uses are non-deterministic and
12
+ re-running Edit/Write against the current repo could clobber
13
+ uncommitted work. The `--apply` re-execution mode is deliberately
14
+ deferred to a future release with proper sandboxing + confirmation.
15
+
16
+ Output: a structured dict (the CLI renders Markdown or emits JSON).
17
+ Never raises; returns an error dict on failure.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import subprocess
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+ from typing import Any, Dict, List, Optional
27
+
28
+
29
+ def _file_changed_since(repo_root: str, file_path: str, since_iso: Optional[str]) -> str:
30
+ """Return a current-state annotation for `file_path`.
31
+
32
+ One of: "missing", "unchanged-since", "changed-since",
33
+ "exists-no-timestamp", "exists-not-in-git". Best-effort; never raises.
34
+ """
35
+ abs_path = file_path if os.path.isabs(file_path) else os.path.join(repo_root, file_path)
36
+ if not os.path.exists(abs_path):
37
+ return "missing"
38
+ if not since_iso:
39
+ return "exists-no-timestamp"
40
+ # Use git to see if the file changed after the episode timestamp.
41
+ try:
42
+ # Normalize the ISO timestamp for git --since.
43
+ since = since_iso.replace("Z", "+00:00")
44
+ result = subprocess.run(
45
+ ["git", "-C", repo_root, "log", "--oneline", f"--since={since}", "--", file_path],
46
+ capture_output=True, text=True, timeout=10,
47
+ )
48
+ if result.returncode != 0:
49
+ return "exists-not-in-git"
50
+ commits = [ln for ln in result.stdout.splitlines() if ln.strip()]
51
+ return "changed-since" if commits else "unchanged-since"
52
+ except (subprocess.SubprocessError, OSError):
53
+ return "exists-no-timestamp"
54
+
55
+
56
+ def replay_episode(
57
+ episode_id: str,
58
+ memory_base: str,
59
+ repo_root: Optional[str] = None,
60
+ ) -> Dict[str, Any]:
61
+ """Build a read-only replay report for `episode_id`.
62
+
63
+ Args:
64
+ episode_id: The episode id (without the `task-` prefix).
65
+ memory_base: Path to the project's `.loki/memory/` directory.
66
+ repo_root: Repo root for current-state annotations. Defaults to
67
+ the parent-of-parent of memory_base (i.e. the project root).
68
+
69
+ Returns:
70
+ dict: {episode_id, found, timestamp, goal, outcome, agent,
71
+ timeline:[{step, tool, target, timestamp}], files:[{path,
72
+ state}], summary:{steps, files_touched, files_missing,
73
+ files_changed_since}} or {error: ...}.
74
+ """
75
+ try:
76
+ from memory.storage import MemoryStorage
77
+ storage = MemoryStorage(memory_base)
78
+ episode = storage.load_episode(episode_id)
79
+ if episode is None:
80
+ return {"episode_id": episode_id, "found": False,
81
+ "error": f"episode '{episode_id}' not found in {memory_base}"}
82
+
83
+ if repo_root is None:
84
+ # memory_base is typically <root>/.loki/memory
85
+ repo_root = str(Path(memory_base).resolve().parent.parent)
86
+
87
+ ts = episode.get("timestamp")
88
+ context = episode.get("context", {}) if isinstance(episode.get("context"), dict) else {}
89
+ goal = context.get("goal") or episode.get("goal") or episode.get("summary", "")
90
+
91
+ # Timeline from action_log (v7.7.18 format). Fall back to
92
+ # tools_used (older episode format) if action_log is empty.
93
+ timeline: List[Dict[str, Any]] = []
94
+ action_log = episode.get("action_log") or []
95
+ if action_log:
96
+ for i, a in enumerate(action_log, 1):
97
+ if isinstance(a, dict):
98
+ timeline.append({
99
+ "step": i,
100
+ "tool": a.get("action", a.get("tool", "?")),
101
+ "target": a.get("target", a.get("input", "")),
102
+ "timestamp": a.get("t", a.get("timestamp", 0)),
103
+ })
104
+ else:
105
+ for i, t in enumerate(episode.get("tools_used", []) or [], 1):
106
+ timeline.append({"step": i, "tool": str(t), "target": "", "timestamp": 0})
107
+
108
+ # Current-state annotations for touched files.
109
+ touched = []
110
+ seen = set()
111
+ for f in (episode.get("files_modified") or []) + (episode.get("files_changed") or []):
112
+ if f and f not in seen:
113
+ seen.add(f)
114
+ touched.append(f)
115
+ files = []
116
+ missing = 0
117
+ changed = 0
118
+ for f in touched:
119
+ state = _file_changed_since(repo_root, f, ts)
120
+ if state == "missing":
121
+ missing += 1
122
+ elif state == "changed-since":
123
+ changed += 1
124
+ files.append({"path": f, "state": state})
125
+
126
+ return {
127
+ "episode_id": episode_id,
128
+ "found": True,
129
+ "timestamp": ts,
130
+ "goal": str(goal)[:500],
131
+ "outcome": episode.get("outcome", "unknown"),
132
+ "agent": episode.get("agent", "unknown"),
133
+ "timeline": timeline,
134
+ "files": files,
135
+ "summary": {
136
+ "steps": len(timeline),
137
+ "files_touched": len(touched),
138
+ "files_missing": missing,
139
+ "files_changed_since": changed,
140
+ },
141
+ "mode": "read-only (dry-run); --apply re-execution deferred to a future release",
142
+ "generated_at": datetime.now(timezone.utc).isoformat(),
143
+ }
144
+ except Exception as e:
145
+ return {"episode_id": episode_id, "found": False, "error": str(e)}
146
+
147
+
148
+ def render_markdown(report: Dict[str, Any]) -> str:
149
+ """Render a replay report dict as a human-readable Markdown timeline."""
150
+ if not report.get("found"):
151
+ return f"# Replay: {report.get('episode_id', '?')}\n\nNot found: {report.get('error', 'unknown error')}\n"
152
+ # v7.7.22 council fix (Opus 2): use .get() defaults throughout so a
153
+ # hand-built or partial report dict cannot KeyError. The CLI always
154
+ # passes a complete replay_episode() result, but render_markdown is
155
+ # public and may be called with sparse input.
156
+ lines = [
157
+ f"# Replay: {report.get('episode_id', '?')}",
158
+ "",
159
+ f"- Goal: {report.get('goal', '')}",
160
+ f"- Outcome: {report.get('outcome')}",
161
+ f"- Agent: {report.get('agent')}",
162
+ f"- Recorded: {report.get('timestamp')}",
163
+ f"- Mode: {report.get('mode')}",
164
+ "",
165
+ "## Timeline",
166
+ "",
167
+ ]
168
+ timeline = report.get("timeline") or []
169
+ if timeline:
170
+ for step in timeline:
171
+ lines.append(f"{step.get('step', '?')}. **{step.get('tool', '?')}** {step.get('target', '')}")
172
+ else:
173
+ lines.append("(no recorded actions)")
174
+ lines += ["", "## Files touched (current state)", ""]
175
+ files = report.get("files") or []
176
+ if files:
177
+ for f in files:
178
+ lines.append(f"- `{f.get('path', '?')}` -- {f.get('state', '?')}")
179
+ else:
180
+ lines.append("(none)")
181
+ s = report.get("summary") or {}
182
+ lines += [
183
+ "",
184
+ "## Summary",
185
+ "",
186
+ f"- Steps: {s['steps']}",
187
+ f"- Files touched: {s['files_touched']}",
188
+ f"- Now missing: {s['files_missing']}",
189
+ f"- Changed since episode: {s['files_changed_since']}",
190
+ "",
191
+ ]
192
+ return "\n".join(lines)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loki-mode",
3
- "version": "7.7.20",
3
+ "version": "7.7.22",
4
4
  "description": "Loki Mode by Autonomi. Multi-agent autonomous SDLC framework. Spec to deployed app: PRD, GitHub issue, OpenAPI/JSON/YAML, or one-line brief. 4 AI providers (Claude Code, OpenAI Codex, Cline, Aider). 11 quality gates.",
5
5
  "keywords": [
6
6
  "agent",