Skip to content

refactor: Ideal State Purification (Mypy Strict & Ruff Clean) - #69

Merged
Cipher208 merged 4 commits into
masterfrom
feat/ideal-state-purification
Aug 11, 2026
Merged

refactor: Ideal State Purification (Mypy Strict & Ruff Clean)#69
Cipher208 merged 4 commits into
masterfrom
feat/ideal-state-purification

Conversation

@Cipher208

@Cipher208 Cipher208 commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Achieved absolute engineering excellence: 100% Mypy strict coverage, zero Ruff issues, and modularized high-complexity components.

Summary by CodeRabbit

  • Bug Fixes

    • Improved handling of missing, null, or unexpected database values across memory, search, wiki, graph, and lifecycle operations.
    • Prevented crashes when generated IDs or aggregate counts are unavailable.
    • Improved encrypted data normalization and legacy data handling.
    • Wiki search and listing results now consistently normalize fields and tags.
  • Refactor

    • Expanded type safety and consistency across public tools and services.
    • Improved retrieval routing and hook dispatch without changing expected behavior.
    • Strengthened configuration value validation and conversion.

@github-actions github-actions Bot added the chore label Aug 11, 2026
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Cipher208, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 45 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 504a8ec7-c194-44e5-989e-d89250697510

📥 Commits

Reviewing files that changed from the base of the PR and between aeff058 and 54a18b9.

📒 Files selected for processing (17)
  • config.py
  • features/backup.py
  • features/import_export.py
  • features/secrets.py
  • hooks/loader.py
  • lifecycle/emotion/engine.py
  • mcp_server/middlewares.py
  • mcp_server/server.py
  • mcp_server/tools/episodic.py
  • mcp_server/tools/graph.py
  • mcp_server/tools/ops.py
  • mcp_server/tools/session.py
  • pyproject.toml
  • rag/conflict.py
  • rag/search.py
  • shared/saga/impl/base.py
  • shared/saga/impl/crypto.py

Walkthrough

The pull request tightens type annotations across the application, normalizes configuration and database values, updates MCP tool hook dispatch, refactors RAG route matching, and improves handling of missing or non-dictionary data.

Changes

Core data and configuration normalization

