loki-mode 6.13.1 → 6.15.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/README.md +1 -1
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/loki +221 -0
- package/autonomy/run.sh +84 -8
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +193 -5
- package/docs/INSTALLATION.md +1 -1
- package/mcp/__init__.py +1 -1
- package/mcp/server.py +259 -0
- package/memory/__init__.py +8 -0
- package/memory/engine.py +21 -0
- package/package.json +1 -1
package/README.md
CHANGED
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: loki-mode
|
|
|
3
3
|
description: Multi-agent autonomous startup system. Triggers on "Loki Mode". Takes PRD to deployed product with minimal human intervention. Requires --dangerously-skip-permissions flag.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Loki Mode v6.
|
|
6
|
+
# Loki Mode v6.15.0
|
|
7
7
|
|
|
8
8
|
**You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
|
|
9
9
|
|
|
@@ -267,4 +267,4 @@ The following features are documented in skill modules but not yet fully automat
|
|
|
267
267
|
| Quality gates 3-reviewer system | Implemented (v5.35.0) | 5 specialist reviewers in `skills/quality-gates.md`; execution in run.sh |
|
|
268
268
|
| Benchmarks (HumanEval, SWE-bench) | Infrastructure only | Runner scripts and datasets exist in `benchmarks/`; no published results |
|
|
269
269
|
|
|
270
|
-
**v6.
|
|
270
|
+
**v6.15.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
6.
|
|
1
|
+
6.15.0
|
package/autonomy/loki
CHANGED
|
@@ -419,6 +419,7 @@ show_help() {
|
|
|
419
419
|
echo " checkpoint|cp Save/restore session checkpoints"
|
|
420
420
|
echo " projects Multi-project registry management"
|
|
421
421
|
echo " audit [cmd] Agent audit log and quality scanning (log|scan)"
|
|
422
|
+
echo " review [dir] Run quality gates on any project (standalone, no AI needed)"
|
|
422
423
|
echo " optimize Optimize prompts based on session history"
|
|
423
424
|
echo " enterprise Enterprise feature management (tokens, OIDC)"
|
|
424
425
|
echo " metrics Prometheus/OpenMetrics metrics from dashboard"
|
|
@@ -7848,6 +7849,9 @@ main() {
|
|
|
7848
7849
|
audit)
|
|
7849
7850
|
cmd_audit "$@"
|
|
7850
7851
|
;;
|
|
7852
|
+
review)
|
|
7853
|
+
cmd_review "$@"
|
|
7854
|
+
;;
|
|
7851
7855
|
optimize)
|
|
7852
7856
|
cmd_optimize "$@"
|
|
7853
7857
|
;;
|
|
@@ -7895,6 +7899,223 @@ main() {
|
|
|
7895
7899
|
esac
|
|
7896
7900
|
}
|
|
7897
7901
|
|
|
7902
|
+
# Standalone project review - run quality gates on any codebase (v6.14.0)
|
|
7903
|
+
cmd_review() {
|
|
7904
|
+
local target_dir="."
|
|
7905
|
+
local json_output=""
|
|
7906
|
+
local verbose=""
|
|
7907
|
+
|
|
7908
|
+
while [[ $# -gt 0 ]]; do
|
|
7909
|
+
case "$1" in
|
|
7910
|
+
--json) json_output="true"; shift ;;
|
|
7911
|
+
--verbose|-v) verbose="true"; shift ;;
|
|
7912
|
+
--help|-h)
|
|
7913
|
+
echo -e "${BOLD}loki review${NC} - Run quality gates on any project (standalone)"
|
|
7914
|
+
echo ""
|
|
7915
|
+
echo "Usage: loki review [directory] [options]"
|
|
7916
|
+
echo ""
|
|
7917
|
+
echo "Arguments:"
|
|
7918
|
+
echo " directory Path to project (default: current directory)"
|
|
7919
|
+
echo ""
|
|
7920
|
+
echo "Options:"
|
|
7921
|
+
echo " --json Machine-readable JSON output"
|
|
7922
|
+
echo " --verbose, -v Show detailed output per gate"
|
|
7923
|
+
echo " --help, -h Show this help"
|
|
7924
|
+
echo ""
|
|
7925
|
+
echo "Gates: project-type, lint, tests, security, dependencies, structure"
|
|
7926
|
+
echo "Exit code: 0 if all pass, 1 if any fail"
|
|
7927
|
+
return 0
|
|
7928
|
+
;;
|
|
7929
|
+
-*) echo -e "${RED}Unknown option: $1${NC}"; return 1 ;;
|
|
7930
|
+
*) target_dir="$1"; shift ;;
|
|
7931
|
+
esac
|
|
7932
|
+
done
|
|
7933
|
+
|
|
7934
|
+
# Resolve and validate directory
|
|
7935
|
+
target_dir="$(cd "$target_dir" 2>/dev/null && pwd)" || {
|
|
7936
|
+
echo -e "${RED}Error: Directory not found: $target_dir${NC}"; return 1
|
|
7937
|
+
}
|
|
7938
|
+
|
|
7939
|
+
local project_type="unknown"
|
|
7940
|
+
local results=() # gate:status pairs
|
|
7941
|
+
local total_pass=0 total_warn=0 total_fail=0
|
|
7942
|
+
local gate_details=()
|
|
7943
|
+
|
|
7944
|
+
# Helper: record gate result
|
|
7945
|
+
_review_gate() {
|
|
7946
|
+
local gate="$1" status="$2" detail="${3:-}"
|
|
7947
|
+
results+=("$gate:$status")
|
|
7948
|
+
gate_details+=("$gate:$detail")
|
|
7949
|
+
case "$status" in
|
|
7950
|
+
PASS) total_pass=$((total_pass + 1)) ;;
|
|
7951
|
+
WARN) total_warn=$((total_warn + 1)) ;;
|
|
7952
|
+
FAIL) total_fail=$((total_fail + 1)) ;;
|
|
7953
|
+
esac
|
|
7954
|
+
}
|
|
7955
|
+
|
|
7956
|
+
# Gate 1: Detect project type
|
|
7957
|
+
if [ -f "$target_dir/package.json" ]; then
|
|
7958
|
+
project_type="node"
|
|
7959
|
+
elif [ -f "$target_dir/pyproject.toml" ] || [ -f "$target_dir/setup.py" ] || [ -f "$target_dir/requirements.txt" ]; then
|
|
7960
|
+
project_type="python"
|
|
7961
|
+
elif [ -f "$target_dir/go.mod" ]; then
|
|
7962
|
+
project_type="go"
|
|
7963
|
+
elif [ -f "$target_dir/Cargo.toml" ]; then
|
|
7964
|
+
project_type="rust"
|
|
7965
|
+
fi
|
|
7966
|
+
if [ "$project_type" != "unknown" ]; then
|
|
7967
|
+
_review_gate "project-type" "PASS" "Detected: $project_type"
|
|
7968
|
+
else
|
|
7969
|
+
_review_gate "project-type" "WARN" "Could not detect project type"
|
|
7970
|
+
fi
|
|
7971
|
+
|
|
7972
|
+
# Gate 2: Lint check
|
|
7973
|
+
local lint_output="" lint_status="PASS"
|
|
7974
|
+
case "$project_type" in
|
|
7975
|
+
node)
|
|
7976
|
+
if [ -f "$target_dir/.eslintrc.js" ] || [ -f "$target_dir/.eslintrc.json" ] || [ -f "$target_dir/.eslintrc.yml" ] || [ -f "$target_dir/eslint.config.js" ] || [ -f "$target_dir/eslint.config.mjs" ]; then
|
|
7977
|
+
lint_output=$(cd "$target_dir" && npx eslint . --max-warnings=0 2>&1) || lint_status="FAIL"
|
|
7978
|
+
else
|
|
7979
|
+
lint_output="No ESLint config found"; lint_status="WARN"
|
|
7980
|
+
fi ;;
|
|
7981
|
+
python)
|
|
7982
|
+
if command -v ruff &>/dev/null; then
|
|
7983
|
+
lint_output=$(cd "$target_dir" && ruff check . 2>&1) || lint_status="FAIL"
|
|
7984
|
+
elif command -v pylint &>/dev/null; then
|
|
7985
|
+
lint_output=$(cd "$target_dir" && pylint --recursive=y . 2>&1) || lint_status="FAIL"
|
|
7986
|
+
else
|
|
7987
|
+
lint_output="No linter available (install ruff or pylint)"; lint_status="WARN"
|
|
7988
|
+
fi ;;
|
|
7989
|
+
go)
|
|
7990
|
+
if command -v golangci-lint &>/dev/null; then
|
|
7991
|
+
lint_output=$(cd "$target_dir" && golangci-lint run 2>&1) || lint_status="FAIL"
|
|
7992
|
+
else
|
|
7993
|
+
lint_output=$(cd "$target_dir" && go vet ./... 2>&1) || lint_status="FAIL"
|
|
7994
|
+
fi ;;
|
|
7995
|
+
rust)
|
|
7996
|
+
lint_output=$(cd "$target_dir" && cargo clippy -- -D warnings 2>&1) || lint_status="FAIL"
|
|
7997
|
+
;;
|
|
7998
|
+
*) lint_output="Skipped (unknown project type)"; lint_status="WARN" ;;
|
|
7999
|
+
esac
|
|
8000
|
+
_review_gate "lint" "$lint_status" "$lint_output"
|
|
8001
|
+
|
|
8002
|
+
# Gate 3: Tests
|
|
8003
|
+
local test_output="" test_status="PASS"
|
|
8004
|
+
case "$project_type" in
|
|
8005
|
+
node)
|
|
8006
|
+
if grep -q '"test"' "$target_dir/package.json" 2>/dev/null; then
|
|
8007
|
+
test_output=$(cd "$target_dir" && npm test 2>&1) || test_status="FAIL"
|
|
8008
|
+
else
|
|
8009
|
+
test_output="No test script in package.json"; test_status="WARN"
|
|
8010
|
+
fi ;;
|
|
8011
|
+
python)
|
|
8012
|
+
if command -v pytest &>/dev/null; then
|
|
8013
|
+
test_output=$(cd "$target_dir" && pytest --tb=short 2>&1) || test_status="FAIL"
|
|
8014
|
+
else
|
|
8015
|
+
test_output="pytest not available"; test_status="WARN"
|
|
8016
|
+
fi ;;
|
|
8017
|
+
go) test_output=$(cd "$target_dir" && go test ./... 2>&1) || test_status="FAIL" ;;
|
|
8018
|
+
rust) test_output=$(cd "$target_dir" && cargo test 2>&1) || test_status="FAIL" ;;
|
|
8019
|
+
*) test_output="Skipped (unknown project type)"; test_status="WARN" ;;
|
|
8020
|
+
esac
|
|
8021
|
+
_review_gate "tests" "$test_status" "$test_output"
|
|
8022
|
+
|
|
8023
|
+
# Gate 4: Security - grep for hardcoded secrets
|
|
8024
|
+
local secret_output="" secret_status="PASS"
|
|
8025
|
+
local secret_patterns='(API_KEY|SECRET_KEY|PASSWORD|TOKEN|PRIVATE_KEY)\s*[=:]\s*["\x27][A-Za-z0-9+/=_-]{8,}'
|
|
8026
|
+
secret_output=$(grep -rEn "$secret_patterns" "$target_dir" \
|
|
8027
|
+
--include="*.js" --include="*.ts" --include="*.py" --include="*.go" --include="*.rs" \
|
|
8028
|
+
--include="*.jsx" --include="*.tsx" --include="*.java" --include="*.rb" \
|
|
8029
|
+
--exclude-dir=node_modules --exclude-dir=.git --exclude-dir=vendor \
|
|
8030
|
+
--exclude-dir=__pycache__ --exclude-dir=.venv --exclude-dir=target 2>/dev/null) || true
|
|
8031
|
+
if [ -n "$secret_output" ]; then
|
|
8032
|
+
secret_status="FAIL"
|
|
8033
|
+
secret_output="Potential hardcoded secrets found:
|
|
8034
|
+
$secret_output"
|
|
8035
|
+
else
|
|
8036
|
+
secret_output="No hardcoded secrets detected"
|
|
8037
|
+
fi
|
|
8038
|
+
_review_gate "security" "$secret_status" "$secret_output"
|
|
8039
|
+
|
|
8040
|
+
# Gate 5: Dependency audit
|
|
8041
|
+
local dep_output="" dep_status="PASS"
|
|
8042
|
+
case "$project_type" in
|
|
8043
|
+
node)
|
|
8044
|
+
if [ -f "$target_dir/package-lock.json" ] || [ -f "$target_dir/yarn.lock" ]; then
|
|
8045
|
+
dep_output=$(cd "$target_dir" && npm audit --production 2>&1) || dep_status="WARN"
|
|
8046
|
+
else
|
|
8047
|
+
dep_output="No lockfile found"; dep_status="WARN"
|
|
8048
|
+
fi ;;
|
|
8049
|
+
python)
|
|
8050
|
+
if command -v pip-audit &>/dev/null; then
|
|
8051
|
+
dep_output=$(cd "$target_dir" && pip-audit 2>&1) || dep_status="WARN"
|
|
8052
|
+
else
|
|
8053
|
+
dep_output="pip-audit not available"; dep_status="WARN"
|
|
8054
|
+
fi ;;
|
|
8055
|
+
*) dep_output="No dependency audit available for $project_type"; dep_status="WARN" ;;
|
|
8056
|
+
esac
|
|
8057
|
+
_review_gate "dependencies" "$dep_status" "$dep_output"
|
|
8058
|
+
|
|
8059
|
+
# Gate 6: Structure check
|
|
8060
|
+
local struct_output="" struct_status="PASS" struct_issues=()
|
|
8061
|
+
[ ! -f "$target_dir/README.md" ] && [ ! -f "$target_dir/README.rst" ] && [ ! -f "$target_dir/README" ] && struct_issues+=("Missing README")
|
|
8062
|
+
local file_count
|
|
8063
|
+
file_count=$(find "$target_dir" -type f -not -path '*/.git/*' -not -path '*/node_modules/*' -not -path '*/vendor/*' -not -path '*/__pycache__/*' -not -path '*/.venv/*' -not -path '*/target/*' 2>/dev/null | wc -l | tr -d ' ')
|
|
8064
|
+
[ "$file_count" -gt 5000 ] && struct_issues+=("Large project: $file_count files")
|
|
8065
|
+
local huge_files
|
|
8066
|
+
huge_files=$(find "$target_dir" -type f -size +1M -not -path '*/.git/*' -not -path '*/node_modules/*' -not -path '*/vendor/*' -not -path '*/target/*' 2>/dev/null | head -5)
|
|
8067
|
+
[ -n "$huge_files" ] && struct_issues+=("Files >1MB found: $(echo "$huge_files" | wc -l | tr -d ' ')")
|
|
8068
|
+
if [ ${#struct_issues[@]} -gt 0 ]; then
|
|
8069
|
+
struct_status="WARN"
|
|
8070
|
+
struct_output=$(printf '%s\n' "${struct_issues[@]}")
|
|
8071
|
+
else
|
|
8072
|
+
struct_output="README present, $file_count files, no oversized files"
|
|
8073
|
+
fi
|
|
8074
|
+
_review_gate "structure" "$struct_status" "$struct_output"
|
|
8075
|
+
|
|
8076
|
+
# Output results
|
|
8077
|
+
if [ -n "$json_output" ]; then
|
|
8078
|
+
local gates_json="["
|
|
8079
|
+
local first="true"
|
|
8080
|
+
for r in "${results[@]}"; do
|
|
8081
|
+
local gate="${r%%:*}" status="${r#*:}"
|
|
8082
|
+
[ "$first" = "true" ] && first="" || gates_json+=","
|
|
8083
|
+
gates_json+="{\"gate\":\"$gate\",\"status\":\"$status\"}"
|
|
8084
|
+
done
|
|
8085
|
+
gates_json+="]"
|
|
8086
|
+
printf '{"directory":"%s","project_type":"%s","pass":%d,"warn":%d,"fail":%d,"gates":%s}\n' \
|
|
8087
|
+
"$target_dir" "$project_type" "$total_pass" "$total_warn" "$total_fail" "$gates_json"
|
|
8088
|
+
else
|
|
8089
|
+
echo -e "${BOLD}Loki Review: $target_dir${NC}"
|
|
8090
|
+
echo -e "Project type: ${CYAN}$project_type${NC}"
|
|
8091
|
+
echo "---"
|
|
8092
|
+
for r in "${results[@]}"; do
|
|
8093
|
+
local gate="${r%%:*}" status="${r#*:}"
|
|
8094
|
+
case "$status" in
|
|
8095
|
+
PASS) echo -e " ${GREEN}[PASS]${NC} $gate" ;;
|
|
8096
|
+
WARN) echo -e " ${YELLOW}[WARN]${NC} $gate" ;;
|
|
8097
|
+
FAIL) echo -e " ${RED}[FAIL]${NC} $gate" ;;
|
|
8098
|
+
esac
|
|
8099
|
+
done
|
|
8100
|
+
echo "---"
|
|
8101
|
+
echo -e "Results: ${GREEN}$total_pass passed${NC}, ${YELLOW}$total_warn warnings${NC}, ${RED}$total_fail failed${NC}"
|
|
8102
|
+
|
|
8103
|
+
if [ -n "$verbose" ]; then
|
|
8104
|
+
echo ""
|
|
8105
|
+
echo -e "${BOLD}Details:${NC}"
|
|
8106
|
+
for d in "${gate_details[@]}"; do
|
|
8107
|
+
local gate="${d%%:*}" detail="${d#*:}"
|
|
8108
|
+
echo -e "\n${CYAN}[$gate]${NC}"
|
|
8109
|
+
echo "$detail" | head -30
|
|
8110
|
+
done
|
|
8111
|
+
fi
|
|
8112
|
+
fi
|
|
8113
|
+
|
|
8114
|
+
# Exit code: 1 if any failures
|
|
8115
|
+
[ "$total_fail" -gt 0 ] && return 1
|
|
8116
|
+
return 0
|
|
8117
|
+
}
|
|
8118
|
+
|
|
7898
8119
|
# Worktree management (v6.7.0)
|
|
7899
8120
|
cmd_worktree() {
|
|
7900
8121
|
local subcommand="${1:-list}"
|
package/autonomy/run.sh
CHANGED
|
@@ -7267,6 +7267,83 @@ except Exception as e:
|
|
|
7267
7267
|
PYEOF
|
|
7268
7268
|
}
|
|
7269
7269
|
|
|
7270
|
+
# Automatic episode capture with enriched context (v6.15.0)
|
|
7271
|
+
# Captures git changes, files modified, and RARV phase automatically
|
|
7272
|
+
# after every iteration -- no manual invocation needed.
|
|
7273
|
+
auto_capture_episode() {
|
|
7274
|
+
local iteration="$1"
|
|
7275
|
+
local exit_code="$2"
|
|
7276
|
+
local rarv_phase="$3"
|
|
7277
|
+
local goal="$4"
|
|
7278
|
+
local duration="$5"
|
|
7279
|
+
local log_file="$6"
|
|
7280
|
+
local target_dir="${TARGET_DIR:-.}"
|
|
7281
|
+
|
|
7282
|
+
# Only capture if memory system exists
|
|
7283
|
+
if [ ! -d "$target_dir/.loki/memory" ]; then
|
|
7284
|
+
return
|
|
7285
|
+
fi
|
|
7286
|
+
|
|
7287
|
+
# Collect git context: files modified in this iteration
|
|
7288
|
+
local files_modified=""
|
|
7289
|
+
files_modified=$(cd "$target_dir" && git diff --name-only HEAD 2>/dev/null | head -20 | tr '\n' '|' || true)
|
|
7290
|
+
|
|
7291
|
+
# Collect last git commit if any
|
|
7292
|
+
local git_commit=""
|
|
7293
|
+
git_commit=$(cd "$target_dir" && git rev-parse --short HEAD 2>/dev/null || true)
|
|
7294
|
+
|
|
7295
|
+
# Determine outcome
|
|
7296
|
+
local outcome="success"
|
|
7297
|
+
if [ "$exit_code" -ne 0 ]; then
|
|
7298
|
+
outcome="failure"
|
|
7299
|
+
fi
|
|
7300
|
+
|
|
7301
|
+
# Pass all context via environment variables (prevents injection)
|
|
7302
|
+
_LOKI_PROJECT_DIR="$PROJECT_DIR" _LOKI_TARGET_DIR="$target_dir" \
|
|
7303
|
+
_LOKI_ITERATION="$iteration" _LOKI_EXIT_CODE="$exit_code" \
|
|
7304
|
+
_LOKI_RARV_PHASE="$rarv_phase" _LOKI_GOAL="$goal" \
|
|
7305
|
+
_LOKI_DURATION="$duration" _LOKI_OUTCOME="$outcome" \
|
|
7306
|
+
_LOKI_FILES_MODIFIED="$files_modified" _LOKI_GIT_COMMIT="$git_commit" \
|
|
7307
|
+
python3 << 'PYEOF' 2>/dev/null || true
|
|
7308
|
+
import sys
|
|
7309
|
+
import os
|
|
7310
|
+
|
|
7311
|
+
project_dir = os.environ.get('_LOKI_PROJECT_DIR', '')
|
|
7312
|
+
target_dir = os.environ.get('_LOKI_TARGET_DIR', '.')
|
|
7313
|
+
iteration = os.environ.get('_LOKI_ITERATION', '0')
|
|
7314
|
+
rarv_phase = os.environ.get('_LOKI_RARV_PHASE', 'iteration')
|
|
7315
|
+
goal = os.environ.get('_LOKI_GOAL', '')
|
|
7316
|
+
duration = os.environ.get('_LOKI_DURATION', '0')
|
|
7317
|
+
outcome = os.environ.get('_LOKI_OUTCOME', 'success')
|
|
7318
|
+
files_modified = os.environ.get('_LOKI_FILES_MODIFIED', '')
|
|
7319
|
+
git_commit = os.environ.get('_LOKI_GIT_COMMIT', '')
|
|
7320
|
+
|
|
7321
|
+
sys.path.insert(0, project_dir)
|
|
7322
|
+
try:
|
|
7323
|
+
from memory.engine import MemoryEngine, create_storage
|
|
7324
|
+
from memory.schemas import EpisodeTrace
|
|
7325
|
+
|
|
7326
|
+
storage = create_storage(f'{target_dir}/.loki/memory')
|
|
7327
|
+
engine = MemoryEngine(storage=storage, base_path=f'{target_dir}/.loki/memory')
|
|
7328
|
+
engine.initialize()
|
|
7329
|
+
|
|
7330
|
+
trace = EpisodeTrace.create(
|
|
7331
|
+
task_id=f'iteration-{iteration}',
|
|
7332
|
+
agent='loki-orchestrator',
|
|
7333
|
+
phase=rarv_phase.upper() if rarv_phase else 'ACT',
|
|
7334
|
+
goal=goal,
|
|
7335
|
+
)
|
|
7336
|
+
trace.outcome = outcome
|
|
7337
|
+
trace.duration_seconds = int(duration) if duration.isdigit() else 0
|
|
7338
|
+
trace.git_commit = git_commit if git_commit else None
|
|
7339
|
+
trace.files_modified = [f for f in files_modified.split('|') if f] if files_modified else []
|
|
7340
|
+
|
|
7341
|
+
engine.store_episode(trace)
|
|
7342
|
+
except Exception:
|
|
7343
|
+
pass # Silently fail -- memory capture must never break the loop
|
|
7344
|
+
PYEOF
|
|
7345
|
+
}
|
|
7346
|
+
|
|
7270
7347
|
# Run memory consolidation pipeline
|
|
7271
7348
|
run_memory_consolidation() {
|
|
7272
7349
|
local target_dir="${TARGET_DIR:-.}"
|
|
@@ -8614,13 +8691,15 @@ if __name__ == "__main__":
|
|
|
8614
8691
|
fi
|
|
8615
8692
|
fi
|
|
8616
8693
|
|
|
8694
|
+
# Automatic episode capture after every RARV iteration (v6.15.0)
|
|
8695
|
+
# Captures RARV phase, git changes, and iteration context automatically
|
|
8696
|
+
auto_capture_episode "$ITERATION_COUNT" "$exit_code" "${rarv_phase:-iteration}" \
|
|
8697
|
+
"${prd_path:-codebase-analysis}" "$duration" "$log_file"
|
|
8698
|
+
|
|
8617
8699
|
# Check for success - ONLY stop on explicit completion promise
|
|
8618
8700
|
# There's never a "complete" product - always improvements, bugs, features
|
|
8619
8701
|
if [ $exit_code -eq 0 ]; then
|
|
8620
|
-
#
|
|
8621
|
-
local task_id="iteration-$ITERATION_COUNT"
|
|
8622
|
-
local goal_desc="${prd_path:-codebase-analysis}"
|
|
8623
|
-
store_episode_trace "$task_id" "success" "iteration" "$goal_desc" "$duration"
|
|
8702
|
+
# Episode trace already captured by auto_capture_episode above (v6.15.0)
|
|
8624
8703
|
|
|
8625
8704
|
# Track iteration for Completion Council convergence detection
|
|
8626
8705
|
if type council_track_iteration &>/dev/null; then
|
|
@@ -8673,10 +8752,7 @@ if __name__ == "__main__":
|
|
|
8673
8752
|
fi
|
|
8674
8753
|
|
|
8675
8754
|
# Only apply retry logic for ERRORS (non-zero exit code)
|
|
8676
|
-
#
|
|
8677
|
-
local task_id="iteration-$ITERATION_COUNT"
|
|
8678
|
-
local goal_desc="${prd_path:-codebase-analysis}"
|
|
8679
|
-
store_episode_trace "$task_id" "failure" "iteration" "$goal_desc" "$duration"
|
|
8755
|
+
# Episode trace already captured by auto_capture_episode above (v6.15.0)
|
|
8680
8756
|
|
|
8681
8757
|
# Checkpoint failed iteration state (v5.57.0)
|
|
8682
8758
|
create_checkpoint "iteration-${ITERATION_COUNT} failed (exit=$exit_code)" "iteration-${ITERATION_COUNT}-fail"
|
package/dashboard/__init__.py
CHANGED
package/dashboard/server.py
CHANGED
|
@@ -1622,15 +1622,51 @@ def _sanitize_agent_id(agent_id: str) -> str:
|
|
|
1622
1622
|
@app.get("/api/memory/summary")
|
|
1623
1623
|
async def get_memory_summary():
|
|
1624
1624
|
"""Get memory system summary from .loki/memory/."""
|
|
1625
|
+
# Try SQLite backend first for accurate counts
|
|
1626
|
+
storage = _get_memory_storage()
|
|
1627
|
+
if storage is not None:
|
|
1628
|
+
try:
|
|
1629
|
+
stats = storage.get_stats()
|
|
1630
|
+
summary = {
|
|
1631
|
+
"episodic": {"count": stats.get("episode_count", 0), "latestDate": None},
|
|
1632
|
+
"semantic": {"patterns": stats.get("pattern_count", 0), "antiPatterns": 0},
|
|
1633
|
+
"procedural": {"skills": stats.get("skill_count", 0)},
|
|
1634
|
+
"backend": "sqlite",
|
|
1635
|
+
}
|
|
1636
|
+
# Get latest episode date
|
|
1637
|
+
episode_ids = storage.list_episodes(limit=1)
|
|
1638
|
+
if episode_ids:
|
|
1639
|
+
ep = storage.load_episode(episode_ids[0])
|
|
1640
|
+
if ep:
|
|
1641
|
+
summary["episodic"]["latestDate"] = ep.get("timestamp", "")
|
|
1642
|
+
# Token economics from JSON (not in SQLite)
|
|
1643
|
+
econ_file = _get_loki_dir() / "memory" / "token_economics.json"
|
|
1644
|
+
if econ_file.exists():
|
|
1645
|
+
try:
|
|
1646
|
+
econ = json.loads(econ_file.read_text())
|
|
1647
|
+
summary["tokenEconomics"] = {
|
|
1648
|
+
"discoveryTokens": econ.get("discoveryTokens", 0),
|
|
1649
|
+
"readTokens": econ.get("readTokens", 0),
|
|
1650
|
+
"savingsPercent": econ.get("savingsPercent", 0),
|
|
1651
|
+
}
|
|
1652
|
+
except Exception:
|
|
1653
|
+
summary["tokenEconomics"] = {"discoveryTokens": 0, "readTokens": 0, "savingsPercent": 0}
|
|
1654
|
+
else:
|
|
1655
|
+
summary["tokenEconomics"] = {"discoveryTokens": 0, "readTokens": 0, "savingsPercent": 0}
|
|
1656
|
+
return summary
|
|
1657
|
+
except Exception:
|
|
1658
|
+
pass
|
|
1659
|
+
|
|
1660
|
+
# Fallback to JSON file-based counts
|
|
1625
1661
|
memory_dir = _get_loki_dir() / "memory"
|
|
1626
1662
|
summary = {
|
|
1627
1663
|
"episodic": {"count": 0, "latestDate": None},
|
|
1628
1664
|
"semantic": {"patterns": 0, "antiPatterns": 0},
|
|
1629
1665
|
"procedural": {"skills": 0},
|
|
1630
1666
|
"tokenEconomics": {"discoveryTokens": 0, "readTokens": 0, "savingsPercent": 0},
|
|
1667
|
+
"backend": "json",
|
|
1631
1668
|
}
|
|
1632
1669
|
|
|
1633
|
-
# Count episodic memories
|
|
1634
1670
|
ep_dir = memory_dir / "episodic"
|
|
1635
1671
|
if ep_dir.exists():
|
|
1636
1672
|
episodes = sorted(ep_dir.glob("*.json"))
|
|
@@ -1642,7 +1678,6 @@ async def get_memory_summary():
|
|
|
1642
1678
|
except Exception:
|
|
1643
1679
|
pass
|
|
1644
1680
|
|
|
1645
|
-
# Count semantic patterns
|
|
1646
1681
|
sem_dir = memory_dir / "semantic"
|
|
1647
1682
|
patterns_file = sem_dir / "patterns.json"
|
|
1648
1683
|
anti_file = sem_dir / "anti-patterns.json"
|
|
@@ -1659,12 +1694,10 @@ async def get_memory_summary():
|
|
|
1659
1694
|
except Exception:
|
|
1660
1695
|
pass
|
|
1661
1696
|
|
|
1662
|
-
# Count skills
|
|
1663
1697
|
skills_dir = memory_dir / "skills"
|
|
1664
1698
|
if skills_dir.exists():
|
|
1665
1699
|
summary["procedural"]["skills"] = len(list(skills_dir.glob("*.json")))
|
|
1666
1700
|
|
|
1667
|
-
# Token economics
|
|
1668
1701
|
econ_file = memory_dir / "token_economics.json"
|
|
1669
1702
|
if econ_file.exists():
|
|
1670
1703
|
try:
|
|
@@ -1683,6 +1716,21 @@ async def get_memory_summary():
|
|
|
1683
1716
|
@app.get("/api/memory/episodes")
|
|
1684
1717
|
async def list_episodes(limit: int = Query(default=50, ge=1, le=1000)):
|
|
1685
1718
|
"""List episodic memory entries."""
|
|
1719
|
+
# Try SQLite backend first
|
|
1720
|
+
storage = _get_memory_storage()
|
|
1721
|
+
if storage is not None:
|
|
1722
|
+
try:
|
|
1723
|
+
ids = storage.list_episodes(limit=limit)
|
|
1724
|
+
episodes = []
|
|
1725
|
+
for eid in ids:
|
|
1726
|
+
ep = storage.load_episode(eid)
|
|
1727
|
+
if ep:
|
|
1728
|
+
episodes.append(ep)
|
|
1729
|
+
return episodes
|
|
1730
|
+
except Exception:
|
|
1731
|
+
pass
|
|
1732
|
+
|
|
1733
|
+
# Fallback to JSON files
|
|
1686
1734
|
ep_dir = _get_loki_dir() / "memory" / "episodic"
|
|
1687
1735
|
episodes = []
|
|
1688
1736
|
if ep_dir.exists():
|
|
@@ -1698,11 +1746,21 @@ async def list_episodes(limit: int = Query(default=50, ge=1, le=1000)):
|
|
|
1698
1746
|
@app.get("/api/memory/episodes/{episode_id}")
|
|
1699
1747
|
async def get_episode(episode_id: str):
|
|
1700
1748
|
"""Get a specific episodic memory entry."""
|
|
1749
|
+
# Try SQLite first
|
|
1750
|
+
storage = _get_memory_storage()
|
|
1751
|
+
if storage is not None:
|
|
1752
|
+
try:
|
|
1753
|
+
ep = storage.load_episode(episode_id)
|
|
1754
|
+
if ep:
|
|
1755
|
+
return ep
|
|
1756
|
+
except Exception:
|
|
1757
|
+
pass
|
|
1758
|
+
|
|
1759
|
+
# Fallback to JSON files
|
|
1701
1760
|
loki_dir = _get_loki_dir()
|
|
1702
1761
|
ep_dir = loki_dir / "memory" / "episodic"
|
|
1703
1762
|
if not ep_dir.exists():
|
|
1704
1763
|
raise HTTPException(status_code=404, detail="Episode not found")
|
|
1705
|
-
# Try direct filename match
|
|
1706
1764
|
for f in ep_dir.glob("*.json"):
|
|
1707
1765
|
resolved = os.path.realpath(f)
|
|
1708
1766
|
if not resolved.startswith(os.path.realpath(str(loki_dir))):
|
|
@@ -1719,6 +1777,21 @@ async def get_episode(episode_id: str):
|
|
|
1719
1777
|
@app.get("/api/memory/patterns")
|
|
1720
1778
|
async def list_patterns():
|
|
1721
1779
|
"""List semantic patterns."""
|
|
1780
|
+
# Try SQLite first
|
|
1781
|
+
storage = _get_memory_storage()
|
|
1782
|
+
if storage is not None:
|
|
1783
|
+
try:
|
|
1784
|
+
ids = storage.list_patterns()
|
|
1785
|
+
patterns = []
|
|
1786
|
+
for pid in ids:
|
|
1787
|
+
p = storage.load_pattern(pid)
|
|
1788
|
+
if p:
|
|
1789
|
+
patterns.append(p)
|
|
1790
|
+
return patterns
|
|
1791
|
+
except Exception:
|
|
1792
|
+
pass
|
|
1793
|
+
|
|
1794
|
+
# Fallback to JSON
|
|
1722
1795
|
sem_dir = _get_loki_dir() / "memory" / "semantic"
|
|
1723
1796
|
patterns_file = sem_dir / "patterns.json"
|
|
1724
1797
|
if patterns_file.exists():
|
|
@@ -1743,6 +1816,21 @@ async def get_pattern(pattern_id: str):
|
|
|
1743
1816
|
@app.get("/api/memory/skills")
|
|
1744
1817
|
async def list_skills():
|
|
1745
1818
|
"""List procedural skills."""
|
|
1819
|
+
# Try SQLite first
|
|
1820
|
+
storage = _get_memory_storage()
|
|
1821
|
+
if storage is not None:
|
|
1822
|
+
try:
|
|
1823
|
+
ids = storage.list_skills()
|
|
1824
|
+
skills = []
|
|
1825
|
+
for sid in ids:
|
|
1826
|
+
s = storage.load_skill(sid)
|
|
1827
|
+
if s:
|
|
1828
|
+
skills.append(s)
|
|
1829
|
+
return skills
|
|
1830
|
+
except Exception:
|
|
1831
|
+
pass
|
|
1832
|
+
|
|
1833
|
+
# Fallback to JSON
|
|
1746
1834
|
skills_dir = _get_loki_dir() / "memory" / "skills"
|
|
1747
1835
|
skills = []
|
|
1748
1836
|
if skills_dir.exists():
|
|
@@ -1824,6 +1912,106 @@ async def get_memory_timeline():
|
|
|
1824
1912
|
return {"entries": episodes, "lastUpdated": None}
|
|
1825
1913
|
|
|
1826
1914
|
|
|
1915
|
+
# ---------------------------------------------------------------------------
|
|
1916
|
+
# Memory Search & Stats (v6.15.0) - SQLite FTS5 powered
|
|
1917
|
+
# ---------------------------------------------------------------------------
|
|
1918
|
+
|
|
1919
|
+
def _get_memory_storage():
|
|
1920
|
+
"""Get the best available memory storage backend (SQLite preferred)."""
|
|
1921
|
+
memory_dir = _get_loki_dir() / "memory"
|
|
1922
|
+
base_path = str(memory_dir)
|
|
1923
|
+
try:
|
|
1924
|
+
import sys
|
|
1925
|
+
project_root = str(_Path(__file__).resolve().parent.parent)
|
|
1926
|
+
if project_root not in sys.path:
|
|
1927
|
+
sys.path.insert(0, project_root)
|
|
1928
|
+
from memory.sqlite_storage import SQLiteMemoryStorage
|
|
1929
|
+
return SQLiteMemoryStorage(base_path=base_path)
|
|
1930
|
+
except Exception:
|
|
1931
|
+
return None
|
|
1932
|
+
|
|
1933
|
+
|
|
1934
|
+
@app.get("/api/memory/search")
|
|
1935
|
+
async def search_memory(
|
|
1936
|
+
q: str = Query(..., min_length=1, max_length=500, description="Search query"),
|
|
1937
|
+
collection: str = Query(default="all", regex="^(episodes|patterns|skills|all)$"),
|
|
1938
|
+
limit: int = Query(default=20, ge=1, le=100),
|
|
1939
|
+
):
|
|
1940
|
+
"""Full-text search across memory using FTS5."""
|
|
1941
|
+
storage = _get_memory_storage()
|
|
1942
|
+
if storage is None:
|
|
1943
|
+
return {"results": [], "message": "SQLite memory backend not available"}
|
|
1944
|
+
|
|
1945
|
+
try:
|
|
1946
|
+
results = storage.search_fts(q, collection=collection, limit=limit)
|
|
1947
|
+
compact = []
|
|
1948
|
+
for r in results:
|
|
1949
|
+
entry = {
|
|
1950
|
+
"id": r.get("id", ""),
|
|
1951
|
+
"type": r.get("_type", "unknown"),
|
|
1952
|
+
"summary": (
|
|
1953
|
+
r.get("goal", "") or
|
|
1954
|
+
r.get("pattern", "") or
|
|
1955
|
+
r.get("description", "") or
|
|
1956
|
+
r.get("name", "")
|
|
1957
|
+
)[:300],
|
|
1958
|
+
"score": round(r.get("_score", 0), 3),
|
|
1959
|
+
}
|
|
1960
|
+
if r.get("outcome"):
|
|
1961
|
+
entry["outcome"] = r["outcome"]
|
|
1962
|
+
if r.get("category"):
|
|
1963
|
+
entry["category"] = r["category"]
|
|
1964
|
+
if r.get("timestamp"):
|
|
1965
|
+
entry["timestamp"] = r["timestamp"]
|
|
1966
|
+
compact.append(entry)
|
|
1967
|
+
return {"results": compact, "count": len(compact), "query": q, "collection": collection}
|
|
1968
|
+
except Exception as e:
|
|
1969
|
+
raise HTTPException(status_code=500, detail=f"Search failed: {e}")
|
|
1970
|
+
|
|
1971
|
+
|
|
1972
|
+
@app.get("/api/memory/stats")
|
|
1973
|
+
async def get_memory_stats():
|
|
1974
|
+
"""Get memory system statistics (counts, size, backend info)."""
|
|
1975
|
+
storage = _get_memory_storage()
|
|
1976
|
+
if storage is not None:
|
|
1977
|
+
try:
|
|
1978
|
+
return storage.get_stats()
|
|
1979
|
+
except Exception:
|
|
1980
|
+
pass
|
|
1981
|
+
|
|
1982
|
+
# Fallback: compute stats from JSON files
|
|
1983
|
+
memory_dir = _get_loki_dir() / "memory"
|
|
1984
|
+
ep_count = 0
|
|
1985
|
+
ep_dir = memory_dir / "episodic"
|
|
1986
|
+
if ep_dir.exists():
|
|
1987
|
+
for d in ep_dir.iterdir():
|
|
1988
|
+
if d.is_dir():
|
|
1989
|
+
ep_count += len(list(d.glob("*.json")))
|
|
1990
|
+
elif d.suffix == ".json":
|
|
1991
|
+
ep_count += 1
|
|
1992
|
+
|
|
1993
|
+
pat_count = 0
|
|
1994
|
+
patterns_file = memory_dir / "semantic" / "patterns.json"
|
|
1995
|
+
if patterns_file.exists():
|
|
1996
|
+
try:
|
|
1997
|
+
data = json.loads(patterns_file.read_text())
|
|
1998
|
+
pat_count = len(data) if isinstance(data, list) else len(data.get("patterns", []))
|
|
1999
|
+
except Exception:
|
|
2000
|
+
pass
|
|
2001
|
+
|
|
2002
|
+
skill_count = 0
|
|
2003
|
+
skills_dir = memory_dir / "skills"
|
|
2004
|
+
if skills_dir.exists():
|
|
2005
|
+
skill_count = len(list(skills_dir.glob("*.json")))
|
|
2006
|
+
|
|
2007
|
+
return {
|
|
2008
|
+
"backend": "json",
|
|
2009
|
+
"episode_count": ep_count,
|
|
2010
|
+
"pattern_count": pat_count,
|
|
2011
|
+
"skill_count": skill_count,
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
|
|
1827
2015
|
# Learning/metrics endpoints
|
|
1828
2016
|
|
|
1829
2017
|
|
package/docs/INSTALLATION.md
CHANGED
package/mcp/__init__.py
CHANGED
package/mcp/server.py
CHANGED
|
@@ -1376,6 +1376,265 @@ async def loki_code_search_stats() -> str:
|
|
|
1376
1376
|
return json.dumps({"error": str(e)})
|
|
1377
1377
|
|
|
1378
1378
|
|
|
1379
|
+
# ============================================================
|
|
1380
|
+
# MEMORY SEARCH TOOLS (v6.15.0) - SQLite FTS5 powered
|
|
1381
|
+
# ============================================================
|
|
1382
|
+
|
|
1383
|
+
@mcp.tool()
|
|
1384
|
+
async def mem_search(
|
|
1385
|
+
query: str,
|
|
1386
|
+
collection: str = "all",
|
|
1387
|
+
limit: int = 10,
|
|
1388
|
+
) -> str:
|
|
1389
|
+
"""
|
|
1390
|
+
Search memory using full-text search (FTS5).
|
|
1391
|
+
|
|
1392
|
+
Fast keyword search across all memory types. Supports AND, OR, NOT
|
|
1393
|
+
operators and prefix matching (e.g. "debug*").
|
|
1394
|
+
|
|
1395
|
+
Args:
|
|
1396
|
+
query: Search query (plain text or FTS5 syntax)
|
|
1397
|
+
collection: Which memories to search (episodes, patterns, skills, all)
|
|
1398
|
+
limit: Maximum results to return
|
|
1399
|
+
|
|
1400
|
+
Returns:
|
|
1401
|
+
JSON array of matching memories with relevance scores
|
|
1402
|
+
"""
|
|
1403
|
+
_emit_tool_event_async(
|
|
1404
|
+
'mem_search', 'start',
|
|
1405
|
+
parameters={'query': query, 'collection': collection, 'limit': limit}
|
|
1406
|
+
)
|
|
1407
|
+
try:
|
|
1408
|
+
base_path = safe_path_join('.loki', 'memory')
|
|
1409
|
+
if not os.path.exists(base_path):
|
|
1410
|
+
result = json.dumps({"results": [], "message": "Memory system not initialized"})
|
|
1411
|
+
_emit_tool_event_async('mem_search', 'complete', result_status='success')
|
|
1412
|
+
return result
|
|
1413
|
+
|
|
1414
|
+
# Try SQLite backend first (has FTS5), fall back to keyword search
|
|
1415
|
+
try:
|
|
1416
|
+
from memory.sqlite_storage import SQLiteMemoryStorage
|
|
1417
|
+
storage = SQLiteMemoryStorage(base_path)
|
|
1418
|
+
results = storage.search_fts(query, collection=collection, limit=limit)
|
|
1419
|
+
except (ImportError, Exception):
|
|
1420
|
+
# Fall back to retrieval-based search
|
|
1421
|
+
from memory.retrieval import MemoryRetrieval
|
|
1422
|
+
from memory.storage import MemoryStorage
|
|
1423
|
+
storage = MemoryStorage(base_path)
|
|
1424
|
+
retriever = MemoryRetrieval(storage)
|
|
1425
|
+
context = {"goal": query, "task_type": "exploration"}
|
|
1426
|
+
results = retriever.retrieve_task_aware(context, top_k=limit)
|
|
1427
|
+
|
|
1428
|
+
# Compact results for token efficiency
|
|
1429
|
+
compact = []
|
|
1430
|
+
for r in results:
|
|
1431
|
+
entry = {
|
|
1432
|
+
"id": r.get("id", ""),
|
|
1433
|
+
"type": r.get("_type", r.get("type", "unknown")),
|
|
1434
|
+
"summary": (
|
|
1435
|
+
r.get("goal", "") or
|
|
1436
|
+
r.get("pattern", "") or
|
|
1437
|
+
r.get("description", "") or
|
|
1438
|
+
r.get("name", "")
|
|
1439
|
+
)[:200],
|
|
1440
|
+
}
|
|
1441
|
+
if r.get("_score"):
|
|
1442
|
+
entry["score"] = round(r["_score"], 3)
|
|
1443
|
+
if r.get("outcome"):
|
|
1444
|
+
entry["outcome"] = r["outcome"]
|
|
1445
|
+
if r.get("category"):
|
|
1446
|
+
entry["category"] = r["category"]
|
|
1447
|
+
compact.append(entry)
|
|
1448
|
+
|
|
1449
|
+
result = json.dumps({"results": compact, "count": len(compact)}, default=str)
|
|
1450
|
+
_emit_tool_event_async('mem_search', 'complete', result_status='success')
|
|
1451
|
+
return result
|
|
1452
|
+
except PathTraversalError as e:
|
|
1453
|
+
logger.error(f"Path traversal attempt blocked: {e}")
|
|
1454
|
+
_emit_tool_event_async('mem_search', 'complete', result_status='error', error='Access denied')
|
|
1455
|
+
return json.dumps({"error": "Access denied", "results": []})
|
|
1456
|
+
except Exception as e:
|
|
1457
|
+
logger.error(f"mem_search failed: {e}")
|
|
1458
|
+
_emit_tool_event_async('mem_search', 'complete', result_status='error', error=str(e))
|
|
1459
|
+
return json.dumps({"error": str(e), "results": []})
|
|
1460
|
+
|
|
1461
|
+
|
|
1462
|
+
@mcp.tool()
|
|
1463
|
+
async def mem_timeline(
|
|
1464
|
+
around_id: str = "",
|
|
1465
|
+
limit: int = 20,
|
|
1466
|
+
since_hours: int = 24,
|
|
1467
|
+
) -> str:
|
|
1468
|
+
"""
|
|
1469
|
+
Get chronological context from memory timeline.
|
|
1470
|
+
|
|
1471
|
+
Shows recent actions, key decisions, and episode traces in time order.
|
|
1472
|
+
Use around_id to get context surrounding a specific memory entry.
|
|
1473
|
+
|
|
1474
|
+
Args:
|
|
1475
|
+
around_id: Optional memory ID to center the timeline around
|
|
1476
|
+
limit: Maximum timeline entries to return
|
|
1477
|
+
since_hours: Only show entries from the last N hours (default 24)
|
|
1478
|
+
|
|
1479
|
+
Returns:
|
|
1480
|
+
JSON timeline with actions and decisions
|
|
1481
|
+
"""
|
|
1482
|
+
_emit_tool_event_async(
|
|
1483
|
+
'mem_timeline', 'start',
|
|
1484
|
+
parameters={'around_id': around_id, 'limit': limit, 'since_hours': since_hours}
|
|
1485
|
+
)
|
|
1486
|
+
try:
|
|
1487
|
+
base_path = safe_path_join('.loki', 'memory')
|
|
1488
|
+
if not os.path.exists(base_path):
|
|
1489
|
+
result = json.dumps({"timeline": [], "message": "Memory system not initialized"})
|
|
1490
|
+
_emit_tool_event_async('mem_timeline', 'complete', result_status='success')
|
|
1491
|
+
return result
|
|
1492
|
+
|
|
1493
|
+
from datetime import timedelta
|
|
1494
|
+
cutoff = datetime.now(timezone.utc) - timedelta(hours=since_hours)
|
|
1495
|
+
|
|
1496
|
+
try:
|
|
1497
|
+
from memory.sqlite_storage import SQLiteMemoryStorage
|
|
1498
|
+
storage = SQLiteMemoryStorage(base_path)
|
|
1499
|
+
|
|
1500
|
+
# Get timeline actions
|
|
1501
|
+
timeline = storage.get_timeline()
|
|
1502
|
+
actions = timeline.get("recent_actions", [])[:limit]
|
|
1503
|
+
|
|
1504
|
+
# Get recent episodes for richer context
|
|
1505
|
+
episode_ids = storage.list_episodes(since=cutoff, limit=limit)
|
|
1506
|
+
episodes = []
|
|
1507
|
+
for eid in episode_ids:
|
|
1508
|
+
ep = storage.load_episode(eid)
|
|
1509
|
+
if ep:
|
|
1510
|
+
episodes.append({
|
|
1511
|
+
"id": ep.get("id"),
|
|
1512
|
+
"timestamp": ep.get("timestamp"),
|
|
1513
|
+
"phase": ep.get("phase"),
|
|
1514
|
+
"goal": (ep.get("goal", "") or "")[:150],
|
|
1515
|
+
"outcome": ep.get("outcome"),
|
|
1516
|
+
"duration_seconds": ep.get("duration_seconds"),
|
|
1517
|
+
"files_modified": ep.get("files_modified", [])[:5],
|
|
1518
|
+
})
|
|
1519
|
+
|
|
1520
|
+
except (ImportError, Exception):
|
|
1521
|
+
from memory.storage import MemoryStorage
|
|
1522
|
+
storage = MemoryStorage(base_path)
|
|
1523
|
+
timeline = storage.get_timeline()
|
|
1524
|
+
actions = timeline.get("recent_actions", [])[:limit]
|
|
1525
|
+
|
|
1526
|
+
episode_ids = storage.list_episodes(since=cutoff, limit=limit)
|
|
1527
|
+
episodes = []
|
|
1528
|
+
for eid in episode_ids:
|
|
1529
|
+
ep = storage.load_episode(eid)
|
|
1530
|
+
if ep:
|
|
1531
|
+
episodes.append({
|
|
1532
|
+
"id": ep.get("id"),
|
|
1533
|
+
"timestamp": ep.get("timestamp"),
|
|
1534
|
+
"phase": ep.get("phase"),
|
|
1535
|
+
"goal": (ep.get("goal", "") or "")[:150],
|
|
1536
|
+
"outcome": ep.get("outcome"),
|
|
1537
|
+
})
|
|
1538
|
+
|
|
1539
|
+
result = json.dumps({
|
|
1540
|
+
"actions": actions,
|
|
1541
|
+
"episodes": episodes,
|
|
1542
|
+
"decisions": timeline.get("key_decisions", [])[:10],
|
|
1543
|
+
"active_context": timeline.get("active_context", {}),
|
|
1544
|
+
}, default=str)
|
|
1545
|
+
_emit_tool_event_async('mem_timeline', 'complete', result_status='success')
|
|
1546
|
+
return result
|
|
1547
|
+
except PathTraversalError as e:
|
|
1548
|
+
logger.error(f"Path traversal attempt blocked: {e}")
|
|
1549
|
+
_emit_tool_event_async('mem_timeline', 'complete', result_status='error', error='Access denied')
|
|
1550
|
+
return json.dumps({"error": "Access denied", "timeline": []})
|
|
1551
|
+
except Exception as e:
|
|
1552
|
+
logger.error(f"mem_timeline failed: {e}")
|
|
1553
|
+
_emit_tool_event_async('mem_timeline', 'complete', result_status='error', error=str(e))
|
|
1554
|
+
return json.dumps({"error": str(e), "timeline": []})
|
|
1555
|
+
|
|
1556
|
+
|
|
1557
|
+
@mcp.tool()
|
|
1558
|
+
async def mem_get(
|
|
1559
|
+
ids: str,
|
|
1560
|
+
) -> str:
|
|
1561
|
+
"""
|
|
1562
|
+
Fetch full details for one or more memory entries by ID.
|
|
1563
|
+
|
|
1564
|
+
Use after mem_search to get complete data for specific results.
|
|
1565
|
+
|
|
1566
|
+
Args:
|
|
1567
|
+
ids: Comma-separated list of memory IDs to fetch
|
|
1568
|
+
|
|
1569
|
+
Returns:
|
|
1570
|
+
JSON object with full memory details keyed by ID
|
|
1571
|
+
"""
|
|
1572
|
+
_emit_tool_event_async(
|
|
1573
|
+
'mem_get', 'start',
|
|
1574
|
+
parameters={'ids': ids}
|
|
1575
|
+
)
|
|
1576
|
+
try:
|
|
1577
|
+
base_path = safe_path_join('.loki', 'memory')
|
|
1578
|
+
if not os.path.exists(base_path):
|
|
1579
|
+
result = json.dumps({"entries": {}, "message": "Memory system not initialized"})
|
|
1580
|
+
_emit_tool_event_async('mem_get', 'complete', result_status='success')
|
|
1581
|
+
return result
|
|
1582
|
+
|
|
1583
|
+
id_list = [i.strip() for i in ids.split(",") if i.strip()]
|
|
1584
|
+
if not id_list:
|
|
1585
|
+
return json.dumps({"entries": {}, "error": "No IDs provided"})
|
|
1586
|
+
|
|
1587
|
+
# Cap at 20 to prevent abuse
|
|
1588
|
+
id_list = id_list[:20]
|
|
1589
|
+
|
|
1590
|
+
try:
|
|
1591
|
+
from memory.sqlite_storage import SQLiteMemoryStorage
|
|
1592
|
+
storage = SQLiteMemoryStorage(base_path)
|
|
1593
|
+
except (ImportError, Exception):
|
|
1594
|
+
from memory.storage import MemoryStorage
|
|
1595
|
+
storage = MemoryStorage(base_path)
|
|
1596
|
+
|
|
1597
|
+
entries = {}
|
|
1598
|
+
for mem_id in id_list:
|
|
1599
|
+
mem_id = mem_id.strip()
|
|
1600
|
+
# Try each collection
|
|
1601
|
+
data = storage.load_episode(mem_id)
|
|
1602
|
+
if data:
|
|
1603
|
+
data["_type"] = "episode"
|
|
1604
|
+
entries[mem_id] = data
|
|
1605
|
+
continue
|
|
1606
|
+
|
|
1607
|
+
data = storage.load_pattern(mem_id)
|
|
1608
|
+
if data:
|
|
1609
|
+
data["_type"] = "pattern"
|
|
1610
|
+
entries[mem_id] = data
|
|
1611
|
+
continue
|
|
1612
|
+
|
|
1613
|
+
data = storage.load_skill(mem_id)
|
|
1614
|
+
if data:
|
|
1615
|
+
data["_type"] = "skill"
|
|
1616
|
+
entries[mem_id] = data
|
|
1617
|
+
continue
|
|
1618
|
+
|
|
1619
|
+
entries[mem_id] = None # Not found
|
|
1620
|
+
|
|
1621
|
+
result = json.dumps({
|
|
1622
|
+
"entries": entries,
|
|
1623
|
+
"found": sum(1 for v in entries.values() if v is not None),
|
|
1624
|
+
"total_requested": len(id_list),
|
|
1625
|
+
}, default=str)
|
|
1626
|
+
_emit_tool_event_async('mem_get', 'complete', result_status='success')
|
|
1627
|
+
return result
|
|
1628
|
+
except PathTraversalError as e:
|
|
1629
|
+
logger.error(f"Path traversal attempt blocked: {e}")
|
|
1630
|
+
_emit_tool_event_async('mem_get', 'complete', result_status='error', error='Access denied')
|
|
1631
|
+
return json.dumps({"error": "Access denied", "entries": {}})
|
|
1632
|
+
except Exception as e:
|
|
1633
|
+
logger.error(f"mem_get failed: {e}")
|
|
1634
|
+
_emit_tool_event_async('mem_get', 'complete', result_status='error', error=str(e))
|
|
1635
|
+
return json.dumps({"error": str(e), "entries": {}})
|
|
1636
|
+
|
|
1637
|
+
|
|
1379
1638
|
# ============================================================
|
|
1380
1639
|
# PROMPTS - Pre-built prompt templates
|
|
1381
1640
|
# ============================================================
|
package/memory/__init__.py
CHANGED
|
@@ -42,12 +42,18 @@ from .schemas import (
|
|
|
42
42
|
|
|
43
43
|
from .storage import MemoryStorage, DEFAULT_NAMESPACE
|
|
44
44
|
|
|
45
|
+
try:
|
|
46
|
+
from .sqlite_storage import SQLiteMemoryStorage
|
|
47
|
+
except ImportError:
|
|
48
|
+
SQLiteMemoryStorage = None
|
|
49
|
+
|
|
45
50
|
from .engine import (
|
|
46
51
|
MemoryEngine,
|
|
47
52
|
EpisodicMemory,
|
|
48
53
|
SemanticMemory,
|
|
49
54
|
ProceduralMemory,
|
|
50
55
|
TASK_STRATEGIES,
|
|
56
|
+
create_storage,
|
|
51
57
|
)
|
|
52
58
|
|
|
53
59
|
from .retrieval import (
|
|
@@ -110,6 +116,8 @@ __all__ = [
|
|
|
110
116
|
"ProceduralSkill",
|
|
111
117
|
# Engine
|
|
112
118
|
"MemoryStorage",
|
|
119
|
+
"SQLiteMemoryStorage",
|
|
120
|
+
"create_storage",
|
|
113
121
|
"MemoryEngine",
|
|
114
122
|
"EpisodicMemory",
|
|
115
123
|
"SemanticMemory",
|
package/memory/engine.py
CHANGED
|
@@ -1295,3 +1295,24 @@ class ProceduralMemory:
|
|
|
1295
1295
|
def search(self, query: str, top_k: int = 5) -> List[Dict[str, Any]]:
|
|
1296
1296
|
"""Search skills by similarity."""
|
|
1297
1297
|
return self._engine.retrieve_by_similarity(query, "procedural", top_k)
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
def create_storage(base_path: str = ".loki/memory", namespace: Optional[str] = None):
|
|
1301
|
+
"""
|
|
1302
|
+
Factory function to create the best available storage backend.
|
|
1303
|
+
|
|
1304
|
+
Tries SQLite+FTS5 first (faster search, single file), falls back to
|
|
1305
|
+
JSON-based MemoryStorage if SQLite initialization fails.
|
|
1306
|
+
|
|
1307
|
+
Args:
|
|
1308
|
+
base_path: Base path for memory data
|
|
1309
|
+
namespace: Optional namespace for project isolation
|
|
1310
|
+
|
|
1311
|
+
Returns:
|
|
1312
|
+
SQLiteMemoryStorage or MemoryStorage instance
|
|
1313
|
+
"""
|
|
1314
|
+
try:
|
|
1315
|
+
from .sqlite_storage import SQLiteMemoryStorage
|
|
1316
|
+
return SQLiteMemoryStorage(base_path=base_path, namespace=namespace)
|
|
1317
|
+
except Exception:
|
|
1318
|
+
return MemoryStorage(base_path=base_path, namespace=namespace)
|