loki-mode 7.7.21 → 7.7.24
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/README.md +7 -5
- package/SKILL.md +4 -4
- package/VERSION +1 -1
- package/autonomy/loki +60 -0
- package/dashboard/__init__.py +1 -1
- package/docs/INSTALLATION.md +2 -2
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/mcp/server.py +6 -5
- package/memory/ingest.py +30 -9
- package/memory/knowledge_graph.py +40 -16
- package/memory/replay.py +192 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,15 +24,15 @@
|
|
|
24
24
|
|
|
25
25
|
## Why Loki Mode?
|
|
26
26
|
|
|
27
|
-
- **
|
|
27
|
+
- **Spec to product, autonomously** -- Describe what you want, walk away, come back to working code with tests. Loki runs the full RARV-C closure loop (Reason - Act - Reflect - Verify - Close) until the work is actually done, not just attempted.
|
|
28
28
|
- **Production quality built in** -- 11 quality gates (`skills/quality-gates.md`), blind 3-reviewer code review (`run.sh:run_code_review()`), anti-sycophancy checks
|
|
29
|
+
- **Cross-project memory** -- Episodic/semantic/procedural memory with vector search; knowledge learned on one project surfaces on the next (v5.15.0+, see `memory/engine.py`)
|
|
29
30
|
- **Self-hosted and private** -- Your keys, your infrastructure, no data leaves your network
|
|
30
|
-
- **4 active AI providers** -- Claude, Codex, Cline, Aider with automatic failover (`loki-ts/src/runner/providers.ts`). Gemini CLI deprecated v7.5.18; Antigravity CLI coming soon.
|
|
31
31
|
- **Legacy system healing** -- `loki heal` archaeology/stabilize/isolate/modernize/validate phases (v6.67.0, see `skills/healing.md`)
|
|
32
|
-
- **Memory system** -- Episodic/semantic/procedural with vector search (v5.15.0, see `memory/engine.py`)
|
|
33
32
|
- **MCP server** -- 15 tools including ChromaDB code search (`mcp/server.py`)
|
|
34
33
|
- **Full-stack output** -- Source code, tests, Docker configs, CI/CD pipelines, audit logs
|
|
35
|
-
- **
|
|
34
|
+
- **Provider-agnostic** -- runs on Claude, Codex, Cline, or Aider with automatic failover (`loki-ts/src/runner/providers.ts`); no vendor lock-in. Gemini CLI deprecated v7.5.18; Antigravity CLI coming soon.
|
|
35
|
+
- **Open source** -- Free for personal, internal, and academic use.
|
|
36
36
|
|
|
37
37
|
---
|
|
38
38
|
|
|
@@ -302,7 +302,9 @@ Loki Mode is the only platform that is fully self-hosted, open source, and inclu
|
|
|
302
302
|
|
|
303
303
|
---
|
|
304
304
|
|
|
305
|
-
##
|
|
305
|
+
## Provider-Agnostic Runtime
|
|
306
|
+
|
|
307
|
+
Loki's autonomy and quality loop are the product; the underlying coding CLI is swappable. Loki runs on any of the providers below so you are never locked to one vendor.
|
|
306
308
|
|
|
307
309
|
| Provider | Status | Autonomous Flag | Parallel Agents | Install |
|
|
308
310
|
|----------|--------|:-:|:-:|---------|
|
package/SKILL.md
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: loki-mode
|
|
3
|
-
description:
|
|
3
|
+
description: Autonomous spec-to-product system. Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product via the RARV-C closure loop, with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Loki Mode v7.7.
|
|
6
|
+
# Loki Mode v7.7.24
|
|
7
7
|
|
|
8
8
|
**You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
|
|
9
9
|
|
|
10
10
|
**Spec in, product out.** A "spec" is whatever describes the work: a Markdown PRD, a GitHub issue, an OpenAPI doc, a Jira ticket -- a PRD is one form of spec.
|
|
11
11
|
|
|
12
|
-
**
|
|
12
|
+
**Provider-agnostic (stable since v5.0.0):** runs on Claude/Codex/Cline/Aider with abstract model tiers and degraded mode for non-Claude providers; no vendor lock-in. Gemini deprecated v7.5.18. See `skills/providers.md`. **Current track (v7.7.x):** LSP grounding as first-class agent tool (v7.7.0-v7.7.9; lsp_get_diagnostics actually-returns-diagnostics regression fix v7.7.14), provider_source cli (v7.7.11-v7.7.12 bash/bun parity), Docker/bash-3.2 robustness (v7.7.13), audit chain cross-file verification fix (v7.7.15), Phase 1 RARV-C closure (real provider judges, gate-failure flock, synthetic PRD e2e, status `--json`).
|
|
13
13
|
|
|
14
14
|
**Runtime migration:** Bash-to-Bun migration. Read-only commands (`version`, `status`, `stats`, `doctor`, `provider show/list`, `memory list/index`) flow through Bun runtime via `bin/loki` since v7.3.0. Every other command remains on the Bash runtime (`autonomy/loki`). Rollback: `LOKI_LEGACY_BASH=1`. See `UPGRADING.md` and `docs/architecture/ADR-001-runtime-migration.md`.
|
|
15
15
|
|
|
@@ -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.
|
|
384
|
+
**v7.7.24 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
7.7.
|
|
1
|
+
7.7.24
|
package/autonomy/loki
CHANGED
|
@@ -299,6 +299,30 @@ load_memory_context() {
|
|
|
299
299
|
return 0
|
|
300
300
|
fi
|
|
301
301
|
|
|
302
|
+
# v7.7.23 privacy opt-out (excellence bar 6): honor a per-project
|
|
303
|
+
# .loki/config.json {"memory": {"disabled": true}} flag. Lets a user
|
|
304
|
+
# disable ALL memory capture/retrieval for a sensitive project without
|
|
305
|
+
# setting an env var on every invocation.
|
|
306
|
+
if [ -f "$LOKI_DIR/config.json" ]; then
|
|
307
|
+
# v7.7.23 council fix (Opus 2): FAIL-CLOSED. A config.json that
|
|
308
|
+
# exists but cannot be parsed prints 'true' (suppress memory) so
|
|
309
|
+
# a JSON typo on a sensitive project does not silently re-enable
|
|
310
|
+
# retrieval. Only the no-config case (outer -f guard false)
|
|
311
|
+
# proceeds with memory on.
|
|
312
|
+
local _mem_disabled
|
|
313
|
+
_mem_disabled=$(python3 -c "
|
|
314
|
+
import json, sys
|
|
315
|
+
try:
|
|
316
|
+
d = json.load(open('$LOKI_DIR/config.json'))
|
|
317
|
+
print('true' if d.get('memory', {}).get('disabled') is True else 'false')
|
|
318
|
+
except Exception:
|
|
319
|
+
print('true') # malformed config -> fail closed (suppress memory)
|
|
320
|
+
" 2>/dev/null || echo "true")
|
|
321
|
+
if [ "$_mem_disabled" = "true" ]; then
|
|
322
|
+
return 0
|
|
323
|
+
fi
|
|
324
|
+
fi
|
|
325
|
+
|
|
302
326
|
# Check if python3 is available (required for memory system)
|
|
303
327
|
if ! command -v python3 &> /dev/null; then
|
|
304
328
|
echo -e "${YELLOW}Warning: python3 not found - memory context loading disabled${NC}" >&2
|
|
@@ -15054,6 +15078,7 @@ except Exception as e:
|
|
|
15054
15078
|
echo ""
|
|
15055
15079
|
echo " loki memory ingest --from-claude-transcript <path> # v7.7.18"
|
|
15056
15080
|
echo " loki memory ingest --from-stdin # v7.7.18"
|
|
15081
|
+
echo " loki memory replay <episode-id> [--json] # v7.7.22"
|
|
15057
15082
|
echo " loki memory crossproject --for 'build api' # v7.7.20"
|
|
15058
15083
|
echo " loki memory graph --export graph.json # v7.7.20"
|
|
15059
15084
|
echo " loki memory graph rebuild # v7.7.20"
|
|
@@ -15138,6 +15163,41 @@ print(json.dumps({'episode_path': path}))
|
|
|
15138
15163
|
fi
|
|
15139
15164
|
;;
|
|
15140
15165
|
|
|
15166
|
+
replay)
|
|
15167
|
+
# v7.7.22 wow feature: READ-ONLY deterministic session replay.
|
|
15168
|
+
# Renders a past episode's action timeline + current state of
|
|
15169
|
+
# touched files. Does NOT re-execute (LLM tool_uses are
|
|
15170
|
+
# non-deterministic + re-running edits could clobber work;
|
|
15171
|
+
# --apply deferred to a future release).
|
|
15172
|
+
# Usage: loki memory replay <episode-id> [--json]
|
|
15173
|
+
shift # drop "replay"
|
|
15174
|
+
local _replay_id=""
|
|
15175
|
+
local _replay_json="false"
|
|
15176
|
+
while [ $# -gt 0 ]; do
|
|
15177
|
+
case "$1" in
|
|
15178
|
+
--json) _replay_json="true"; shift ;;
|
|
15179
|
+
-h|--help) echo "Usage: loki memory replay <episode-id> [--json]"; return 0 ;;
|
|
15180
|
+
*) [ -z "$_replay_id" ] && _replay_id="$1"; shift ;;
|
|
15181
|
+
esac
|
|
15182
|
+
done
|
|
15183
|
+
if [ -z "$_replay_id" ]; then
|
|
15184
|
+
echo -e "${RED}Usage: loki memory replay <episode-id> [--json]${NC}"
|
|
15185
|
+
exit 1
|
|
15186
|
+
fi
|
|
15187
|
+
local _replay_mem
|
|
15188
|
+
_replay_mem="$(pwd)/.loki/memory"
|
|
15189
|
+
PYTHONPATH="${SKILL_DIR:-$(pwd)}" _LOKI_REPLAY_JSON="$_replay_json" python3 -c "
|
|
15190
|
+
import sys, os, json
|
|
15191
|
+
from memory.replay import replay_episode, render_markdown
|
|
15192
|
+
report = replay_episode(sys.argv[1], sys.argv[2])
|
|
15193
|
+
if os.environ.get('_LOKI_REPLAY_JSON') == 'true':
|
|
15194
|
+
print(json.dumps(report, indent=2, default=str))
|
|
15195
|
+
else:
|
|
15196
|
+
print(render_markdown(report))
|
|
15197
|
+
sys.exit(0 if report.get('found') else 1)
|
|
15198
|
+
" "$_replay_id" "$_replay_mem"
|
|
15199
|
+
;;
|
|
15200
|
+
|
|
15141
15201
|
crossproject)
|
|
15142
15202
|
# v7.7.20: surface cross-project knowledge-graph patterns
|
|
15143
15203
|
# (wakes the previously-dead memory/knowledge_graph.py).
|
package/dashboard/__init__.py
CHANGED
package/docs/INSTALLATION.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The flagship product of [Autonomi](https://www.autonomi.dev/). Complete installation instructions for all platforms and use cases.
|
|
4
4
|
|
|
5
|
-
**Version:** v7.7.
|
|
5
|
+
**Version:** v7.7.24
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -32,7 +32,7 @@ setting any flag to `0`.
|
|
|
32
32
|
|
|
33
33
|
### Earlier highlights still in scope
|
|
34
34
|
- Bash-to-Bun runtime migration in progress (see `UPGRADING.md`)
|
|
35
|
-
-
|
|
35
|
+
- Provider-agnostic runtime: Claude (full), Codex, Cline, Aider (no vendor lock-in)
|
|
36
36
|
- Memory system (episodic / semantic / procedural)
|
|
37
37
|
- ChromaDB semantic code search via MCP
|
|
38
38
|
|
package/loki-ts/dist/loki.js
CHANGED
|
@@ -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.
|
|
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.24";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=
|
|
588
|
+
//# debugId=2B5B8BCEF68E54B364756E2164756E21
|
package/mcp/__init__.py
CHANGED
package/mcp/server.py
CHANGED
|
@@ -1010,17 +1010,18 @@ async def loki_memory_capture_session_summary(
|
|
|
1010
1010
|
try:
|
|
1011
1011
|
from memory.ingest import ingest_from_summary, _capture_disabled
|
|
1012
1012
|
|
|
1013
|
-
|
|
1013
|
+
base_path = safe_path_join('.loki', 'memory')
|
|
1014
|
+
# v7.7.23: pass base_path so the .loki/config.json memory.disabled
|
|
1015
|
+
# opt-out is honored in addition to the env escape hatch.
|
|
1016
|
+
if _capture_disabled(base_path):
|
|
1014
1017
|
_emit_tool_event_async(
|
|
1015
1018
|
'loki_memory_capture_session_summary', 'complete',
|
|
1016
|
-
result_status='skipped', error='disabled via env'
|
|
1019
|
+
result_status='skipped', error='disabled via env or config'
|
|
1017
1020
|
)
|
|
1018
1021
|
return json.dumps({
|
|
1019
|
-
"error": "memory capture disabled
|
|
1022
|
+
"error": "memory capture disabled (LOKI_MEMORY_CAPTURE_DISABLED or .loki/config.json memory.disabled)",
|
|
1020
1023
|
"disabled": True,
|
|
1021
1024
|
})
|
|
1022
|
-
|
|
1023
|
-
base_path = safe_path_join('.loki', 'memory')
|
|
1024
1025
|
path = ingest_from_summary(
|
|
1025
1026
|
base_path,
|
|
1026
1027
|
goal=goal,
|
package/memory/ingest.py
CHANGED
|
@@ -125,13 +125,34 @@ def _scrub_path(path: str) -> str:
|
|
|
125
125
|
return path
|
|
126
126
|
|
|
127
127
|
|
|
128
|
-
def _capture_disabled() -> bool:
|
|
129
|
-
"""
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
)
|
|
128
|
+
def _capture_disabled(memory_base: Optional[str] = None) -> bool:
|
|
129
|
+
"""True when capture should be skipped.
|
|
130
|
+
|
|
131
|
+
Honors the `LOKI_MEMORY_CAPTURE_DISABLED=true` env escape hatch AND
|
|
132
|
+
(v7.7.23 privacy opt-out, excellence bar 6) a per-project
|
|
133
|
+
`.loki/config.json` `{"memory": {"disabled": true}}` flag. When
|
|
134
|
+
`memory_base` is provided (e.g. `<root>/.loki/memory`), the config
|
|
135
|
+
is resolved as its sibling `<root>/.loki/config.json`.
|
|
136
|
+
"""
|
|
137
|
+
if os.environ.get("LOKI_MEMORY_CAPTURE_DISABLED", "").lower() in ("true", "1", "yes"):
|
|
138
|
+
return True
|
|
139
|
+
if memory_base:
|
|
140
|
+
# v7.7.23 council fix (Opus 2): FAIL-CLOSED. If a config.json
|
|
141
|
+
# EXISTS but cannot be parsed, the user's intent is ambiguous and
|
|
142
|
+
# the safe privacy default is to SUPPRESS capture (leaked data is
|
|
143
|
+
# irreversible; lost memory is recoverable). Only the no-config
|
|
144
|
+
# case fails open (capture proceeds = default behavior).
|
|
145
|
+
config_path = Path(memory_base).parent / "config.json"
|
|
146
|
+
if config_path.is_file():
|
|
147
|
+
try:
|
|
148
|
+
with open(config_path) as f:
|
|
149
|
+
cfg = json.load(f)
|
|
150
|
+
except Exception:
|
|
151
|
+
# Config present but unreadable/malformed -> fail closed.
|
|
152
|
+
return True
|
|
153
|
+
if isinstance(cfg, dict) and cfg.get("memory", {}).get("disabled") is True:
|
|
154
|
+
return True
|
|
155
|
+
return False
|
|
135
156
|
|
|
136
157
|
|
|
137
158
|
def _log_to_errors(memory_base: str, function_name: str, exc: BaseException) -> None:
|
|
@@ -301,7 +322,7 @@ def ingest_from_claude_transcript(
|
|
|
301
322
|
Path to the written episode JSON on success; None on failure
|
|
302
323
|
(silent fail, error logged to `.errors.log`).
|
|
303
324
|
"""
|
|
304
|
-
if _capture_disabled():
|
|
325
|
+
if _capture_disabled(memory_base):
|
|
305
326
|
return None
|
|
306
327
|
try:
|
|
307
328
|
path = Path(transcript_path)
|
|
@@ -409,7 +430,7 @@ def ingest_from_summary(
|
|
|
409
430
|
|
|
410
431
|
Returns episode path on success, None on failure.
|
|
411
432
|
"""
|
|
412
|
-
if _capture_disabled():
|
|
433
|
+
if _capture_disabled(memory_base):
|
|
413
434
|
return None
|
|
414
435
|
try:
|
|
415
436
|
from memory.engine import MemoryEngine, create_storage
|
|
@@ -167,30 +167,54 @@ class OrganizationKnowledgeGraph:
|
|
|
167
167
|
self._graph = json.load(f)
|
|
168
168
|
return self._graph
|
|
169
169
|
|
|
170
|
+
_STOPWORDS = {
|
|
171
|
+
'the', 'a', 'an', 'to', 'for', 'of', 'and', 'or', 'with', 'without',
|
|
172
|
+
'is', 'are', 'be', 'up', 'on', 'in', 'by', 'not', 'make', 'choose',
|
|
173
|
+
'store', 'how', 'do', 'i', 'we', 'my', 'our', 'this', 'that',
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
@classmethod
|
|
177
|
+
def _tokenize(cls, text):
|
|
178
|
+
"""Lowercase, split on non-alphanumerics, drop stopwords + 1-2 char tokens."""
|
|
179
|
+
import re
|
|
180
|
+
toks = re.split(r'[^a-z0-9]+', str(text or '').lower())
|
|
181
|
+
return {t for t in toks if len(t) > 2 and t not in cls._STOPWORDS}
|
|
182
|
+
|
|
170
183
|
def query_patterns(self, query, max_results=10):
|
|
171
|
-
"""
|
|
184
|
+
"""Keyword search across stored patterns.
|
|
185
|
+
|
|
186
|
+
Scores by token overlap between the query and each pattern's
|
|
187
|
+
name/pattern/description/category fields. Token overlap (not
|
|
188
|
+
whole-string substring) lets natural-language goals like "make
|
|
189
|
+
the charge endpoint safe to retry" match a pattern named
|
|
190
|
+
"idempotency-key-on-charge". A full-string substring hit still
|
|
191
|
+
gets a bonus, preserving prior behavior for exact queries.
|
|
172
192
|
|
|
173
|
-
Searches
|
|
174
|
-
|
|
175
|
-
schema.
|
|
193
|
+
Searches 'name', 'pattern', 'description', and 'category' for
|
|
194
|
+
compatibility with both simple dicts and the SemanticPattern schema.
|
|
176
195
|
"""
|
|
177
196
|
patterns = self.load_patterns(limit=1000)
|
|
178
197
|
query_lower = query.lower()
|
|
198
|
+
query_tokens = self._tokenize(query)
|
|
179
199
|
scored = []
|
|
200
|
+
# Per-field weight applied to each overlapping token.
|
|
201
|
+
fields = (('name', 3), ('pattern', 3), ('category', 2), ('description', 1))
|
|
180
202
|
for p in patterns:
|
|
181
203
|
score = 0
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
204
|
+
for field, weight in fields:
|
|
205
|
+
value = p.get(field, '')
|
|
206
|
+
if not value:
|
|
207
|
+
continue
|
|
208
|
+
# Coerce non-string field values (a hand-edited or
|
|
209
|
+
# future-schema JSONL row could store a list/int) so we
|
|
210
|
+
# never crash on .lower() in the live retrieval path.
|
|
211
|
+
value_lower = str(value).lower()
|
|
212
|
+
# Whole-query substring bonus (preserves exact-match behavior).
|
|
213
|
+
if query_lower and query_lower in value_lower:
|
|
214
|
+
score += weight
|
|
215
|
+
# Token-overlap scoring (enables NL-goal retrieval).
|
|
216
|
+
overlap = query_tokens & self._tokenize(value)
|
|
217
|
+
score += weight * len(overlap)
|
|
194
218
|
if score > 0:
|
|
195
219
|
scored.append((score, p))
|
|
196
220
|
scored.sort(key=lambda x: -x[0])
|
package/memory/replay.py
ADDED
|
@@ -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.
|
|
3
|
+
"version": "7.7.24",
|
|
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",
|