Layer / File(s) Summary
Configuration and memory data contracts
config.py, core/*, features/*
Configuration values, memory results, IDs, metadata, and feature outputs now use explicit types and normalized values.
Graph and wiki data paths
graph/*, wiki/*
Graph rows, wiki configuration, tags, search results, and manager helpers now normalize values and declare concrete return types.

MCP tool contracts and hook dispatch

Layer / File(s) Summary
MCP middleware and registry contracts
mcp_server/app.py, mcp_server/context.py, mcp_server/middlewares.py, mcp_server/registry.py, mcp_server/server.py
MCP middleware, registry, server, and rate-limit paths now use explicit types.
MCP tool execution paths
mcp_server/tools/*, mcp_server/tools_layer.py
Tool contexts and results use parameterized types. Tools call _fire_hook directly and normalize rate-limit and database results.

RAG routing and search flow

Layer / File(s) Summary
Route matching
rag/router.py
Route selection now uses dedicated recent, wiki, entity, graph, and semantic matching helpers.
Search and result normalization
rag/search.py, rag/searcher.py, rag/engine.py, rag/ingestor.py, rag/conflict.py, rag/quantize.py
RAG search inputs, callbacks, IDs, scores, counts, and formatted results now use explicit types and normalized values.

Shared state, lifecycle, and crypto handling

Layer / File(s) Summary
Lifecycle and shared processing
lifecycle/*, shared/importance/*, shared/middleware.py, shared/memory_types.py, shared/archived_memories.py, shared/dream_buffer.py
Lifecycle counts, row handling, scores, IDs, middleware execution, and regex fields now have defensive conversions and concrete annotations.
Saga state and crypto exports
shared/saga/*, features/secrets.py
Saga state reads normalize non-dictionary data. Crypto helpers expose is_encrypted_blob and remove redundant casts.

Static analysis and supporting type fixes

Layer / File(s) Summary
Analysis configuration and imports
pyproject.toml, hooks/*, shared/embeddings.py, core/session.py
Mypy and Ruff settings are stricter. Callable annotations and optional import diagnostics are explicit.

Estimated code review effort: 3 (Moderate) | ~25 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 35.52% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the refactor and its primary goals: strict Mypy compliance and zero Ruff issues.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ideal-state-purification

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Aug 11, 2026

Copy link
Copy Markdown

Greptile Summary

The PR broadly strengthens typing and normalizes database-facing values across memory, wiki, graph, lifecycle, and MCP tool code.

  • Enables stricter static-analysis configuration and adds concrete annotations.
  • Refactors hook dispatch and registration-related modules.
  • Normalizes nullable database values, identifiers, counts, tags, and serialized fields.

Confidence Score: 2/5

The PR is not safe to merge because episode persistence, wiki tag serialization, and hook-dispatch indirection remain broken on current paths.

Episode saves still fail from the SQL binding mismatch, wiki searches still return character-split tags, and modular tool calls still bypass the hook symbol patched by the established integration path.

Files Needing Attention: core/episodic.py, mcp_server/tools/wiki.py, mcp_server/tools/memory.py, mcp_server/tools/episodic.py, mcp_server/tools/graph.py, mcp_server/tools/ops.py, mcp_server/tools/session.py, mcp_server/tools_layer.py

Important Files Changed

Filename Overview
core/episodic.py Adds stricter result conversion and row typing around episodic persistence.
mcp_server/tools/wiki.py Refactors wiki tool result normalization and return annotations.
mcp_server/tools/memory.py Replaces tools-layer hook references with directly imported hook dispatch and adds stricter response typing.
mcp_server/tools_layer.py Reorganizes the compatibility and dispatch surface for modular MCP tools.
mcp_server/context.py Adds constructor typing while retaining explicit registration of user and agent hook instances.

Reviews (4): Last reviewed commit: "style: apply ruff format" | Re-trigger Greptile

Comment thread core/episodic.py
cursor = await conn.execute(
"INSERT INTO episodes (user_id, summary, emotional_weight, tags, created_at) VALUES (?, ?, ?, ?, ?)",
(user_id, summary, emotional_weight, json.dumps(tags or []), time.time()),
(user_id, summary, emotional_weight, json.dumps(tags or [])),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Episode INSERT binding mismatch

When any episode is saved, this four-value tuple is bound to an INSERT with five placeholders, causing SQLite to raise a binding-count error before the episode is persisted.

Suggested change
(user_id, summary, emotional_weight, json.dumps(tags or [])),
(user_id, summary, emotional_weight, json.dumps(tags or []), time.time()),
Prompt To Fix With AI
This is a comment left during a code review.
Path: core/episodic.py
Line: 51

Comment:
**Episode INSERT binding mismatch**

When any episode is saved, this four-value tuple is bound to an `INSERT` with five placeholders, causing SQLite to raise a binding-count error before the episode is persisted.

```suggestion
            (user_id, summary, emotional_weight, json.dumps(tags or []), time.time()),
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Codex

Comment thread mcp_server/tools/wiki.py
_invalidate_cache,
_get_recall_cache,
_set_recall_cache,
_fire_hook,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Hook dispatch bypasses indirection

When the existing hook tests replace tools_layer._fire_hook, these direct imports retain their own binding to base._fire_hook, so the tracking callback is never invoked and hook assertions fail across the changed memory, episodic, graph, ops, and session tools.

Prompt To Fix With AI
This is a comment left during a code review.
Path: mcp_server/tools/memory.py
Line: 19

Comment:
**Hook dispatch bypasses indirection**

When the existing hook tests replace `tools_layer._fire_hook`, these direct imports retain their own binding to `base._fire_hook`, so the tracking callback is never invoked and hook assertions fail across the changed memory, episodic, graph, ops, and session tools.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Codex

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

🧹 Nitpick comments (2)
lifecycle/compactor.py (1)

53-54: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Preserve SQLite row identifiers in compaction errors.

The connection manager configures SQLite rows as aiosqlite.Row, not dict. Therefore, this branch logs "unknown" for every archive failure and loses the candidate ID. Extract row["id"] with a safe fallback instead of checking only dict.

Proposed fix
             except (KeyError, RuntimeError):
-                rid = row["id"] if isinstance(row, dict) else "unknown"
+                try:
+                    rid = row["id"]
+                except (KeyError, IndexError, TypeError):
+                    rid = "unknown"
                 logger.exception("Failed to archive memory %s", rid)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lifecycle/compactor.py` around lines 53 - 54, Update the row-ID extraction in
the archive error handling around the compactor’s logger.exception call to
support aiosqlite.Row and other mapping-like rows, while retaining a safe
fallback when the ID cannot be read. Ensure candidate IDs are preserved in
compaction failure logs instead of defaulting to "unknown" for non-dict rows.
mcp_server/tools_layer.py (1)

34-34: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Preserve the asynchronous return type.

base._fire_hook is async and returns dict[str, Any] (mcp_server/tools/base.py Lines 120-128). Returning Any hides the awaitable contract.

Make _fire_hook_wrapper async with a dict[str, Any] return type, or annotate the current wrapper as returning Awaitable[dict[str, Any]].

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcp_server/tools_layer.py` at line 34, Update _fire_hook_wrapper to preserve
base._fire_hook’s asynchronous contract by making it async with a dict[str, Any]
return annotation, or by annotating its current form as Awaitable[dict[str,
Any]]. Ensure the wrapper continues returning the awaited hook result.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@core/__init__.py`:
- Around line 35-43: Update recall so cached values are returned only when
cached is a list; treat any non-list value as a cache miss, continue through the
existing l4.search and episodic search flow, and overwrite the invalid cache
entry with the resulting memories.

In `@core/episodic.py`:
- Around line 51-54: Add the missing created_at timestamp bind value to the
parameter tuple in save, matching the five placeholders in its SQL statement;
preserve the existing user_id, summary, emotional_weight, and serialized tags
bindings and commit/return behavior.

In `@mcp_server/middlewares.py`:
- Line 26: Update the allowed_origins configuration used by CORSMiddleware to
validate that config.get("cors", "allowed_origins") returns a list containing
only strings, falling back safely when absent or invalid. Replace wildcard-port
entries such as http://localhost:* with exact origins or configure an equivalent
allow_origin_regex so localhost and 127.0.0.1 ports match correctly.

In `@mcp_server/tools/ops.py`:
- Around line 374-381: Update _delete_graph to delete related epi_tags and
epi_edges rows within the same transaction before deleting matching epi_nodes.
Use the selected node IDs or equivalent predicates to target only dependents of
the specified user_id and cutoff, preserving the existing purge scope and
transaction handling.
- Around line 355-381: Update _delete_staging, _delete_audit, and _delete_graph
to use the active context connection manager instead of constructing services
with their default global manager. Pass that manager into DreamBuffer,
AuditTrail, and EpistemicGraph, or reuse the corresponding app-owned instances,
while preserving the existing purge queries and results.
- Around line 335-353: Remove the finally-block connection closures from
_delete_core and _delete_episodes, since
app.mm.user_memory(...)._cm.get(DB_NAME) may return the same cached connection
to concurrent purge tasks. Keep commit and result handling intact, and rely on
the connection manager for the shared connection lifecycle (or otherwise use
explicitly dedicated, coordinated connections).
- Around line 187-188: Update the async memory_api_key and memory_backup
handlers to avoid executing synchronous storage operations directly on the event
loop. Use the available async APIs, or route EncryptedStore save/revoke and
backup, restore, manifest, and directory operations through a serialized
asyncio.to_thread path while preserving existing behavior and responses.

In `@mcp_server/tools/wiki.py`:
- Around line 27-41: Update the result construction in wiki_search to
deserialize each result’s tags value before returning it, using the shared tag
parser or equivalent safe JSON-list normalization. Ensure JSON-encoded tag
strings become lists of tag names, while missing or invalid values produce an
empty list, and preserve the existing title, type, and count behavior.

In `@rag/ingestor.py`:
- Around line 82-84: Update the page ID retrieval in the ingestion flow to raise
an error when SELECT last_insert_rowid() returns no row or NULL, instead of
assigning 0. Ensure the failure occurs before chunk insertion so the transaction
rolls back, while preserving normal insertion when a valid page ID is returned.

In `@rag/search.py`:
- Line 126: Update the search_rrf call in searcher.py to pass the existing
default_bin_for callback as binary_for_fn, matching the callback used by the
other retrieval path. Ensure the RRF flow performs binary retrieval and
preserves hybrid result ranking; alternatively, provide an equivalent default
encoder within search_rrf.
- Line 229: Update the result mapping in RAGEngine.search so the wiki_type value
from metadata.get("wiki_type") remains None when absent instead of being
converted to the string "None"; otherwise apply the API’s established
empty-value contract while preserving non-null values.
- Line 138: Update the bin_ranks construction in search_binary to key results by
page_id rather than chunk id, deduplicating multiple chunks from the same page
by retaining the best (lowest) binary rank. Keep the resulting keys consistent
with the page lookups at Lines 159-160 so binary-only pages remain included and
ranks cannot collide across chunk IDs.

In `@wiki/shared.py`:
- Around line 26-33: Update get_enabled_types to validate wiki_cfg and layer_cfg
with isinstance(..., dict) before calling .get(); treat any non-dict wiki or
layer configuration as empty and return all_types. Apply the same nested-mapping
validation to the corresponding configuration access in the additional affected
code path.

---

Nitpick comments:
In `@lifecycle/compactor.py`:
- Around line 53-54: Update the row-ID extraction in the archive error handling
around the compactor’s logger.exception call to support aiosqlite.Row and other
mapping-like rows, while retaining a safe fallback when the ID cannot be read.
Ensure candidate IDs are preserved in compaction failure logs instead of
defaulting to "unknown" for non-dict rows.

In `@mcp_server/tools_layer.py`:
- Line 34: Update _fire_hook_wrapper to preserve base._fire_hook’s asynchronous
contract by making it async with a dict[str, Any] return annotation, or by
annotating its current form as Awaitable[dict[str, Any]]. Ensure the wrapper
continues returning the awaited hook result.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0cd427af-be61-4e6e-bed4-203bb3965106

📥 Commits

Reviewing files that changed from the base of the PR and between 10ac082 and aeff058.

📒 Files selected for processing (55)
  • .pytest_cache/v/cache/lastfailed
  • config.py
  • core/__init__.py
  • core/episodic.py
  • core/memory.py
  • core/session.py
  • features/auth/api_key.py
  • features/backup_cron.py
  • features/dashboard.py
  • features/import_export.py
  • features/secrets.py
  • graph/epistemic.py
  • graph/temporal.py
  • hooks/loader.py
  • hooks/models.py
  • hooks/registry.py
  • lifecycle/compactor.py
  • lifecycle/consolidation.py
  • lifecycle/emotion/engine.py
  • lifecycle/forgetting.py
  • mcp_server/app.py
  • mcp_server/context.py
  • mcp_server/middlewares.py
  • mcp_server/registry.py
  • mcp_server/server.py
  • mcp_server/tools/episodic.py
  • mcp_server/tools/graph.py
  • mcp_server/tools/memory.py
  • mcp_server/tools/ops.py
  • mcp_server/tools/session.py
  • mcp_server/tools/wiki.py
  • mcp_server/tools_layer.py
  • mcp_server/utils/circuit_breaker.py
  • pyproject.toml
  • rag/conflict.py
  • rag/engine.py
  • rag/ingestor.py
  • rag/quantize.py
  • rag/router.py
  • rag/schema.py
  • rag/search.py
  • rag/searcher.py
  • shared/archived_memories.py
  • shared/dream_buffer.py
  • shared/embeddings.py
  • shared/importance/scorer.py
  • shared/importance/signals/emotion_signal.py
  • shared/memory_types.py
  • shared/middleware.py
  • shared/saga/__init__.py
  • shared/saga/impl/base.py
  • shared/saga/impl/crypto.py
  • wiki/index.py
  • wiki/manager.py
  • wiki/shared.py
💤 Files with no reviewable changes (1)
  • .pytest_cache/v/cache/lastfailed

Comment thread core/__init__.py
Comment on lines +35 to +43
async def recall(self, query: str, limit: int = 10) -> list[dict[str, Any]]:
cache_key = f"recall:{self.user_id}:{query}:{limit}"
cached = self._cache.get(cache_key) if self._cache else None
cached: Any = self._cache.get(cache_key) if self._cache else None
if cached is not None:
return cached
return list(cached) if isinstance(cached, list) else []

results = []
results.extend(await self.l4.search(self.user_id, query, limit))
results: list[dict[str, Any]] = []
l4_hits = await self.l4.search(self.user_id, query, limit)
results.extend(l4_hits)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Treat invalid cache values as cache misses.

At Line 39, a non-list cache value returns [] and skips both L4 and episodic searches. A malformed or legacy cache entry can therefore hide valid memories. Fall through to the storage search and overwrite the invalid cache value.

Proposed fix
         if cached is not None:
-            return list(cached) if isinstance(cached, list) else []
+            if isinstance(cached, list):
+                return list(cached)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async def recall(self, query: str, limit: int = 10) -> list[dict[str, Any]]:
cache_key = f"recall:{self.user_id}:{query}:{limit}"
cached = self._cache.get(cache_key) if self._cache else None
cached: Any = self._cache.get(cache_key) if self._cache else None
if cached is not None:
return cached
return list(cached) if isinstance(cached, list) else []
results = []
results.extend(await self.l4.search(self.user_id, query, limit))
results: list[dict[str, Any]] = []
l4_hits = await self.l4.search(self.user_id, query, limit)
results.extend(l4_hits)
async def recall(self, query: str, limit: int = 10) -> list[dict[str, Any]]:
cache_key = f"recall:{self.user_id}:{query}:{limit}"
cached: Any = self._cache.get(cache_key) if self._cache else None
if cached is not None:
if isinstance(cached, list):
return list(cached)
results: list[dict[str, Any]] = []
l4_hits = await self.l4.search(self.user_id, query, limit)
results.extend(l4_hits)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/__init__.py` around lines 35 - 43, Update recall so cached values are
returned only when cached is a list; treat any non-list value as a cache miss,
continue through the existing l4.search and episodic search flow, and overwrite
the invalid cache entry with the resulting memories.

Comment thread core/episodic.py
Comment on lines +51 to +54
(user_id, summary, emotional_weight, json.dumps(tags or [])),
)
await conn.commit()
return cursor.lastrowid
return int(cursor.lastrowid or 0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Restore the created_at bind value.

The SQL statement has five placeholders, but the tuple has four values. Every call to save fails with a binding-count error. Add the timestamp as the fifth value.

Proposed fix
-            (user_id, summary, emotional_weight, json.dumps(tags or [])),
+            (user_id, summary, emotional_weight, json.dumps(tags or []), time.time()),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
(user_id, summary, emotional_weight, json.dumps(tags or [])),
)
await conn.commit()
return cursor.lastrowid
return int(cursor.lastrowid or 0)
(user_id, summary, emotional_weight, json.dumps(tags or []), time.time()),
)
await conn.commit()
return int(cursor.lastrowid or 0)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/episodic.py` around lines 51 - 54, Add the missing created_at timestamp
bind value to the parameter tuple in save, matching the five placeholders in its
SQL statement; preserve the existing user_id, summary, emotional_weight, and
serialized tags bindings and commit/return behavior.

Comment thread mcp_server/middlewares.py
def add_middlewares(app: Starlette) -> None:
app.add_middleware(AuthMiddleware)
allowed_origins = config.get("cors", "allowed_origins", default=["http://localhost:*", "http://127.0.0.1:*"])
allowed_origins: list[str] = config.get("cors", "allowed_origins", default=["http://localhost:*", "http://127.0.0.1:*"])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- candidate files ---'
git ls-files 'mcp_server/**/*.py' '*requirements*' '*pyproject.toml' '*uv.lock' '*poetry.lock' | sed -n '1,120p'

