loki-mode 7.96.0 → 7.98.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 +2 -2
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/completion-council.sh +44 -4
- package/autonomy/hooks/migration-hooks.sh +61 -10
- package/autonomy/run.sh +128 -5
- package/dashboard/__init__.py +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/events/bus.py +11 -1
- package/events/emit.sh +35 -10
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/memory/engine.py +60 -57
- package/memory/rag_injector.py +71 -6
- package/memory/retrieval.py +42 -12
- package/memory/storage.py +55 -18
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
package/README.md
CHANGED
|
@@ -197,7 +197,7 @@ loki quick "build a landing page with a signup form"
|
|
|
197
197
|
| **Bun (recommended)** | `bun install -g loki-mode` | Fastest startup for CLI commands. |
|
|
198
198
|
| **Homebrew** | `brew tap asklokesh/tap && brew install loki-mode` | Auto-installs Bun as a dep |
|
|
199
199
|
| **Docker (easiest)** | `loki docker start prd.md` | Host wrapper: runs loki in the published image with zero config. Bind-mounts the current folder so `.loki` state, resume, and continuity work exactly like local. Auto-detects auth (`ANTHROPIC_API_KEY`, else your host Claude Code login). Needs loki + Docker on the host. See DOCKER_README.md |
|
|
200
|
-
| **Docker (raw)** | `docker pull asklokesh/loki-mode:
|
|
200
|
+
| **Docker (raw)** | `docker pull asklokesh/loki-mode:latest && docker run --rm -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" asklokesh/loki-mode:latest start prd.md` | Bun + Claude CLI pre-installed; needs an API key, or use docker compose with a .env file, see DOCKER_README.md |
|
|
201
201
|
| **npm (compat)** | `npm install -g loki-mode` | Works without Bun (bash fallback). Migrate any time with `loki self-update --to bun`. |
|
|
202
202
|
|
|
203
203
|
**Upgrading:**
|
|
@@ -257,7 +257,7 @@ The next major release sunsets the Bash runtime entirely. There is no firm calen
|
|
|
257
257
|
| Method | Command |
|
|
258
258
|
|--------|---------|
|
|
259
259
|
| **Homebrew** | `brew tap asklokesh/tap && brew install loki-mode` |
|
|
260
|
-
| **Docker** | `docker pull asklokesh/loki-mode:
|
|
260
|
+
| **Docker** | `docker pull asklokesh/loki-mode:latest` |
|
|
261
261
|
| **Inside Claude Code** | `claude --dangerously-skip-permissions` then type "Loki Mode" |
|
|
262
262
|
| **Git clone** | `git clone https://github.com/asklokesh/loki-mode.git` |
|
|
263
263
|
|
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: loki-mode
|
|
|
3
3
|
description: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 8 quality gates, completion council, verified-completion evidence gate). Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Loki Mode v7.
|
|
6
|
+
# Loki Mode v7.98.0
|
|
7
7
|
|
|
8
8
|
**You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
|
|
9
9
|
|
|
@@ -408,4 +408,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
|
|
|
408
408
|
|
|
409
409
|
---
|
|
410
410
|
|
|
411
|
-
**v7.
|
|
411
|
+
**v7.98.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
7.
|
|
1
|
+
7.98.0
|
|
@@ -44,7 +44,37 @@
|
|
|
44
44
|
# Council configuration
|
|
45
45
|
COUNCIL_ENABLED=${LOKI_COUNCIL_ENABLED:-true}
|
|
46
46
|
COUNCIL_SIZE=${LOKI_COUNCIL_SIZE:-3}
|
|
47
|
+
# Guard COUNCIL_SIZE: a non-positive/non-numeric size would make the 2/3 floor 0
|
|
48
|
+
# and let an empty council "approve" by default (fake-green). Force a sane floor.
|
|
49
|
+
case "$COUNCIL_SIZE" in
|
|
50
|
+
''|*[!0-9]*) COUNCIL_SIZE=3 ;;
|
|
51
|
+
esac
|
|
52
|
+
[ "$COUNCIL_SIZE" -lt 1 ] 2>/dev/null && COUNCIL_SIZE=3
|
|
47
53
|
COUNCIL_THRESHOLD=${LOKI_COUNCIL_THRESHOLD:-2}
|
|
54
|
+
|
|
55
|
+
# Effective completion threshold: the operator's COUNCIL_THRESHOLD may only ever
|
|
56
|
+
# TIGHTEN the gate, never weaken it below the 2/3-majority safety floor. Before
|
|
57
|
+
# this, the operator's COUNCIL_THRESHOLD was silently ignored by the vote (a
|
|
58
|
+
# hardcoded 2/3 formula was used), so an operator who set a stricter threshold
|
|
59
|
+
# got a no-op while the logs claimed it was active. We honor it as a floor-raise
|
|
60
|
+
# only: effective = max(ceil(2/3*size), operator_threshold), clamped to size.
|
|
61
|
+
_council_effective_threshold() {
|
|
62
|
+
# Optional $1 = the size to compute against (e.g. the actual number of voters
|
|
63
|
+
# present in this round); defaults to COUNCIL_SIZE. The 2/3 floor + the
|
|
64
|
+
# operator clamp are both relative to this size, so the helper works for both
|
|
65
|
+
# the configured council size and a per-round member count.
|
|
66
|
+
local _size="${1:-$COUNCIL_SIZE}"
|
|
67
|
+
case "$_size" in ''|*[!0-9]*) _size="$COUNCIL_SIZE" ;; esac
|
|
68
|
+
[ "$_size" -lt 1 ] 2>/dev/null && _size=1
|
|
69
|
+
local _floor=$(( (_size * 2 + 2) / 3 ))
|
|
70
|
+
local _op="${COUNCIL_THRESHOLD:-0}"
|
|
71
|
+
case "$_op" in ''|*[!0-9]*) _op=0 ;; esac
|
|
72
|
+
local _eff="$_floor"
|
|
73
|
+
[ "$_op" -gt "$_eff" ] 2>/dev/null && _eff="$_op"
|
|
74
|
+
[ "$_eff" -gt "$_size" ] 2>/dev/null && _eff="$_size"
|
|
75
|
+
[ "$_eff" -lt 1 ] 2>/dev/null && _eff=1
|
|
76
|
+
printf '%s' "$_eff"
|
|
77
|
+
}
|
|
48
78
|
COUNCIL_CHECK_INTERVAL=${LOKI_COUNCIL_CHECK_INTERVAL:-5}
|
|
49
79
|
# Guard against invalid interval (must be positive integer)
|
|
50
80
|
if ! [[ "$COUNCIL_CHECK_INTERVAL" =~ ^[1-9][0-9]*$ ]]; then
|
|
@@ -608,8 +638,11 @@ council_vote() {
|
|
|
608
638
|
log_header "COMPLETION COUNCIL - Iteration $ITERATION_COUNT"
|
|
609
639
|
log_info "Convening ${COUNCIL_SIZE}-member council..."
|
|
610
640
|
|
|
611
|
-
#
|
|
612
|
-
|
|
641
|
+
# Effective threshold honors the operator's COUNCIL_THRESHOLD as a floor-raise
|
|
642
|
+
# over the 2/3-majority safety floor (tighten-only); see
|
|
643
|
+
# _council_effective_threshold.
|
|
644
|
+
local effective_threshold
|
|
645
|
+
effective_threshold=$(_council_effective_threshold)
|
|
613
646
|
|
|
614
647
|
# Gather evidence for council members
|
|
615
648
|
local evidence_file="$vote_dir/evidence.md"
|
|
@@ -2604,7 +2637,13 @@ print(json.dumps(out))
|
|
|
2604
2637
|
" 2>/dev/null || echo "[]")
|
|
2605
2638
|
|
|
2606
2639
|
# Calculate threshold: 2/3 majority
|
|
2607
|
-
|
|
2640
|
+
# Effective threshold honors the operator's COUNCIL_THRESHOLD as a tighten-only
|
|
2641
|
+
# floor-raise over the 2/3 ceiling, computed against the actual member count.
|
|
2642
|
+
# This is the PRIMARY completion-decision path (council_should_stop ->
|
|
2643
|
+
# council_evaluate -> council_aggregate_votes), so it must use the same helper
|
|
2644
|
+
# as council_vote, not a separate hardcoded formula.
|
|
2645
|
+
local threshold
|
|
2646
|
+
threshold=$(_council_effective_threshold "$total_members")
|
|
2608
2647
|
local verdict="CONTINUE"
|
|
2609
2648
|
if [ "$complete_count" -ge "$threshold" ]; then
|
|
2610
2649
|
verdict="COMPLETE"
|
|
@@ -2833,7 +2872,8 @@ council_evaluate() {
|
|
|
2833
2872
|
fi
|
|
2834
2873
|
|
|
2835
2874
|
# Compute threshold using the same ceiling(2/3) formula as council_vote and council_aggregate_votes
|
|
2836
|
-
local _eval_threshold
|
|
2875
|
+
local _eval_threshold
|
|
2876
|
+
_eval_threshold=$(_council_effective_threshold)
|
|
2837
2877
|
|
|
2838
2878
|
# Step 1: Aggregate votes from all members.
|
|
2839
2879
|
# Phase C (v7.5.20): try the Claude `--agents <json>` + `--json-schema`
|
|
@@ -162,7 +162,14 @@ hook_pre_file_edit() {
|
|
|
162
162
|
[[ -z "$file_path" ]] && return 0
|
|
163
163
|
local snap_base
|
|
164
164
|
snap_base=$(_migration_snapshot_dir)
|
|
165
|
-
|
|
165
|
+
# Enforce the pairing contract: if the snapshot cannot be captured, BLOCK the
|
|
166
|
+
# edit. Proceeding would leave post_file_edit's block_and_rollback path with
|
|
167
|
+
# no snapshot to restore -- a test failure would then leave the broken edit
|
|
168
|
+
# in place (silent revert failure). Fail loud/closed here instead.
|
|
169
|
+
if ! _heal_snapshot_save "$snap_base" "$file_path"; then
|
|
170
|
+
echo "HOOK_BLOCKED: could not capture a pre-edit snapshot for ${file_path}; refusing to edit without a revert path. Check that ${snap_base}/snapshots is writable."
|
|
171
|
+
return 1
|
|
172
|
+
fi
|
|
166
173
|
return 0
|
|
167
174
|
}
|
|
168
175
|
|
|
@@ -418,7 +425,15 @@ print('OK')
|
|
|
418
425
|
# Capture a pre-edit snapshot so post_healing_modify can revert ONLY the
|
|
419
426
|
# healing edit on test failure (not unrelated uncommitted changes, and not
|
|
420
427
|
# via git checkout which discards everything). Keyed by file path.
|
|
421
|
-
|
|
428
|
+
#
|
|
429
|
+
# Enforce the pairing contract: if the snapshot cannot be captured, BLOCK the
|
|
430
|
+
# edit. Proceeding would leave the edit with no revert path -- a later test
|
|
431
|
+
# failure would then leave the broken edit in place while honestly reporting
|
|
432
|
+
# "no snapshot" (silent revert failure). Fail loud/closed here instead.
|
|
433
|
+
if ! _heal_snapshot_save "$heal_dir" "$file_path"; then
|
|
434
|
+
echo "HOOK_BLOCKED: could not capture a pre-edit snapshot for ${file_path}; refusing to modify it without a revert path. Check that ${heal_dir}/snapshots is writable."
|
|
435
|
+
return 1
|
|
436
|
+
fi
|
|
422
437
|
|
|
423
438
|
return 0
|
|
424
439
|
}
|
|
@@ -438,27 +453,63 @@ _heal_snapshot_path() {
|
|
|
438
453
|
# healing edit will CREATE it), write a sentinel marker instead so the revert
|
|
439
454
|
# path knows to remove the file rather than restore content.
|
|
440
455
|
#
|
|
441
|
-
# Pairing contract: hook_pre_healing_modify (which calls
|
|
442
|
-
# file before hook_post_healing_modify reverts it
|
|
456
|
+
# Pairing contract (ENFORCED, fail-closed): hook_pre_healing_modify (which calls
|
|
457
|
+
# this) MUST run for a file before hook_post_healing_modify reverts it, AND that
|
|
458
|
+
# pre call MUST leave behind a revertable snapshot. The snapshot is refreshed on
|
|
443
459
|
# every pre call, so a post without a matching fresh pre could restore a stale
|
|
444
460
|
# blob. On the success path the snapshot is intentionally left in place; the
|
|
445
461
|
# next pre overwrites it.
|
|
462
|
+
#
|
|
463
|
+
# Returns 0 ONLY when a revertable snapshot demonstrably exists on disk after
|
|
464
|
+
# this call: exactly one of {content snapshot, ".absent" marker}. Returns 1 on
|
|
465
|
+
# ANY failure (cannot create the snapshot dir, cp/sentinel write failed, the
|
|
466
|
+
# wrong marker survived, or both/neither markers exist). A 1 return MUST block
|
|
467
|
+
# the edit at the caller -- otherwise the edit proceeds with no revert path and a
|
|
468
|
+
# later test failure leaves the broken edit in place (the silent-revert-failure
|
|
469
|
+
# this guard exists to prevent). NEVER return 0 on a failure path.
|
|
446
470
|
_heal_snapshot_save() {
|
|
447
471
|
local heal_dir="$1"
|
|
448
472
|
local file_path="$2"
|
|
473
|
+
# Empty file_path: nothing to snapshot. Callers treat empty file_path as a
|
|
474
|
+
# no-op (return 0 before reaching here in the hooks); not a contract breach.
|
|
449
475
|
[[ -z "$file_path" ]] && return 0
|
|
450
476
|
local snap_dir="$heal_dir/snapshots"
|
|
451
|
-
mkdir -p "$snap_dir" 2>/dev/null ||
|
|
477
|
+
if ! mkdir -p "$snap_dir" 2>/dev/null || [[ ! -d "$snap_dir" ]]; then
|
|
478
|
+
return 1
|
|
479
|
+
fi
|
|
452
480
|
local snap
|
|
453
481
|
snap=$(_heal_snapshot_path "$heal_dir" "$file_path")
|
|
454
482
|
if [[ -f "$file_path" ]]; then
|
|
455
|
-
|
|
456
|
-
|
|
483
|
+
# Capture content, then drop any stale absent-marker so EXACTLY the
|
|
484
|
+
# content snapshot survives. Fail closed if either step fails.
|
|
485
|
+
if ! cp "$file_path" "$snap" 2>/dev/null; then
|
|
486
|
+
return 1
|
|
487
|
+
fi
|
|
488
|
+
if ! rm -f "$snap.absent" 2>/dev/null; then
|
|
489
|
+
return 1
|
|
490
|
+
fi
|
|
491
|
+
# Verify exactly the content snapshot exists and the absent-marker is
|
|
492
|
+
# gone. A lingering marker is a contract violation (restore checks the
|
|
493
|
+
# content snapshot first, but the inconsistency must not be tolerated).
|
|
494
|
+
if [[ ! -f "$snap" || -f "$snap.absent" ]]; then
|
|
495
|
+
return 1
|
|
496
|
+
fi
|
|
457
497
|
else
|
|
458
498
|
# File does not exist pre-edit: record an "absent" marker, drop any
|
|
459
|
-
# stale content snapshot.
|
|
460
|
-
|
|
461
|
-
|
|
499
|
+
# stale content snapshot. CRITICAL: if the stale content snapshot is not
|
|
500
|
+
# removed, restore checks the content snapshot FIRST and would restore
|
|
501
|
+
# content for a file that should have been REMOVED. Fail closed if the
|
|
502
|
+
# stale snapshot cannot be cleared or the marker cannot be written.
|
|
503
|
+
if ! rm -f "$snap" 2>/dev/null; then
|
|
504
|
+
return 1
|
|
505
|
+
fi
|
|
506
|
+
if ! : > "$snap.absent" 2>/dev/null; then
|
|
507
|
+
return 1
|
|
508
|
+
fi
|
|
509
|
+
# Verify exactly the absent-marker exists and no content snapshot does.
|
|
510
|
+
if [[ ! -f "$snap.absent" || -f "$snap" ]]; then
|
|
511
|
+
return 1
|
|
512
|
+
fi
|
|
462
513
|
fi
|
|
463
514
|
return 0
|
|
464
515
|
}
|
package/autonomy/run.sh
CHANGED
|
@@ -2058,6 +2058,25 @@ validate_api_keys() {
|
|
|
2058
2058
|
return 1
|
|
2059
2059
|
fi
|
|
2060
2060
|
|
|
2061
|
+
# Zero-friction auth preflight for LOCAL runs (must run BEFORE the early
|
|
2062
|
+
# return below, which exits for non-Docker/K8s envs). When claude is the
|
|
2063
|
+
# provider, the claude CLI is on PATH, there is no ANTHROPIC_API_KEY, and no
|
|
2064
|
+
# OAuth credentials file exists (the user installed claude but never ran
|
|
2065
|
+
# `claude login`), the build would otherwise enter, make a failing call, and
|
|
2066
|
+
# 401 -- the worst first impression -- forcing the user to run `loki why`.
|
|
2067
|
+
# Fail fast with the one-step fix instead. Opt out with LOKI_SKIP_AUTH_PREFLIGHT=1.
|
|
2068
|
+
if [[ "$provider" == "claude" && "${LOKI_SKIP_AUTH_PREFLIGHT:-}" != "1" && -z "${ANTHROPIC_API_KEY:-}" ]] \
|
|
2069
|
+
&& command -v claude >/dev/null 2>&1; then
|
|
2070
|
+
local _local_creds="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.credentials.json"
|
|
2071
|
+
if [[ ! -s "$_local_creds" ]]; then
|
|
2072
|
+
log_error "Claude Code is installed but not logged in -- the build would stall instead of running."
|
|
2073
|
+
log_error "Log in once, then retry:"
|
|
2074
|
+
log_error " claude login"
|
|
2075
|
+
log_error "(or set ANTHROPIC_API_KEY, or LOKI_SKIP_AUTH_PREFLIGHT=1 to bypass this check)"
|
|
2076
|
+
return 1
|
|
2077
|
+
fi
|
|
2078
|
+
fi
|
|
2079
|
+
|
|
2061
2080
|
# CLI tools (claude, codex, cline, aider) use their own login sessions.
|
|
2062
2081
|
# Only require API keys inside Docker/K8s where CLI login isn't available.
|
|
2063
2082
|
if [[ ! -f "/.dockerenv" ]] && [[ -z "${KUBERNETES_SERVICE_HOST:-}" ]]; then
|
|
@@ -2162,6 +2181,9 @@ except Exception:
|
|
|
2162
2181
|
return 1
|
|
2163
2182
|
fi
|
|
2164
2183
|
fi
|
|
2184
|
+
# NOTE: the never-logged-in (no credentials file) case is handled earlier,
|
|
2185
|
+
# BEFORE the local early-return, so it covers local runs too (this Docker/
|
|
2186
|
+
# K8s-only block would otherwise be unreachable for that case).
|
|
2165
2187
|
fi
|
|
2166
2188
|
|
|
2167
2189
|
return 0
|
|
@@ -3801,6 +3823,82 @@ effective_session_cap() {
|
|
|
3801
3823
|
return 0
|
|
3802
3824
|
}
|
|
3803
3825
|
|
|
3826
|
+
# Auto-flag parity for parallel worktree Claude sessions (wave-5 fix).
|
|
3827
|
+
# The main RARV loop applies adaptive cost/resilience flags to its claude
|
|
3828
|
+
# invocation (run.sh:16007-16030 effort/max-budget/fallback) plus an MCP
|
|
3829
|
+
# bundle, but the parallel worktree spawn shipped a bare invocation that missed
|
|
3830
|
+
# them entirely. This populates the global _LOKI_WT_AUTO_FLAGS array with ONLY
|
|
3831
|
+
# the flags that are safe on the plain (non stream-json) worktree invocation:
|
|
3832
|
+
# --effort adaptive reasoning depth (dev tier)
|
|
3833
|
+
# --max-budget-usd per-call hard backstop
|
|
3834
|
+
# --fallback-model resilience to model overload/unavailability
|
|
3835
|
+
# --mcp-config (+ --strict-mcp-config) the same MCP bundle the Bun
|
|
3836
|
+
# route emits; a SUPERSET of the bash main loop, which does
|
|
3837
|
+
# NOT emit --mcp-config (see ledger run.sh:15990-15996).
|
|
3838
|
+
# Worktree dev streams benefit from the bundled MCP servers.
|
|
3839
|
+
# We deliberately do NOT replicate the stream-json-coupled flags
|
|
3840
|
+
# (--include-hook-events, --include-partial-messages) nor --output-format /
|
|
3841
|
+
# --session-id: this invocation logs free-form text, not parsed stream-json, so
|
|
3842
|
+
# those would be invalid or unwanted here. Each flag is gated on CLI support +
|
|
3843
|
+
# its opt-out env var, matching the main loop. Extracted from spawn_worktree_session
|
|
3844
|
+
# so it is unit-testable (tests/test-worktree-auto-flags.sh).
|
|
3845
|
+
_loki_build_worktree_claude_flags() {
|
|
3846
|
+
_LOKI_WT_AUTO_FLAGS=()
|
|
3847
|
+
# Non-claude providers never receive these flags.
|
|
3848
|
+
if [ "${PROVIDER_NAME:-claude}" != "claude" ]; then
|
|
3849
|
+
return 0
|
|
3850
|
+
fi
|
|
3851
|
+
# Dev-tier model param drives the fallback derivation (worktree streams are
|
|
3852
|
+
# development work). Resolve via the provider helper when available.
|
|
3853
|
+
local _loki_wt_primary=""
|
|
3854
|
+
if type provider_get_tier_param >/dev/null 2>&1; then
|
|
3855
|
+
_loki_wt_primary="$(provider_get_tier_param development 2>/dev/null || true)"
|
|
3856
|
+
fi
|
|
3857
|
+
if [ "${LOKI_AUTO_EFFORT:-on}" != "off" ] \
|
|
3858
|
+
&& type loki_effort_for_tier >/dev/null 2>&1 \
|
|
3859
|
+
&& type loki_claude_flag_supported >/dev/null 2>&1 \
|
|
3860
|
+
&& loki_claude_flag_supported "--effort"; then
|
|
3861
|
+
local _loki_wt_effort
|
|
3862
|
+
_loki_wt_effort="$(loki_effort_for_tier development "${DETECTED_COMPLEXITY:-${LOKI_COMPLEXITY:-standard}}")"
|
|
3863
|
+
[ -n "$_loki_wt_effort" ] && _LOKI_WT_AUTO_FLAGS+=("--effort" "$_loki_wt_effort")
|
|
3864
|
+
fi
|
|
3865
|
+
if [ "${LOKI_AUTO_BUDGET:-on}" != "off" ] \
|
|
3866
|
+
&& type loki_remaining_budget >/dev/null 2>&1 \
|
|
3867
|
+
&& type loki_claude_flag_supported >/dev/null 2>&1 \
|
|
3868
|
+
&& loki_claude_flag_supported "--max-budget-usd"; then
|
|
3869
|
+
local _loki_wt_rem
|
|
3870
|
+
_loki_wt_rem="$(loki_remaining_budget)"
|
|
3871
|
+
[ -n "$_loki_wt_rem" ] && _LOKI_WT_AUTO_FLAGS+=("--max-budget-usd" "$_loki_wt_rem")
|
|
3872
|
+
fi
|
|
3873
|
+
if [ "${LOKI_AUTO_FALLBACK:-on}" != "off" ] \
|
|
3874
|
+
&& [ -n "$_loki_wt_primary" ] \
|
|
3875
|
+
&& type loki_fallback_for_primary >/dev/null 2>&1 \
|
|
3876
|
+
&& type loki_claude_flag_supported >/dev/null 2>&1 \
|
|
3877
|
+
&& loki_claude_flag_supported "--fallback-model"; then
|
|
3878
|
+
local _loki_wt_fb
|
|
3879
|
+
_loki_wt_fb="$(loki_fallback_for_primary "$_loki_wt_primary")"
|
|
3880
|
+
[ -n "$_loki_wt_fb" ] && _LOKI_WT_AUTO_FLAGS+=("--fallback-model" "$_loki_wt_fb")
|
|
3881
|
+
fi
|
|
3882
|
+
if type loki_mcp_config_argv >/dev/null 2>&1 \
|
|
3883
|
+
&& type loki_claude_flag_supported >/dev/null 2>&1 \
|
|
3884
|
+
&& loki_claude_flag_supported "--mcp-config"; then
|
|
3885
|
+
local _loki_wt_mcp
|
|
3886
|
+
if _loki_wt_mcp="$(loki_mcp_config_argv)" && [ -n "$_loki_wt_mcp" ]; then
|
|
3887
|
+
_LOKI_WT_AUTO_FLAGS+=("--mcp-config")
|
|
3888
|
+
local _loki_wt_mcp_path
|
|
3889
|
+
for _loki_wt_mcp_path in $_loki_wt_mcp; do
|
|
3890
|
+
_LOKI_WT_AUTO_FLAGS+=("$_loki_wt_mcp_path")
|
|
3891
|
+
done
|
|
3892
|
+
# --strict-mcp-config only alongside a real bundle, never bare.
|
|
3893
|
+
if [ "${LOKI_STRICT_MCP:-1}" != "0" ] \
|
|
3894
|
+
&& loki_claude_flag_supported "--strict-mcp-config"; then
|
|
3895
|
+
_LOKI_WT_AUTO_FLAGS+=("--strict-mcp-config")
|
|
3896
|
+
fi
|
|
3897
|
+
fi
|
|
3898
|
+
fi
|
|
3899
|
+
return 0
|
|
3900
|
+
}
|
|
3901
|
+
|
|
3804
3902
|
# Spawn a Claude session in a worktree
|
|
3805
3903
|
spawn_worktree_session() {
|
|
3806
3904
|
local stream_name="$1"
|
|
@@ -3843,6 +3941,11 @@ spawn_worktree_session() {
|
|
|
3843
3941
|
|
|
3844
3942
|
log_step "Spawning ${PROVIDER_DISPLAY_NAME:-Claude} session: $stream_name"
|
|
3845
3943
|
|
|
3944
|
+
# Build the worktree auto-flag set (effort/max-budget/fallback/mcp-config)
|
|
3945
|
+
# BEFORE the ( subshell so it is inherited (a `bash -c` would not). Populates
|
|
3946
|
+
# the global _LOKI_WT_AUTO_FLAGS array. See _loki_build_worktree_claude_flags.
|
|
3947
|
+
_loki_build_worktree_claude_flags
|
|
3948
|
+
|
|
3846
3949
|
(
|
|
3847
3950
|
cd "$worktree_path" || exit 1
|
|
3848
3951
|
_wt_exit=0
|
|
@@ -3858,12 +3961,17 @@ spawn_worktree_session() {
|
|
|
3858
3961
|
if type loki_caveman_activate_env >/dev/null 2>&1; then
|
|
3859
3962
|
_loki_wt_cm="$(loki_caveman_activate_env)"
|
|
3860
3963
|
fi
|
|
3964
|
+
# Expand the auto-flag array with the bash-3.2 empty-array guard
|
|
3965
|
+
# (${arr[@]+...}) so a bare "${arr[@]}" under set -u does not
|
|
3966
|
+
# abort with "unbound variable" when no flags were collected.
|
|
3861
3967
|
if [ -n "$_loki_wt_cm" ]; then
|
|
3862
3968
|
CAVEMAN_DEFAULT_MODE="$_loki_wt_cm" claude --dangerously-skip-permissions \
|
|
3969
|
+
"${_LOKI_WT_AUTO_FLAGS[@]+"${_LOKI_WT_AUTO_FLAGS[@]}"}" \
|
|
3863
3970
|
-p "Loki Mode: $task_prompt. Read .loki/CONTINUITY.md for context." \
|
|
3864
3971
|
>> "$log_file" 2>&1 || _wt_exit=$?
|
|
3865
3972
|
else
|
|
3866
3973
|
claude --dangerously-skip-permissions \
|
|
3974
|
+
"${_LOKI_WT_AUTO_FLAGS[@]+"${_LOKI_WT_AUTO_FLAGS[@]}"}" \
|
|
3867
3975
|
-p "Loki Mode: $task_prompt. Read .loki/CONTINUITY.md for context." \
|
|
3868
3976
|
>> "$log_file" 2>&1 || _wt_exit=$?
|
|
3869
3977
|
fi
|
|
@@ -12138,16 +12246,31 @@ check_task_completion_signal() {
|
|
|
12138
12246
|
local fb_statement="${fb_content:-All PRD requirements implemented and tests passing}"
|
|
12139
12247
|
# Build minimal JSON payload
|
|
12140
12248
|
signal_file="$fallback_file"
|
|
12141
|
-
#
|
|
12142
|
-
#
|
|
12143
|
-
python3
|
|
12249
|
+
# Synthesize the payload into a TEMP file then atomically move it into
|
|
12250
|
+
# place, so the signal file is never observed truncated/empty/malformed:
|
|
12251
|
+
# a bare `python3 ... > "$fallback_file"` truncates the file BEFORE python
|
|
12252
|
+
# runs, so a python crash (or missing interpreter) mid-write would leave
|
|
12253
|
+
# an empty/partial file that downstream json.loads then silently drops.
|
|
12254
|
+
local _fb_tmp="${fallback_file}.tmp.$$"
|
|
12255
|
+
if python3 -c "
|
|
12144
12256
|
import json, sys
|
|
12145
|
-
|
|
12257
|
+
sys.stdout.write(json.dumps({
|
|
12146
12258
|
'statement': sys.argv[1][:1000],
|
|
12147
12259
|
'evidence': 'file-based completion via COMPLETION_REQUESTED fallback',
|
|
12148
12260
|
'confidence': 'medium',
|
|
12149
12261
|
'source': 'completion_requested_file_fallback'
|
|
12150
|
-
}))" "$fb_statement" > "$
|
|
12262
|
+
}))" "$fb_statement" > "$_fb_tmp" 2>/dev/null && [ -s "$_fb_tmp" ]; then
|
|
12263
|
+
mv -f "$_fb_tmp" "$fallback_file" 2>/dev/null || rm -f "$_fb_tmp" 2>/dev/null
|
|
12264
|
+
else
|
|
12265
|
+
# python unavailable or produced nothing: write a hand-built minimal
|
|
12266
|
+
# valid JSON (statement embedded with a conservative escape) atomically.
|
|
12267
|
+
rm -f "$_fb_tmp" 2>/dev/null
|
|
12268
|
+
local _fb_esc
|
|
12269
|
+
_fb_esc=$(printf '%s' "$fb_statement" | tr -d '"\\\n\r' | cut -c1-1000)
|
|
12270
|
+
printf '{"statement":"%s","evidence":"file-based completion via COMPLETION_REQUESTED fallback","confidence":"medium","source":"completion_requested_file_fallback"}' "$_fb_esc" > "${fallback_file}.tmp2.$$" 2>/dev/null \
|
|
12271
|
+
&& mv -f "${fallback_file}.tmp2.$$" "$fallback_file" 2>/dev/null \
|
|
12272
|
+
|| { rm -f "${fallback_file}.tmp2.$$" 2>/dev/null; printf '{"statement":"completion requested","evidence":"fallback","confidence":"low","source":"completion_requested_file_fallback"}' > "$fallback_file" 2>/dev/null; }
|
|
12273
|
+
fi
|
|
12151
12274
|
fi
|
|
12152
12275
|
|
|
12153
12276
|
if [ ! -f "$signal_file" ]; then
|
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/). Loki Mode is a spec-driven autonomous builder with a built-in trust layer that takes any spec to a deployed product and verifies completion with evidence (quality gates plus a completion council), not just a "done" claim. Complete installation instructions for all platforms and use cases.
|
|
4
4
|
|
|
5
|
-
**Version:** v7.
|
|
5
|
+
**Version:** v7.98.0
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
package/events/bus.py
CHANGED
|
@@ -130,6 +130,16 @@ class LokiEvent:
|
|
|
130
130
|
# dedup (_processed_ids + pending-file glob) and re-importing every flat
|
|
131
131
|
# line on each call. Derive a DETERMINISTIC id from the record content
|
|
132
132
|
# so repeated imports of the same line are idempotent.
|
|
133
|
+
#
|
|
134
|
+
# Width: the digest source is user-controllable (timestamp + type +
|
|
135
|
+
# payload). An 8-hex-char (32-bit) truncation collides at the birthday
|
|
136
|
+
# bound around ~77k distinct events (sqrt(2^32)); a collision makes two
|
|
137
|
+
# DISTINCT events share an id, so dedup (_processed_ids + pending glob)
|
|
138
|
+
# silently DROPS the second event. Widen to 16 hex chars (64 bits) so
|
|
139
|
+
# the collision bound moves to ~4 billion distinct events -- well past
|
|
140
|
+
# any realistic events.jsonl. This deterministic-derivation path is
|
|
141
|
+
# Python-only (bus.ts has no jsonl import and mints a random id when one
|
|
142
|
+
# is absent), so widening here needs no cross-language parity change.
|
|
133
143
|
event_id = data.get('id', '')
|
|
134
144
|
if not event_id:
|
|
135
145
|
digest_src = '%s|%s|%s' % (
|
|
@@ -137,7 +147,7 @@ class LokiEvent:
|
|
|
137
147
|
raw_type,
|
|
138
148
|
json.dumps(payload, sort_keys=True),
|
|
139
149
|
)
|
|
140
|
-
event_id = hashlib.sha1(digest_src.encode('utf-8')).hexdigest()[:
|
|
150
|
+
event_id = hashlib.sha1(digest_src.encode('utf-8')).hexdigest()[:16]
|
|
141
151
|
|
|
142
152
|
return cls(
|
|
143
153
|
id=event_id,
|
package/events/emit.sh
CHANGED
|
@@ -119,16 +119,27 @@ fi
|
|
|
119
119
|
|
|
120
120
|
# JSON escape helper: handles \, ", and control characters including newlines
|
|
121
121
|
#
|
|
122
|
-
# The sed pass escapes
|
|
123
|
-
#
|
|
124
|
-
#
|
|
125
|
-
#
|
|
126
|
-
#
|
|
127
|
-
#
|
|
128
|
-
#
|
|
122
|
+
# The sed pass escapes backslash, double-quote, tab and carriage-return to
|
|
123
|
+
# their named short forms (\\ \" \t \r); the first awk pass collapses embedded
|
|
124
|
+
# newlines to \n. Every REMAINING C0 control byte (0x01-0x08, 0x0B-0x0C,
|
|
125
|
+
# 0x0E-0x1F -- which includes backspace 0x08 and form-feed 0x0C) is invalid raw
|
|
126
|
+
# inside a JSON string and is escaped as \uXXXX by the final awk pass (its map
|
|
127
|
+
# covers all of i=1..31). Without that escaping json.loads / JSON.parse reject
|
|
128
|
+
# the line and consumers (dashboard _read_events, learning aggregator) silently
|
|
129
|
+
# drop it. The named short forms emitted by sed are already two-char ASCII by
|
|
130
|
+
# the time the final pass runs, so they are never re-escaped.
|
|
131
|
+
#
|
|
132
|
+
# NOTE: backspace/form-feed are deliberately NOT escaped in the sed pass.
|
|
133
|
+
# Doing so requires embedding the raw control bytes in the sed pattern, which
|
|
134
|
+
# editors silently strip -- that left the two empty-pattern sed no-ops this
|
|
135
|
+
# helper previously carried. An empty sed regex reuses the LAST applied regex,
|
|
136
|
+
# so those entries were dead AND a latent corruption hazard. The final awk
|
|
137
|
+
# pass already escapes every byte in i=1..31 (backspace and form-feed included)
|
|
138
|
+
# to the six-char uXXXX unicode escape, producing valid JSON, so the
|
|
139
|
+
# named-short-form variants are unnecessary.
|
|
129
140
|
json_escape() {
|
|
130
141
|
printf '%s' "$1" \
|
|
131
|
-
| sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g; s/\r/\\r/g
|
|
142
|
+
| sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g; s/\r/\\r/g' \
|
|
132
143
|
| awk '{if(NR>1) printf "\\n"; printf "%s", $0}' \
|
|
133
144
|
| awk 'BEGIN{for(i=1;i<=31;i++)m[sprintf("%c",i)]=sprintf("\\u%04x",i)}
|
|
134
145
|
{s=""; n=length($0);
|
|
@@ -163,9 +174,23 @@ EVENT=$(cat <<EOF
|
|
|
163
174
|
EOF
|
|
164
175
|
)
|
|
165
176
|
|
|
166
|
-
# Write event file
|
|
177
|
+
# Write event file atomically.
|
|
178
|
+
#
|
|
179
|
+
# A bare `echo "$EVENT" > "$EVENT_FILE"` is NON-atomic: a reader (bus.py
|
|
180
|
+
# get_pending_events / from_dict, dashboard) that globs `*.json` mid-write sees
|
|
181
|
+
# a truncated/partial file and either fails json.loads (event silently dropped)
|
|
182
|
+
# or, worse, reads a half-written record. Write to a sibling temp file in the
|
|
183
|
+
# SAME directory (so rename(2) is an atomic same-filesystem operation) and then
|
|
184
|
+
# rename into place; a `.tmp` suffix keeps the in-progress file out of the
|
|
185
|
+
# `*.json` glob readers use. Clean up the temp on any failure so partials never
|
|
186
|
+
# linger.
|
|
167
187
|
EVENT_FILE="$EVENTS_DIR/${TIMESTAMP//:/-}_$EVENT_ID.json"
|
|
168
|
-
|
|
188
|
+
EVENT_TMP="$EVENT_FILE.tmp"
|
|
189
|
+
if printf '%s\n' "$EVENT" > "$EVENT_TMP" && mv -f "$EVENT_TMP" "$EVENT_FILE"; then
|
|
190
|
+
:
|
|
191
|
+
else
|
|
192
|
+
rm -f "$EVENT_TMP" 2>/dev/null || true
|
|
193
|
+
fi
|
|
169
194
|
|
|
170
195
|
# Rotate events.jsonl if it exceeds 50MB (keep 1 backup)
|
|
171
196
|
EVENTS_LOG="$LOKI_DIR/events.jsonl"
|
package/loki-ts/dist/loki.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
var z8=Object.defineProperty;var X8=($)=>$;function K8($,Q){this[$]=X8.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)z8($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:K8.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var J$=import.meta.require;var y1={};b(y1,{lokiDir:()=>P,homeLokiDir:()=>T$,findRepoRootForVersion:()=>e$,REPO_ROOT:()=>h});import{resolve as t,dirname as i$}from"path";import{fileURLToPath as q8}from"url";import{existsSync as E$}from"fs";import{homedir as J8}from"os";function V8(){let $=h1;for(let Q=0;Q<6;Q++){if(E$(t($,"VERSION"))&&E$(t($,"autonomy/run.sh")))return $;let Z=i$($);if(Z===$)break;$=Z}return t(h1,"..","..","..")}function e$($){let Q=$;for(let Z=0;Z<6;Z++){if(E$(t(Q,"VERSION"))&&E$(t(Q,"autonomy/run.sh")))return Q;let z=i$(Q);if(z===Q)break;Q=z}return t($,"..","..","..")}function P(){return process.env.LOKI_DIR??t(process.cwd(),".loki")}function T$(){return t(J8(),".loki")}var h1,h;var C=L(()=>{h1=i$(q8(import.meta.url));h=V8()});import{readFileSync as W8}from"fs";import{resolve as U8,dirname as H8}from"path";import{fileURLToPath as G8}from"url";function N$(){if(Q$!==null)return Q$;let $="7.
|
|
2
|
+
var z8=Object.defineProperty;var X8=($)=>$;function K8($,Q){this[$]=X8.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)z8($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:K8.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var J$=import.meta.require;var y1={};b(y1,{lokiDir:()=>P,homeLokiDir:()=>T$,findRepoRootForVersion:()=>e$,REPO_ROOT:()=>h});import{resolve as t,dirname as i$}from"path";import{fileURLToPath as q8}from"url";import{existsSync as E$}from"fs";import{homedir as J8}from"os";function V8(){let $=h1;for(let Q=0;Q<6;Q++){if(E$(t($,"VERSION"))&&E$(t($,"autonomy/run.sh")))return $;let Z=i$($);if(Z===$)break;$=Z}return t(h1,"..","..","..")}function e$($){let Q=$;for(let Z=0;Z<6;Z++){if(E$(t(Q,"VERSION"))&&E$(t(Q,"autonomy/run.sh")))return Q;let z=i$(Q);if(z===Q)break;Q=z}return t($,"..","..","..")}function P(){return process.env.LOKI_DIR??t(process.cwd(),".loki")}function T$(){return t(J8(),".loki")}var h1,h;var C=L(()=>{h1=i$(q8(import.meta.url));h=V8()});import{readFileSync as W8}from"fs";import{resolve as U8,dirname as H8}from"path";import{fileURLToPath as G8}from"url";function N$(){if(Q$!==null)return Q$;let $="7.98.0";if(typeof $==="string"&&$.length>0)return Q$=$,Q$;try{let Q=H8(G8(import.meta.url)),Z=e$(Q);Q$=W8(U8(Z,"VERSION"),"utf-8").trim()}catch{Q$="unknown"}return Q$}var Q$=null;var $1=L(()=>{C()});var u1={};b(u1,{runOrThrow:()=>x8,run:()=>F,readStreamCapped:()=>f1,commandVersion:()=>N8,commandExists:()=>f,ShellError:()=>Q1,MAX_STDOUT_BYTES:()=>m1});async function f1($,Q=m1){let Z=$.getReader(),z=new TextDecoder,X="",q=0;try{while(q<Q){let{done:K,value:W}=await Z.read();if(K)break;if(!W)continue;if(q+=W.byteLength,q>Q){let V=W.byteLength-(q-Q);X+=z.decode(W.subarray(0,V),{stream:!0});break}X+=z.decode(W,{stream:!0})}X+=z.decode()}finally{try{await Z.cancel()}catch{}Z.releaseLock()}return X}async function F($,Q={}){let Z=Bun.spawn({cmd:[...$],stdout:"pipe",stderr:"pipe",env:Q.env?{...process.env,...Q.env}:process.env,cwd:Q.cwd}),z,X;if(Q.timeoutMs&&Q.timeoutMs>0)z=setTimeout(()=>{try{Z.kill("SIGTERM")}catch{}X=setTimeout(()=>{try{Z.kill("SIGKILL")}catch{}},2000)},Q.timeoutMs);try{let[q,K,W]=await Promise.all([f1(Z.stdout),new Response(Z.stderr).text(),Z.exited]);return{stdout:q,stderr:K,exitCode:W}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function x8($,Q={}){let Z=await F($,Q);if(Z.exitCode!==0)throw new Q1(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=E8($),Z=await F(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function E8($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function N8($,Q="--version"){if(!await f($))return null;let z=await F([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var m1=16777216,Q1;var d=L(()=>{Q1=class Q1 extends Error{message;exitCode;stdout;stderr;constructor($,Q,Z,z){super($);this.message=$;this.exitCode=Q;this.stdout=Z;this.stderr=z;this.name="ShellError"}}});function r($){return S8?"":$}var S8,T,S,_,lZ,I,k,y,J;var c=L(()=>{S8=(process.env.NO_COLOR??"").length>0;T=r("\x1B[0;31m"),S=r("\x1B[0;32m"),_=r("\x1B[1;33m"),lZ=r("\x1B[0;34m"),I=r("\x1B[0;36m"),k=r("\x1B[1m"),y=r("\x1B[2m"),J=r("\x1B[0m")});import{existsSync as u8}from"fs";async function Z$(){if(A$!==void 0)return A$;let $="/opt/homebrew/bin/python3.12";if(u8($))return A$=$,$;let Q=await f("python3.12");if(Q)return A$=Q,Q;let Z=await f("python3");return A$=Z,Z}async function z$($,Q={}){let Z=await Z$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return F([Z,"-c",$],Q)}var A$;var V$=L(()=>{d()});var q0={};b(q0,{runStatus:()=>W3});import{existsSync as v,readFileSync as U$,readdirSync as i1,statSync as e1}from"fs";import{resolve as D,basename as $3}from"path";import{homedir as Q3}from"os";function $0($){let Q=Math.trunc($);if(Q>=1e6)return`${(Math.trunc(Q/1e6*10)/10).toFixed(1)}M`;if(Q>=1000)return`${(Math.trunc(Q/1000*10)/10).toFixed(1)}K`;return String(Q)}function Q0($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*D$/Q);if(X>D$)X=D$;let q=D$-X,K=S;if(z>=80)K=T;else if(z>=50)K=_;let W="=".repeat(Math.max(0,X))+" ".repeat(Math.max(0,q)),V=$0($),U=$0(Q);return` ${k}${Z}${J} ${K}[${W}]${J} ${z}% (${V} / ${U})`}async function z3(){if(await f("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${J}
|
|
3
3
|
`),process.stdout.write(`Install with:
|
|
4
4
|
`),process.stdout.write(` brew install jq (macOS)
|
|
5
5
|
`),process.stdout.write(` apt install jq (Debian/Ubuntu)
|
|
@@ -802,4 +802,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
|
|
|
802
802
|
`),2}default:return process.stderr.write(`Unknown command: ${Q}
|
|
803
803
|
`),process.stderr.write(Z8),2}}r1();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var FZ=await jZ(Bun.argv.slice(2));process.exit(FZ);
|
|
804
804
|
|
|
805
|
-
//# debugId=
|
|
805
|
+
//# debugId=5C332E0B9079B5FC64756E2164756E21
|
package/mcp/__init__.py
CHANGED
package/memory/engine.py
CHANGED
|
@@ -1034,6 +1034,39 @@ class MemoryEngine:
|
|
|
1034
1034
|
|
|
1035
1035
|
return None
|
|
1036
1036
|
|
|
1037
|
+
@staticmethod
|
|
1038
|
+
def _parse_optional_datetime(
|
|
1039
|
+
raw: Any, *, record_id: str = "<unknown>", field: str = "last_accessed"
|
|
1040
|
+
) -> Optional[datetime]:
|
|
1041
|
+
"""Tolerantly parse an optional ISO datetime field off a stored record.
|
|
1042
|
+
|
|
1043
|
+
A corrupt/non-ISO value on ONE record must not raise out of the
|
|
1044
|
+
_dict_to_* converters and crash the whole batch list-comp in
|
|
1045
|
+
get_recent_episodes / find_patterns / list_skills (which would drop EVERY
|
|
1046
|
+
item in the scan, not just the bad one) on the RARV retrieval hot path.
|
|
1047
|
+
An unparseable value falls back to None (treated as never-accessed) so
|
|
1048
|
+
the record is still returned and retrievable.
|
|
1049
|
+
|
|
1050
|
+
Returns a tz-aware datetime (UTC assumed when naive), or None when the
|
|
1051
|
+
value is missing/empty/unparseable.
|
|
1052
|
+
"""
|
|
1053
|
+
if not raw:
|
|
1054
|
+
return None
|
|
1055
|
+
if isinstance(raw, datetime):
|
|
1056
|
+
return raw.replace(tzinfo=timezone.utc) if raw.tzinfo is None else raw
|
|
1057
|
+
if isinstance(raw, str):
|
|
1058
|
+
value = raw[:-1] if raw.endswith("Z") else raw
|
|
1059
|
+
try:
|
|
1060
|
+
parsed = datetime.fromisoformat(value)
|
|
1061
|
+
except ValueError:
|
|
1062
|
+
logger.warning(
|
|
1063
|
+
"Record %s has unparseable %s %r; treating as never-accessed",
|
|
1064
|
+
record_id, field, raw,
|
|
1065
|
+
)
|
|
1066
|
+
return None
|
|
1067
|
+
return parsed.replace(tzinfo=timezone.utc) if parsed.tzinfo is None else parsed
|
|
1068
|
+
return None
|
|
1069
|
+
|
|
1037
1070
|
def _dict_to_episode(self, data: Dict[str, Any]) -> EpisodeTrace:
|
|
1038
1071
|
"""Convert dictionary to EpisodeTrace."""
|
|
1039
1072
|
# Parse timestamp string to datetime
|
|
@@ -1081,20 +1114,13 @@ class MemoryEngine:
|
|
|
1081
1114
|
for e in errors_raw
|
|
1082
1115
|
]
|
|
1083
1116
|
|
|
1084
|
-
# Parse last_accessed datetime
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
last_accessed = datetime.fromisoformat(last_accessed_raw)
|
|
1092
|
-
if last_accessed.tzinfo is None:
|
|
1093
|
-
last_accessed = last_accessed.replace(tzinfo=timezone.utc)
|
|
1094
|
-
elif isinstance(last_accessed_raw, datetime):
|
|
1095
|
-
last_accessed = last_accessed_raw
|
|
1096
|
-
if last_accessed.tzinfo is None:
|
|
1097
|
-
last_accessed = last_accessed.replace(tzinfo=timezone.utc)
|
|
1117
|
+
# Parse last_accessed datetime (tolerant: a corrupt value falls back to
|
|
1118
|
+
# None so a single bad episode never crashes the retrieval batch).
|
|
1119
|
+
last_accessed = self._parse_optional_datetime(
|
|
1120
|
+
data.get("last_accessed"),
|
|
1121
|
+
record_id=data.get("id", "<unknown>"),
|
|
1122
|
+
field="last_accessed",
|
|
1123
|
+
)
|
|
1098
1124
|
|
|
1099
1125
|
return EpisodeTrace(
|
|
1100
1126
|
id=data.get("id", ""),
|
|
@@ -1119,21 +1145,14 @@ class MemoryEngine:
|
|
|
1119
1145
|
|
|
1120
1146
|
def _dict_to_pattern(self, data: Dict[str, Any]) -> SemanticPattern:
|
|
1121
1147
|
"""Convert dictionary to SemanticPattern."""
|
|
1122
|
-
# Parse last_used string to datetime or None
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
last_used = datetime.fromisoformat(last_used_raw)
|
|
1131
|
-
if last_used.tzinfo is None:
|
|
1132
|
-
last_used = last_used.replace(tzinfo=timezone.utc)
|
|
1133
|
-
elif isinstance(last_used_raw, datetime):
|
|
1134
|
-
last_used = last_used_raw
|
|
1135
|
-
if last_used.tzinfo is None:
|
|
1136
|
-
last_used = last_used.replace(tzinfo=timezone.utc)
|
|
1148
|
+
# Parse last_used string to datetime or None (tolerant: a corrupt value
|
|
1149
|
+
# on one pattern must not crash the find_patterns retrieval batch; it
|
|
1150
|
+
# shares the same hot path and crash mechanism as last_accessed below).
|
|
1151
|
+
last_used = self._parse_optional_datetime(
|
|
1152
|
+
data.get("last_used"),
|
|
1153
|
+
record_id=data.get("id", "<unknown>"),
|
|
1154
|
+
field="last_used",
|
|
1155
|
+
)
|
|
1137
1156
|
|
|
1138
1157
|
# Convert links dicts to Link objects
|
|
1139
1158
|
links_raw = data.get("links", [])
|
|
@@ -1142,20 +1161,12 @@ class MemoryEngine:
|
|
|
1142
1161
|
for link in links_raw
|
|
1143
1162
|
]
|
|
1144
1163
|
|
|
1145
|
-
# Parse last_accessed datetime
|
|
1146
|
-
last_accessed =
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
last_accessed_raw = last_accessed_raw[:-1]
|
|
1152
|
-
last_accessed = datetime.fromisoformat(last_accessed_raw)
|
|
1153
|
-
if last_accessed.tzinfo is None:
|
|
1154
|
-
last_accessed = last_accessed.replace(tzinfo=timezone.utc)
|
|
1155
|
-
elif isinstance(last_accessed_raw, datetime):
|
|
1156
|
-
last_accessed = last_accessed_raw
|
|
1157
|
-
if last_accessed.tzinfo is None:
|
|
1158
|
-
last_accessed = last_accessed.replace(tzinfo=timezone.utc)
|
|
1164
|
+
# Parse last_accessed datetime (tolerant: see _dict_to_episode).
|
|
1165
|
+
last_accessed = self._parse_optional_datetime(
|
|
1166
|
+
data.get("last_accessed"),
|
|
1167
|
+
record_id=data.get("id", "<unknown>"),
|
|
1168
|
+
field="last_accessed",
|
|
1169
|
+
)
|
|
1159
1170
|
|
|
1160
1171
|
return SemanticPattern(
|
|
1161
1172
|
id=data.get("id", ""),
|
|
@@ -1182,20 +1193,12 @@ class MemoryEngine:
|
|
|
1182
1193
|
for e in raw_errors
|
|
1183
1194
|
]
|
|
1184
1195
|
|
|
1185
|
-
# Parse last_accessed datetime
|
|
1186
|
-
last_accessed =
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
last_accessed_raw = last_accessed_raw[:-1]
|
|
1192
|
-
last_accessed = datetime.fromisoformat(last_accessed_raw)
|
|
1193
|
-
if last_accessed.tzinfo is None:
|
|
1194
|
-
last_accessed = last_accessed.replace(tzinfo=timezone.utc)
|
|
1195
|
-
elif isinstance(last_accessed_raw, datetime):
|
|
1196
|
-
last_accessed = last_accessed_raw
|
|
1197
|
-
if last_accessed.tzinfo is None:
|
|
1198
|
-
last_accessed = last_accessed.replace(tzinfo=timezone.utc)
|
|
1196
|
+
# Parse last_accessed datetime (tolerant: see _dict_to_episode).
|
|
1197
|
+
last_accessed = self._parse_optional_datetime(
|
|
1198
|
+
data.get("last_accessed"),
|
|
1199
|
+
record_id=data.get("id", "<unknown>"),
|
|
1200
|
+
field="last_accessed",
|
|
1201
|
+
)
|
|
1199
1202
|
|
|
1200
1203
|
return ProceduralSkill(
|
|
1201
1204
|
id=data.get("id", ""),
|
package/memory/rag_injector.py
CHANGED
|
@@ -9,10 +9,66 @@ for injection into the RARV prompt cycle.
|
|
|
9
9
|
|
|
10
10
|
import argparse
|
|
11
11
|
import json
|
|
12
|
+
import re
|
|
12
13
|
import sys
|
|
13
14
|
from pathlib import Path
|
|
14
15
|
|
|
15
16
|
|
|
17
|
+
# Per-field hard cap so a single oversized/garbage memory entry cannot dominate
|
|
18
|
+
# the injected context or push real signal out of the token budget.
|
|
19
|
+
_MAX_FIELD_CHARS = 600
|
|
20
|
+
|
|
21
|
+
# Control characters (excluding ordinary whitespace handled separately) that
|
|
22
|
+
# must never reach the prompt: they can corrupt rendering or smuggle hidden
|
|
23
|
+
# directives. Covers C0 controls and DEL.
|
|
24
|
+
_CONTROL_CHARS_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _sanitize_field(value, max_chars=_MAX_FIELD_CHARS):
|
|
28
|
+
"""Neutralize a single field from stored memory before it enters a prompt.
|
|
29
|
+
|
|
30
|
+
Memory entries are untrusted input: a malicious or garbage pattern could
|
|
31
|
+
contain prompt-injection payloads (fake instructions, fabricated section
|
|
32
|
+
headers, code-fence breakers, or directives that try to override the RARV
|
|
33
|
+
prompt). This collapses each field to a single safe inline line so it can
|
|
34
|
+
only ever read as data, never as structure or instructions.
|
|
35
|
+
|
|
36
|
+
Steps:
|
|
37
|
+
- Coerce non-strings to str (a dict/list value would otherwise break
|
|
38
|
+
formatting or interpolate unexpected structure).
|
|
39
|
+
- Drop control characters (keeps the visible text intact).
|
|
40
|
+
- Collapse ALL newlines/tabs/carriage-returns to single spaces so the
|
|
41
|
+
field cannot add new lines, fake "### " section headers, close the
|
|
42
|
+
knowledge block, or open/close a markdown code fence.
|
|
43
|
+
- Defang leading structural markdown markers (#, >, -, *, backticks) so the
|
|
44
|
+
field cannot pose as a heading/list/fence even after collapsing.
|
|
45
|
+
- Truncate to a bounded length.
|
|
46
|
+
"""
|
|
47
|
+
if value is None:
|
|
48
|
+
return ''
|
|
49
|
+
if not isinstance(value, str):
|
|
50
|
+
value = str(value)
|
|
51
|
+
# Remove control characters first.
|
|
52
|
+
value = _CONTROL_CHARS_RE.sub('', value)
|
|
53
|
+
# Collapse any vertical whitespace and tabs into single spaces. This is the
|
|
54
|
+
# core injection defense: without newlines the field cannot introduce new
|
|
55
|
+
# markdown structure or standalone instruction lines.
|
|
56
|
+
value = re.sub(r'[\r\n\t]+', ' ', value)
|
|
57
|
+
# Collapse runs of spaces left behind.
|
|
58
|
+
value = re.sub(r' {2,}', ' ', value).strip()
|
|
59
|
+
# Defang leading structural markdown so the field cannot masquerade as a
|
|
60
|
+
# heading, blockquote, list item, or code fence.
|
|
61
|
+
value = re.sub(r'^[#>\-*`]+\s*', '', value)
|
|
62
|
+
# Defang markdown header markers anywhere in the (now single-line) value so
|
|
63
|
+
# a collapsed payload cannot reintroduce a "### " section marker inline.
|
|
64
|
+
# Backticks are neutralized too so a field cannot open/close a code fence.
|
|
65
|
+
value = re.sub(r'#{1,6}\s', '', value)
|
|
66
|
+
value = value.replace('`', '')
|
|
67
|
+
if len(value) > max_chars:
|
|
68
|
+
value = value[:max_chars].rstrip() + '...'
|
|
69
|
+
return value
|
|
70
|
+
|
|
71
|
+
|
|
16
72
|
def build_rag_context(query, max_tokens=2000, knowledge_dir=None):
|
|
17
73
|
"""Build RAG context from the knowledge graph.
|
|
18
74
|
|
|
@@ -39,11 +95,20 @@ def build_rag_context(query, max_tokens=2000, knowledge_dir=None):
|
|
|
39
95
|
total_chars = 0
|
|
40
96
|
|
|
41
97
|
for p in patterns:
|
|
42
|
-
# Support both 'name'/'pattern' and 'description' fields
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
98
|
+
# Support both 'name'/'pattern' and 'description' fields.
|
|
99
|
+
# Every field is sanitized: memory entries are untrusted input and must
|
|
100
|
+
# not be able to inject instructions or break the prompt structure.
|
|
101
|
+
name = _sanitize_field(p.get('name', p.get('pattern', 'Unknown Pattern')))
|
|
102
|
+
desc = _sanitize_field(p.get('description', p.get('correct_approach', '')))
|
|
103
|
+
category = _sanitize_field(p.get('category', ''))
|
|
104
|
+
source_raw = p.get('_source_project', '')
|
|
105
|
+
# Path().name strips any directory traversal; sanitize the basename too.
|
|
106
|
+
source = _sanitize_field(Path(str(source_raw)).name) if source_raw else ''
|
|
107
|
+
|
|
108
|
+
# name may be empty after sanitization (e.g. a field that was only
|
|
109
|
+
# markdown markers); fall back so the heading is never blank.
|
|
110
|
+
if not name:
|
|
111
|
+
name = 'Unknown Pattern'
|
|
47
112
|
|
|
48
113
|
section = '### ' + name
|
|
49
114
|
if category:
|
|
@@ -52,7 +117,7 @@ def build_rag_context(query, max_tokens=2000, knowledge_dir=None):
|
|
|
52
117
|
if desc:
|
|
53
118
|
section += desc + '\n'
|
|
54
119
|
if source:
|
|
55
|
-
section += '_Source: ' +
|
|
120
|
+
section += '_Source: ' + source + '_\n'
|
|
56
121
|
|
|
57
122
|
if total_chars + len(section) > max_chars:
|
|
58
123
|
break
|
package/memory/retrieval.py
CHANGED
|
@@ -269,6 +269,7 @@ class MemoryRetrieval:
|
|
|
269
269
|
vector_indices: Optional[Dict[str, VectorIndex]] = None,
|
|
270
270
|
base_path: str = ".loki/memory",
|
|
271
271
|
namespace: Optional[str] = None,
|
|
272
|
+
include_unstamped_legacy: bool = False,
|
|
272
273
|
):
|
|
273
274
|
"""
|
|
274
275
|
Initialize the memory retrieval system.
|
|
@@ -279,12 +280,20 @@ class MemoryRetrieval:
|
|
|
279
280
|
vector_indices: Optional dict of vector indices (episodic, semantic, skills)
|
|
280
281
|
base_path: Base path for memory storage directory
|
|
281
282
|
namespace: Optional namespace for scoped retrieval
|
|
283
|
+
include_unstamped_legacy: Opt-in escape hatch. When False (the
|
|
284
|
+
secure default), legacy entries that lack a "_namespace" stamp
|
|
285
|
+
are EXCLUDED from a namespaced query. This prevents a silent
|
|
286
|
+
cross-namespace leak where one project reads another project's
|
|
287
|
+
unstamped memory. Set True only when migrating a single-project
|
|
288
|
+
store whose entries predate namespace stamping and you have
|
|
289
|
+
verified every entry belongs to the active namespace.
|
|
282
290
|
"""
|
|
283
291
|
self.storage = storage
|
|
284
292
|
self.embedding_engine = embedding_engine
|
|
285
293
|
self.vector_indices = vector_indices or {}
|
|
286
294
|
self.base_path = Path(base_path)
|
|
287
295
|
self._namespace = namespace
|
|
296
|
+
self._include_unstamped_legacy = include_unstamped_legacy
|
|
288
297
|
# Track when indices were last built to detect staleness (BUG-MEM-002).
|
|
289
298
|
# When consolidation modifies patterns, indices become stale and should
|
|
290
299
|
# be rebuilt before the next similarity search.
|
|
@@ -318,25 +327,45 @@ class MemoryRetrieval:
|
|
|
318
327
|
Behavior:
|
|
319
328
|
- If self._namespace is None, accept all (backward compat for unscoped retrieval).
|
|
320
329
|
- If result has "_namespace" matching, accept.
|
|
321
|
-
- If result lacks "_namespace" (legacy entry written before stamping)
|
|
322
|
-
|
|
323
|
-
|
|
330
|
+
- If result lacks "_namespace" (legacy entry written before stamping):
|
|
331
|
+
treat it conservatively. An unstamped entry has no provable origin,
|
|
332
|
+
so under a namespaced query it could belong to ANY namespace and
|
|
333
|
+
including it is a silent cross-namespace leak (one project reading
|
|
334
|
+
another's memory). By default (self._include_unstamped_legacy is
|
|
335
|
+
False) such entries are EXCLUDED, with a rate-limited warning telling
|
|
336
|
+
operators to re-save the entry to add a stamp. Operators who have
|
|
337
|
+
verified a single-project store predates stamping can opt back in via
|
|
338
|
+
include_unstamped_legacy=True.
|
|
339
|
+
- Otherwise (stamp present but does not match), reject.
|
|
324
340
|
"""
|
|
325
341
|
if self._namespace is None:
|
|
326
342
|
return True
|
|
327
343
|
result_ns = result.get("_namespace")
|
|
328
344
|
if result_ns is None:
|
|
329
|
-
# Legacy entry without namespace stamp
|
|
330
|
-
#
|
|
345
|
+
# Legacy entry without namespace stamp. No provable origin -> do not
|
|
346
|
+
# silently leak it across namespaces. Exclude by default; warn so
|
|
347
|
+
# operators can re-save to add a stamp (rate-limited to avoid spam).
|
|
331
348
|
if MemoryRetrieval._legacy_warned_count < MemoryRetrieval._LEGACY_WARN_LIMIT:
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
349
|
+
if self._include_unstamped_legacy:
|
|
350
|
+
logger.warning(
|
|
351
|
+
"Memory entry id=%s lacks '_namespace' stamp (legacy "
|
|
352
|
+
"entry). Including under namespace=%s because "
|
|
353
|
+
"include_unstamped_legacy is set. Re-save this entry to "
|
|
354
|
+
"stamp it and remove the opt-in.",
|
|
355
|
+
result.get("id", "<unknown>"),
|
|
356
|
+
self._namespace,
|
|
357
|
+
)
|
|
358
|
+
else:
|
|
359
|
+
logger.warning(
|
|
360
|
+
"Memory entry id=%s lacks '_namespace' stamp (legacy "
|
|
361
|
+
"entry). Excluding from namespace=%s query to prevent a "
|
|
362
|
+
"cross-namespace leak. Re-save this entry to stamp it, "
|
|
363
|
+
"or pass include_unstamped_legacy=True to opt in.",
|
|
364
|
+
result.get("id", "<unknown>"),
|
|
365
|
+
self._namespace,
|
|
366
|
+
)
|
|
338
367
|
MemoryRetrieval._legacy_warned_count += 1
|
|
339
|
-
return
|
|
368
|
+
return self._include_unstamped_legacy
|
|
340
369
|
return result_ns == self._namespace
|
|
341
370
|
|
|
342
371
|
def with_namespace(self, namespace: str) -> "MemoryRetrieval":
|
|
@@ -361,6 +390,7 @@ class MemoryRetrieval:
|
|
|
361
390
|
vector_indices=self.vector_indices,
|
|
362
391
|
base_path=str(self.base_path),
|
|
363
392
|
namespace=namespace,
|
|
393
|
+
include_unstamped_legacy=self._include_unstamped_legacy,
|
|
364
394
|
)
|
|
365
395
|
|
|
366
396
|
# -------------------------------------------------------------------------
|
package/memory/storage.py
CHANGED
|
@@ -33,6 +33,29 @@ except ImportError:
|
|
|
33
33
|
# Default namespace constant
|
|
34
34
|
DEFAULT_NAMESPACE = "default"
|
|
35
35
|
|
|
36
|
+
# Allowed namespace characters. A namespace becomes a single path segment under
|
|
37
|
+
# the memory root, so it must not contain separators, traversal, or whitespace.
|
|
38
|
+
_NAMESPACE_RE = re.compile(r'^[a-zA-Z0-9_-]+$')
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _validate_namespace_charset(namespace: str) -> None:
|
|
42
|
+
"""Reject a namespace whose characters could escape its directory.
|
|
43
|
+
|
|
44
|
+
Charset-only check, shared by ``__init__`` and ``with_namespace`` so the two
|
|
45
|
+
validation sites cannot drift. Callers are responsible for deciding whether a
|
|
46
|
+
None/empty namespace is acceptable (it is in ``__init__`` for backward
|
|
47
|
+
compat; it is rejected in ``with_namespace``); this only runs once a concrete
|
|
48
|
+
non-default namespace string is present.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
ValueError: If the namespace contains anything outside [A-Za-z0-9_-].
|
|
52
|
+
"""
|
|
53
|
+
if not _NAMESPACE_RE.match(namespace):
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f"Invalid namespace '{namespace}': "
|
|
56
|
+
"only alphanumeric characters, hyphens, and underscores are allowed"
|
|
57
|
+
)
|
|
58
|
+
|
|
36
59
|
|
|
37
60
|
class MemoryStorage:
|
|
38
61
|
"""
|
|
@@ -73,14 +96,11 @@ class MemoryStorage:
|
|
|
73
96
|
self._root_path = Path(effective_base)
|
|
74
97
|
self._namespace = namespace
|
|
75
98
|
|
|
76
|
-
# Validate namespace to prevent path traversal
|
|
99
|
+
# Validate namespace to prevent path traversal. None/empty is accepted
|
|
100
|
+
# here for backward compat (it selects the default, un-namespaced root);
|
|
101
|
+
# only a concrete non-default namespace is charset-checked.
|
|
77
102
|
if namespace and namespace != DEFAULT_NAMESPACE:
|
|
78
|
-
|
|
79
|
-
if not re.match(r'^[a-zA-Z0-9_-]+$', namespace):
|
|
80
|
-
raise ValueError(
|
|
81
|
-
f"Invalid namespace '{namespace}': "
|
|
82
|
-
"only alphanumeric characters, hyphens, and underscores are allowed"
|
|
83
|
-
)
|
|
103
|
+
_validate_namespace_charset(namespace)
|
|
84
104
|
|
|
85
105
|
# Calculate effective base path (with namespace if specified)
|
|
86
106
|
if namespace and namespace != DEFAULT_NAMESPACE:
|
|
@@ -119,15 +139,21 @@ class MemoryStorage:
|
|
|
119
139
|
New MemoryStorage instance for the specified namespace
|
|
120
140
|
|
|
121
141
|
Raises:
|
|
122
|
-
ValueError: If namespace
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
142
|
+
ValueError: If namespace is empty/None/non-string, or contains
|
|
143
|
+
characters outside [A-Za-z0-9_-] (path-traversal defense).
|
|
144
|
+
"""
|
|
145
|
+
# with_namespace is an explicit "switch to this namespace" call, so an
|
|
146
|
+
# empty, None, whitespace-only, or non-string namespace is meaningless
|
|
147
|
+
# and must be rejected rather than silently resolving to the default
|
|
148
|
+
# root (which is what __init__ would do with a falsy namespace). Reject,
|
|
149
|
+
# do not normalize: normalization would mask a caller bug.
|
|
150
|
+
if not isinstance(namespace, str) or not namespace.strip():
|
|
151
|
+
raise ValueError(
|
|
152
|
+
f"Invalid namespace {namespace!r}: "
|
|
153
|
+
"must be a non-empty string"
|
|
154
|
+
)
|
|
155
|
+
if namespace != DEFAULT_NAMESPACE:
|
|
156
|
+
_validate_namespace_charset(namespace)
|
|
131
157
|
return MemoryStorage(
|
|
132
158
|
base_path=str(self._root_path),
|
|
133
159
|
namespace=namespace,
|
|
@@ -1328,9 +1354,20 @@ class MemoryStorage:
|
|
|
1328
1354
|
base = min(1.0, base + 0.05 * min(len(errors), 3))
|
|
1329
1355
|
|
|
1330
1356
|
# Access frequency boost (diminishing returns).
|
|
1331
|
-
# `or 0` guards
|
|
1332
|
-
#
|
|
1357
|
+
# `or 0` guards an explicit null access_count; the isinstance/`< 0`
|
|
1358
|
+
# clamp additionally guards a non-numeric (e.g. a stored "5") or negative
|
|
1359
|
+
# value (corrupt or hand-edited record) reaching the `> 0` comparison and
|
|
1360
|
+
# log1p() below. A bare string raises TypeError on `"5" > 0`, and a
|
|
1361
|
+
# negative <= -1 raises a math domain error in log1p; bool is excluded so
|
|
1362
|
+
# a stray True is not treated as a count of 1. All coerce to 0 (no boost)
|
|
1363
|
+
# so importance scoring never crashes the scan.
|
|
1333
1364
|
access_count = memory.get("access_count") or 0
|
|
1365
|
+
if (
|
|
1366
|
+
not isinstance(access_count, (int, float))
|
|
1367
|
+
or isinstance(access_count, bool)
|
|
1368
|
+
or access_count < 0
|
|
1369
|
+
):
|
|
1370
|
+
access_count = 0
|
|
1334
1371
|
if access_count > 0:
|
|
1335
1372
|
# Log scale boost, caps at about 0.15 for 100+ accesses
|
|
1336
1373
|
access_boost = 0.05 * math.log1p(access_count)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loki-mode",
|
|
3
3
|
"mcpName": "io.github.asklokesh/loki-mode",
|
|
4
|
-
"version": "7.
|
|
4
|
+
"version": "7.98.0",
|
|
5
5
|
"description": "Loki Mode by Autonomi. Autonomous spec-to-product system: takes a PRD, GitHub issue, OpenAPI/JSON/YAML, or one-line brief to a deployed app via the RARV-C closure loop with 8 quality gates. Provider-agnostic (Claude Code, OpenAI Codex, Cline, Aider).",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"agent",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "loki-mode",
|
|
4
4
|
"displayName": "Loki Mode",
|
|
5
|
-
"version": "7.
|
|
5
|
+
"version": "7.98.0",
|
|
6
6
|
"description": "Autonomous spec-to-product build system with a built-in trust layer (RARV-C closure loop, 8 quality gates, completion council). Ships Loki's spec-hardening, drift-detection, and deterministic PR verification commands plus the Loki MCP server.",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "Autonomi",
|