printf '%s\n' '--- middleware source ---'
cat -n mcp_server/middlewares.py | sed -n '1,100p'

printf '%s\n' '--- Starlette pins and CORS usage ---'
rg -n -i 'starlette|allow_origins|allow_origin_regex|allowed_origins|cors' \
  --glob '!node_modules' --glob '!dist' --glob '!build' . | sed -n '1,240p'

printf '%s\n' '--- config.get definitions and call sites ---'
rg -n 'def get\(|async def get\(|config\.get\(' mcp_server | sed -n '1,240p'

Repository: Cipher208/mcp-ariel-memory

Length of output: 5650


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- configuration sources ---'
git ls-files | rg '(^|/)(config(\.py|/)|.*config.*\.py$|config\.yaml)$' | sed -n '1,160p'
rg -n -C 8 'class .*Config|def get\(|allowed_origins|cors:' --glob '*.py' --glob '*.yaml' --glob '*.yml' . | sed -n '1,260p'

printf '%s\n' '--- dependency declarations ---'
sed -n '25,45p' pyproject.toml
sed -n '2518,2535p' uv.lock

printf '%s\n' '--- Starlette 1.3.1 CORS implementation ---'
curl -fsSL https://raw.githubusercontent.com/encode/starlette/1.3.1/starlette/middleware/cors.py | nl -ba | sed -n '1,240p'

Repository: Cipher208/mcp-ariel-memory

Length of output: 12843


🏁 Script executed:

#!/bin/bash
set -eu

tmp=$(mktemp)
curl -fsSL https://raw.githubusercontent.com/encode/starlette/1.3.1/starlette/middleware/cors.py > "$tmp"

printf '%s\n' '--- Starlette 1.3.1 CORS implementation ---'
sed -n '1,260p' "$tmp"

printf '%s\n' '--- config implementation and current YAML ---'
cat -n config.py | sed -n '1,90p'
cat -n config.yaml | sed -n '1,140p'

printf '%s\n' '--- all CORS configuration references ---'
rg -n -C 5 'allowed_origins|allow_origins|allow_origin_regex|cors:' . \
  --glob '!uv.lock' --glob '!node_modules' --glob '!dist' --glob '!build' | sed -n '1,220p'

Repository: Cipher208/mcp-ariel-memory

Length of output: 16516


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
import re

# Standalone probe of the relevant Starlette 1.3.1 CORSMiddleware logic.
def is_allowed_origin(allow_origins, allow_origin_regex, origin):
    allow_all_origins = "*" in allow_origins
    compiled = re.compile(allow_origin_regex) if allow_origin_regex is not None else None
    if allow_all_origins:
        return True
    if compiled is not None and compiled.fullmatch(origin):
        return True
    return origin in allow_origins

defaults = ["http://localhost:*", "http://127.0.0.1:*"]
for origin in [
    "http://localhost:3000",
    "http://127.0.0.1:5173",
    "http://localhost:*",
    "http://example.com",
]:
    print(f"default origins; {origin!r}: {is_allowed_origin(defaults, None, origin)}")

regex = r"^https?://(?:localhost|127\.0\.0\.1):\d+$"
for origin in ["http://localhost:3000", "http://127.0.0.1:5173", "http://example.com"]:
    print(f"regex origins; {origin!r}: {is_allowed_origin([], regex, origin)}")

for configured in [None, "http://localhost:3000", ["http://localhost:3000", 3000]]:
    try:
        result = "*" in configured
        print(f"configured={configured!r}: init membership check -> {result!r}; annotation conversion -> unchanged")
    except Exception as exc:
        print(f"configured={configured!r}: init membership check -> {type(exc).__name__}: {exc}")
PY

Repository: Cipher208/mcp-ariel-memory

Length of output: 793


Use a valid CORS origin matcher and validate the configuration.

CORSMiddleware performs exact matching for allow_origins; http://localhost:* does not allow http://localhost:3000. Use exact origins or allow_origin_regex. Validate config.get as list[str]; its annotation does not convert None, strings, or non-string list items. A None value causes Starlette initialization to raise TypeError.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcp_server/middlewares.py` at line 26, Update the allowed_origins
configuration used by CORSMiddleware to validate that config.get("cors",
"allowed_origins") returns a list containing only strings, falling back safely
when absent or invalid. Replace wildcard-port entries such as http://localhost:*
with exact origins or configure an equivalent allow_origin_regex so localhost
and 127.0.0.1 ports match correctly.

Source: Path instructions

Comment thread mcp_server/tools/ops.py
Comment on lines +187 to +188
ctx: Context[Any, Any] | None = None,
) -> dict[str, Any]:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Move synchronous storage work off the event loop.

memory_api_key calls synchronous EncryptedStore.save through APIKeyAuth.create_key and APIKeyAuth.revoke (features/auth/store.py Lines 19-70). memory_backup performs synchronous backup, restore, manifest, and directory operations (features/backup_cron.py Lines 112-141 and 174-212).

These calls run inside async MCP handlers and can stall other requests. Use async APIs or a serialized asyncio.to_thread path.
As per path instructions, mcp_server/**/*.py must avoid blocking calls in async functions.

Also applies to: 207-208

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcp_server/tools/ops.py` around lines 187 - 188, Update the async
memory_api_key and memory_backup handlers to avoid executing synchronous storage
operations directly on the event loop. Use the available async APIs, or route
EncryptedStore save/revoke and backup, restore, manifest, and directory
operations through a serialized asyncio.to_thread path while preserving existing
behavior and responses.

Source: Path instructions

Comment thread mcp_server/tools/ops.py
Comment on lines +335 to 353
async def _delete_core() -> int:
conn = await app.mm.user_memory(user_id).l4._cm.get(DB_NAME)
try:
cursor = await conn.execute("DELETE FROM core_memory WHERE user_id=? AND created_at > ?", (user_id, cutoff))
result = cursor.rowcount
result = int(cursor.rowcount)
await conn.commit()
return result
finally:
await conn.close()

async def _delete_episodes():
async def _delete_episodes() -> int:
conn = await app.mm.user_memory(user_id).l3._cm.get(DB_NAME)
try:
cursor = await conn.execute("DELETE FROM episodes WHERE user_id=? AND created_at > ?", (user_id, cutoff))
result = cursor.rowcount
result = int(cursor.rowcount)
await conn.commit()
return result
finally:
await conn.close()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Do not close cached connections from concurrent purge tasks.

AsyncConnectionManager.get caches one connection per database (shared/connection.py Lines 142-162). _delete_core and _delete_episodes run concurrently through asyncio.gather and can share the same connection. Their finally blocks can close it while another helper is executing or committing.

Let the connection manager own the shared connection lifecycle, or allocate dedicated connections and coordinate the purge.
As per path instructions, mcp_server/**/*.py requires correct async connection lifecycle handling.

Also applies to: 361-385

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcp_server/tools/ops.py` around lines 335 - 353, Remove the finally-block
connection closures from _delete_core and _delete_episodes, since
app.mm.user_memory(...)._cm.get(DB_NAME) may return the same cached connection
to concurrent purge tasks. Keep commit and result handling intact, and rely on
the connection manager for the shared connection lifecycle (or otherwise use
explicitly dedicated, coordinated connections).

Source: Path instructions

Comment thread rag/ingestor.py
Comment on lines 82 to +84
cursor = await conn.execute("SELECT last_insert_rowid()")
row = await cursor.fetchone()
page_id = row[0]
page_id = int(row[0]) if row and row[0] is not None else 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Fail when SQLite does not return an inserted page ID.

If SELECT last_insert_rowid() returns no row or NULL, Line 84 sets page_id to 0. The following chunk inserts then use page_id=0, and RAGEngine.ingest_text treats 0 as no page ID. This silently creates orphaned chunks. Raise before chunk insertion so the transaction rolls back.

Proposed fix
             cursor = await conn.execute("SELECT last_insert_rowid()")
             row = await cursor.fetchone()
-            page_id = int(row[0]) if row and row[0] is not None else 0
+            if row is None or row[0] is None:
+                raise RuntimeError("SQLite did not return the inserted page ID")
+            page_id = int(row[0])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
cursor = await conn.execute("SELECT last_insert_rowid()")
row = await cursor.fetchone()
page_id = row[0]
page_id = int(row[0]) if row and row[0] is not None else 0
cursor = await conn.execute("SELECT last_insert_rowid()")
row = await cursor.fetchone()
if row is None or row[0] is None:
raise RuntimeError("SQLite did not return the inserted page ID")
page_id = int(row[0])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rag/ingestor.py` around lines 82 - 84, Update the page ID retrieval in the
ingestion flow to raise an error when SELECT last_insert_rowid() returns no row
or NULL, instead of assigning 0. Ensure the failure occurs before chunk
insertion so the transaction rolls back, while preserving normal insertion when
a valid page ID is returned.

Comment thread rag/search.py
limit: int,
k: int = 60,
binary_for_fn=None,
binary_for_fn: Callable[[list[float]], bytes] | None = None,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Pass the binary encoder on the RRF path.

search_rrf skips binary retrieval when binary_for_fn is None. The provided call in rag/searcher.py Line 81 omits this callback, so hybrid queries always produce FTS-only ranks. Pass the same default_bin_for used by rag/searcher.py Line 66, or keep a default encoder inside search_rrf.

Proposed caller fix
- search_rrf(..., fts_available=fts_ready, binary_dim=self.binary_dim)
+ search_rrf(..., binary_for_fn=default_bin_for, fts_available=fts_ready, binary_dim=self.binary_dim)

As per path instructions, RAG search must preserve search result ranking consistency.

Also applies to: 135-138

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rag/search.py` at line 126, Update the search_rrf call in searcher.py to pass
the existing default_bin_for callback as binary_for_fn, matching the callback
used by the other retrieval path. Ensure the RRF flow performs binary retrieval
and preserves hybrid result ranking; alternatively, provide an equivalent
default encoder within search_rrf.

Source: Path instructions

Comment thread rag/search.py
if binary_for_fn:
with suppress(Exception):
bin_results = await search_binary(cm, query, user_id, limit * 3, binary_for_fn, binary_dim)
bin_ranks = {r["id"]: rank for rank, r in enumerate(bin_results)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Key binary ranks by page ID.

search_binary returns the chunk ID in id and the page ID in page_id. FTS ranks use page IDs. Line 138 keys bin_ranks with chunk IDs, but Lines 159-160 fetch pages by those keys. Binary-only results can disappear, and ID collisions can attach ranks to the wrong pages. Deduplicate by page_id and retain the best binary rank.

Proposed fix
-            bin_ranks = {r["id"]: rank for rank, r in enumerate(bin_results)}
+            bin_ranks = {}
+            for rank, r in enumerate(bin_results):
+                bin_ranks.setdefault(r["page_id"], rank)

As per path instructions, RAG search must preserve search result ranking consistency.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
bin_ranks = {r["id"]: rank for rank, r in enumerate(bin_results)}
bin_ranks = {}
for rank, r in enumerate(bin_results):
bin_ranks.setdefault(r["page_id"], rank)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rag/search.py` at line 138, Update the bin_ranks construction in
search_binary to key results by page_id rather than chunk id, deduplicating
multiple chunks from the same page by retaining the best (lowest) binary rank.
Keep the resulting keys consistent with the page lookups at Lines 159-160 so
binary-only pages remain included and ranks cannot collide across chunk IDs.

Source: Path instructions

Comment thread rag/search.py
"wiki_type": c.wiki_type,
"score": c.final_score or c.rrf_score,
"source": c.source,
"wiki_type": str(c.wiki_type),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve nullable wiki_type values.

The RAG schema allows wiki_type to be NULL, and RAGEngine.search passes the value from metadata.get("wiki_type"). str(c.wiki_type) converts a missing value into the literal string "None". Preserve None or apply the API's defined empty-value contract.

Proposed fix
-        "wiki_type": str(c.wiki_type),
+        "wiki_type": None if c.wiki_type is None else str(c.wiki_type),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"wiki_type": str(c.wiki_type),
"wiki_type": None if c.wiki_type is None else str(c.wiki_type),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rag/search.py` at line 229, Update the result mapping in RAGEngine.search so
the wiki_type value from metadata.get("wiki_type") remains None when absent
instead of being converted to the string "None"; otherwise apply the API’s
established empty-value contract while preserving non-null values.

Comment thread wiki/shared.py
Comment on lines 26 to +33
def get_enabled_types(layer: str, all_types: list[str]) -> list[str]:
"""Return wiki types enabled in config for the given layer."""
cfg = load_config()
layer_cfg = cfg.get("wiki", {}).get(layer, {})
wiki_cfg: dict[str, Any] = cfg.get("wiki", {})
layer_cfg: dict[str, Any] = wiki_cfg.get(layer, {})
if not layer_cfg:
return all_types
return [t for t in all_types if layer_cfg.get(t, True)]
return [t for t in all_types if bool(layer_cfg.get(t, True))]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Validate nested configuration mappings before access.

load_config() validates only the top-level result. If cfg["wiki"] or cfg["wiki"][layer] is None, a list, or a scalar, Line 29/Line 30 and Line 39/Line 40 call .get() on a non-mapping and raise AttributeError. This breaks wiki operations instead of using the documented fallback. Check each nested value with isinstance(..., dict) before accessing it.

Proposed fix
 def get_enabled_types(layer: str, all_types: list[str]) -> list[str]:
     cfg = load_config()
-    wiki_cfg: dict[str, Any] = cfg.get("wiki", {})
-    layer_cfg: dict[str, Any] = wiki_cfg.get(layer, {})
+    wiki_cfg = cfg.get("wiki")
+    if not isinstance(wiki_cfg, dict):
+        return all_types
+    layer_cfg = wiki_cfg.get(layer)
+    if not isinstance(layer_cfg, dict):
+        return all_types
     if not layer_cfg:
         return all_types

Also applies to: 36-42

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@wiki/shared.py` around lines 26 - 33, Update get_enabled_types to validate
wiki_cfg and layer_cfg with isinstance(..., dict) before calling .get(); treat
any non-dict wiki or layer configuration as empty and return all_types. Apply
the same nested-mapping validation to the corresponding configuration access in
the additional affected code path.

@Cipher208
Cipher208 merged commit 0dd33b5 into master Aug 11, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant