fix(memory): fix learning loop to retrieve patterns and gotchas (#530)

* perf: fix frontend lag with batched IPC events and optimized store updates

Critical performance fixes addressing 2-5s UI lag during task execution:

Frontend optimizations:
- Batch IPC log events (100+/sec → 6/sec batched updates)
- Add batchAppendLogs to task-store for efficient log appending
- Only set updatedAt on phase changes, not every progress tick
- Memoize sanitizeMarkdownForDisplay and formatRelativeTime in TaskCard
- Add React.memo with custom comparators to DroppableColumn
- Use IntersectionObserver to pause animations when cards not visible
- Reduce debug logging verbosity

Backend optimizations:
- Add project index caching with 5-minute TTL in client.py
- Batch StatusManager file writes with threading.Timer debounce

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore(deps): update all frontend dependencies including major versions

Updates all frontend dependencies to latest versions:
- react-resizable-panels 3.0.6 → 4.2.0 (breaking API change)
- globals 16.5.0 → 17.0.0
- lucide-react 0.560.0 → 0.562.0
- zod 4.2.1 → 4.3.4
- Plus other minor/patch updates

Updates TerminalGrid.tsx for react-resizable-panels v4 API:
- PanelGroup → Group
- PanelResizeHandle → Separator
- direction → orientation
- Removed order prop
- Changed div wrappers to React.Fragment for proper resize handling

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(memory): fix learning loop to retrieve patterns and gotchas

The memory system was storing patterns and gotchas correctly (100% working)
but never retrieving them for agent prompts. The root cause was that
get_relevant_context() only performed generic semantic search without
filtering for specific episode types.

Changes:
- Add get_patterns_and_gotchas() method to search.py that specifically
  retrieves PATTERN and GOTCHA episodes with focused queries
- Add min_score filtering to reduce noise from low-relevance results
- Add wrapper method to graphiti.py facade class
- Update memory_manager.py to call new method and format results into
  dedicated "Learned Patterns" and "Known Gotchas" sections

This enables cross-session learning where patterns discovered in session 1
will now be available to sessions 2, 3, 4, etc.

* memory is now a app wide setting

* fix(security): address PR review findings for cache and subprocess safety

Fixes the following issues from PR review:

HIGH severity:
- Return defensive copies from _get_cached_project_data() to prevent
  cache corruption when callers modify returned dictionaries
- Validate head_sha before subprocess calls using _validate_git_ref()
  to prevent command injection attacks

MEDIUM severity:
- Add timeout=120 to worktree add subprocess call
- Add timeout=30 to worktree remove/prune subprocess calls
- Add bounds checking to worktree list parsing to prevent IndexError
- Validate head_sha fallback to head_branch (catches invalid refs early)
- Add AttributeError to exception handling in search.py JSON parsing

FALSE POSITIVES (already implemented):
- QA fixer/reviewer memory context - both files already have
  get_graphiti_context() calls at lines 106 and 92 respectively

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-01-02 11:06:23 +01:00
committed by GitHub
parent 30f7951a53
commit f58c257824
13 changed files with 515 additions and 42 deletions
+17
View File
@@ -257,16 +257,33 @@ async def run_autonomous_agent(
phase_thinking_budget = get_phase_thinking_budget(spec_dir, current_phase)
# Create client (fresh context) with phase-specific model and thinking
# Use appropriate agent_type for correct tool permissions and thinking budget
client = create_client(
project_dir,
spec_dir,
phase_model,
agent_type="planner" if first_run else "coder",
max_thinking_tokens=phase_thinking_budget,
)
# Generate appropriate prompt
if first_run:
prompt = generate_planner_prompt(spec_dir, project_dir)
# Retrieve Graphiti memory context for planning phase
# This gives the planner knowledge of previous patterns, gotchas, and insights
planner_context = await get_graphiti_context(
spec_dir,
project_dir,
{
"description": "Planning implementation for new feature",
"id": "planner",
},
)
if planner_context:
prompt += "\n\n" + planner_context
print_status("Graphiti memory context loaded for planner", "success")
first_run = False
current_log_phase = LogPhase.PLANNING
+37 -1
View File
@@ -146,6 +146,12 @@ async def get_graphiti_context(
# Get relevant context
context_items = await memory.get_relevant_context(query, num_results=5)
# Get patterns and gotchas specifically (THE FIX for learning loop!)
# This retrieves PATTERN and GOTCHA episode types for cross-session learning
patterns, gotchas = await memory.get_patterns_and_gotchas(
query, num_results=3, min_score=0.5
)
# Also get recent session history
session_history = await memory.get_session_history(limit=3)
@@ -156,10 +162,12 @@ async def get_graphiti_context(
"memory",
"Graphiti context retrieval complete",
context_items_found=len(context_items) if context_items else 0,
patterns_found=len(patterns) if patterns else 0,
gotchas_found=len(gotchas) if gotchas else 0,
session_history_found=len(session_history) if session_history else 0,
)
if not context_items and not session_history:
if not context_items and not session_history and not patterns and not gotchas:
if is_debug_enabled():
debug("memory", "No relevant context found in Graphiti")
return None
@@ -175,6 +183,34 @@ async def get_graphiti_context(
item_type = item.get("type", "unknown")
sections.append(f"- **[{item_type}]** {content}\n")
# Add patterns section (cross-session learning)
if patterns:
sections.append("### Learned Patterns\n")
sections.append("_Patterns discovered in previous sessions:_\n")
for p in patterns:
pattern_text = p.get("pattern", "")
applies_to = p.get("applies_to", "")
if applies_to:
sections.append(
f"- **Pattern**: {pattern_text}\n _Applies to:_ {applies_to}\n"
)
else:
sections.append(f"- **Pattern**: {pattern_text}\n")
# Add gotchas section (cross-session learning)
if gotchas:
sections.append("### Known Gotchas\n")
sections.append("_Pitfalls to avoid:_\n")
for g in gotchas:
gotcha_text = g.get("gotcha", "")
solution = g.get("solution", "")
if solution:
sections.append(
f"- **Gotcha**: {gotcha_text}\n _Solution:_ {solution}\n"
)
else:
sections.append(f"- **Gotcha**: {gotcha_text}\n")
if session_history:
sections.append("### Recent Session Insights\n")
for session in session_history[:2]: # Only show last 2
+6 -2
View File
@@ -12,6 +12,7 @@ The client factory now uses AGENT_CONFIGS from agents/tools_pkg/models.py as the
single source of truth for phase-aware tool and MCP server configuration.
"""
import copy
import json
import logging
import os
@@ -61,7 +62,8 @@ def _get_cached_project_data(
f"[ClientCache] Cache HIT for project index (age: {cache_age:.1f}s / TTL: {_CACHE_TTL_SECONDS}s)"
)
logger.debug(f"Using cached project index for {project_dir}")
return cached_index, cached_capabilities
# Return deep copies to prevent callers from corrupting the cache
return copy.deepcopy(cached_index), copy.deepcopy(cached_capabilities)
elif debug:
print(
f"[ClientCache] Cache EXPIRED for project index (age: {cache_age:.1f}s > TTL: {_CACHE_TTL_SECONDS}s)"
@@ -91,10 +93,12 @@ def _get_cached_project_data(
print(
"[ClientCache] Cache was populated by another thread, using cached data"
)
return cached_index, cached_capabilities
# Return deep copies to prevent callers from corrupting the cache
return copy.deepcopy(cached_index), copy.deepcopy(cached_capabilities)
# Either no cache entry or it's expired - store our fresh data
_PROJECT_INDEX_CACHE[key] = (project_index, project_capabilities, time.time())
# Return the freshly loaded data (no need to copy since it's not from cache)
return project_index, project_capabilities
@@ -343,6 +343,34 @@ class GraphitiMemory:
return await self._search.get_similar_task_outcomes(task_description, limit)
async def get_patterns_and_gotchas(
self,
query: str,
num_results: int = 5,
min_score: float = 0.5,
) -> tuple[list[dict], list[dict]]:
"""
Get patterns and gotchas relevant to the query.
This method specifically retrieves PATTERN and GOTCHA episode types
to enable cross-session learning. Unlike get_relevant_context(),
it filters for these specific types rather than doing generic search.
Args:
query: Search query (task description)
num_results: Max results per type
min_score: Minimum relevance score (0.0-1.0)
Returns:
Tuple of (patterns, gotchas) lists
"""
if not await self._ensure_initialized():
return [], []
return await self._search.get_patterns_and_gotchas(
query, num_results, min_score
)
# Status and utility methods
def get_status_summary(self) -> dict:
@@ -10,6 +10,8 @@ import logging
from pathlib import Path
from .schema import (
EPISODE_TYPE_GOTCHA,
EPISODE_TYPE_PATTERN,
EPISODE_TYPE_SESSION_INSIGHT,
EPISODE_TYPE_TASK_OUTCOME,
MAX_CONTEXT_RESULTS,
@@ -55,6 +57,7 @@ class GraphitiSearch:
query: str,
num_results: int = MAX_CONTEXT_RESULTS,
include_project_context: bool = True,
min_score: float = 0.0,
) -> list[dict]:
"""
Search for relevant context based on a query.
@@ -104,6 +107,12 @@ class GraphitiSearch:
}
)
# Filter by minimum score if specified
if min_score > 0:
context_items = [
item for item in context_items if item.get("score", 0) >= min_score
]
logger.info(
f"Found {len(context_items)} relevant context items for: {query[:50]}..."
)
@@ -153,7 +162,7 @@ class GraphitiSearch:
):
continue
sessions.append(data)
except (json.JSONDecodeError, TypeError):
except (json.JSONDecodeError, TypeError, AttributeError):
continue
# Sort by session number and return latest
@@ -205,7 +214,7 @@ class GraphitiSearch:
"score": getattr(result, "score", 0.0),
}
)
except (json.JSONDecodeError, TypeError):
except (json.JSONDecodeError, TypeError, AttributeError):
continue
return outcomes[:limit]
@@ -213,3 +222,107 @@ class GraphitiSearch:
except Exception as e:
logger.warning(f"Failed to get similar task outcomes: {e}")
return []
async def get_patterns_and_gotchas(
self,
query: str,
num_results: int = 5,
min_score: float = 0.5,
) -> tuple[list[dict], list[dict]]:
"""
Retrieve patterns and gotchas relevant to the current task.
Unlike get_relevant_context(), this specifically filters for
EPISODE_TYPE_PATTERN and EPISODE_TYPE_GOTCHA episodes to enable
cross-session learning.
Args:
query: Search query (task description)
num_results: Max results per type
min_score: Minimum relevance score (0.0-1.0)
Returns:
Tuple of (patterns, gotchas) lists
"""
patterns = []
gotchas = []
try:
# Search with query focused on patterns
pattern_results = await self.client.graphiti.search(
query=f"pattern: {query}",
group_ids=[self.group_id],
num_results=num_results * 2,
)
for result in pattern_results:
content = getattr(result, "content", None) or getattr(
result, "fact", None
)
score = getattr(result, "score", 0.0)
if score < min_score:
continue
if content and EPISODE_TYPE_PATTERN in str(content):
try:
data = (
json.loads(content) if isinstance(content, str) else content
)
if data.get("type") == EPISODE_TYPE_PATTERN:
patterns.append(
{
"pattern": data.get("pattern", ""),
"applies_to": data.get("applies_to", ""),
"example": data.get("example", ""),
"score": score,
}
)
except (json.JSONDecodeError, TypeError, AttributeError):
continue
# Search with query focused on gotchas
gotcha_results = await self.client.graphiti.search(
query=f"gotcha pitfall avoid: {query}",
group_ids=[self.group_id],
num_results=num_results * 2,
)
for result in gotcha_results:
content = getattr(result, "content", None) or getattr(
result, "fact", None
)
score = getattr(result, "score", 0.0)
if score < min_score:
continue
if content and EPISODE_TYPE_GOTCHA in str(content):
try:
data = (
json.loads(content) if isinstance(content, str) else content
)
if data.get("type") == EPISODE_TYPE_GOTCHA:
gotchas.append(
{
"gotcha": data.get("gotcha", ""),
"trigger": data.get("trigger", ""),
"solution": data.get("solution", ""),
"score": score,
}
)
except (json.JSONDecodeError, TypeError, AttributeError):
continue
# Sort by score and limit
patterns.sort(key=lambda x: x.get("score", 0), reverse=True)
gotchas.sort(key=lambda x: x.get("score", 0), reverse=True)
logger.info(
f"Found {len(patterns)} patterns and {len(gotchas)} gotchas for: {query[:50]}..."
)
return patterns[:num_results], gotchas[:num_results]
except Exception as e:
logger.warning(f"Failed to get patterns/gotchas: {e}")
return [], []
+57
View File
@@ -3,10 +3,16 @@ QA Fixer Agent Session
=======================
Runs QA fixer sessions to resolve issues identified by the reviewer.
Memory Integration:
- Retrieves past patterns, fixes, and gotchas before fixing
- Saves fix outcomes and learnings after session
"""
from pathlib import Path
# Memory integration for cross-session learning
from agents.memory_manager import get_graphiti_context, save_session_memory
from claude_agent_sdk import ClaudeSDKClient
from debug import debug, debug_detailed, debug_error, debug_section, debug_success
from security.tool_input_validator import get_safe_tool_input
@@ -45,6 +51,7 @@ async def run_qa_fixer_session(
spec_dir: Path,
fix_session: int,
verbose: bool = False,
project_dir: Path | None = None,
) -> tuple[str, str]:
"""
Run a QA fixer agent session.
@@ -54,12 +61,18 @@ async def run_qa_fixer_session(
spec_dir: Spec directory
fix_session: Fix iteration number
verbose: Whether to show detailed output
project_dir: Project root directory (for memory context)
Returns:
(status, response_text) where status is:
- "fixed" if fixes were applied
- "error" if an error occurred
"""
# Derive project_dir from spec_dir if not provided
# spec_dir is typically: /project/.auto-claude/specs/001-name/
if project_dir is None:
# Walk up from spec_dir to find project root
project_dir = spec_dir.parent.parent.parent
debug_section("qa_fixer", f"QA Fixer Session {fix_session}")
debug(
"qa_fixer",
@@ -89,6 +102,20 @@ async def run_qa_fixer_session(
prompt = load_qa_fixer_prompt()
debug_detailed("qa_fixer", "Loaded QA fixer prompt", prompt_length=len(prompt))
# Retrieve memory context for fixer (past fixes, patterns, gotchas)
fixer_memory_context = await get_graphiti_context(
spec_dir,
project_dir,
{
"description": "Fixing QA issues and implementing corrections",
"id": f"qa_fixer_{fix_session}",
},
)
if fixer_memory_context:
prompt += "\n\n" + fixer_memory_context
print("✓ Memory context loaded for QA fixer")
debug_success("qa_fixer", "Graphiti memory context loaded for fixer")
# Add session context - use full path so agent can find files
prompt += f"\n\n---\n\n**Fix Session**: {fix_session}\n"
prompt += f"**Spec Directory**: {spec_dir}\n"
@@ -244,12 +271,42 @@ async def run_qa_fixer_session(
if status
else False,
)
# Save fixer session insights to memory
fixer_discoveries = {
"files_understood": {},
"patterns_found": [
f"QA fixer session {fix_session}: Applied fixes from QA_FIX_REQUEST.md"
],
"gotchas_encountered": [],
}
if status and status.get("ready_for_qa_revalidation"):
debug_success("qa_fixer", "Fixes applied, ready for QA revalidation")
# Save successful fix session to memory
await save_session_memory(
spec_dir=spec_dir,
project_dir=project_dir,
subtask_id=f"qa_fixer_{fix_session}",
session_num=fix_session,
success=True,
subtasks_completed=[f"qa_fixer_{fix_session}"],
discoveries=fixer_discoveries,
)
return "fixed", response_text
else:
# Fixer didn't update the status properly, but we'll trust it worked
debug_success("qa_fixer", "Fixes assumed applied (status not updated)")
# Still save to memory as successful (fixes were attempted)
await save_session_memory(
spec_dir=spec_dir,
project_dir=project_dir,
subtask_id=f"qa_fixer_{fix_session}",
session_num=fix_session,
success=True,
subtasks_completed=[f"qa_fixer_{fix_session}"],
discoveries=fixer_discoveries,
)
return "fixed", response_text
except Exception as e:
+57
View File
@@ -4,10 +4,16 @@ QA Reviewer Agent Session
Runs QA validation sessions to review implementation against
acceptance criteria.
Memory Integration:
- Retrieves past patterns, gotchas, and insights before QA session
- Saves QA findings (bugs, patterns, validation outcomes) after session
"""
from pathlib import Path
# Memory integration for cross-session learning
from agents.memory_manager import get_graphiti_context, save_session_memory
from claude_agent_sdk import ClaudeSDKClient
from debug import debug, debug_detailed, debug_error, debug_section, debug_success
from prompts_pkg import get_qa_reviewer_prompt
@@ -82,6 +88,20 @@ async def run_qa_agent_session(
project_dir=str(project_dir),
)
# Retrieve memory context for QA (past patterns, gotchas, validation insights)
qa_memory_context = await get_graphiti_context(
spec_dir,
project_dir,
{
"description": "QA validation and acceptance criteria review",
"id": f"qa_reviewer_{qa_session}",
},
)
if qa_memory_context:
prompt += "\n\n" + qa_memory_context
print("✓ Memory context loaded for QA reviewer")
debug_success("qa_reviewer", "Graphiti memory context loaded for QA")
# Add session context
prompt += f"\n\n---\n\n**QA Session**: {qa_session}\n"
prompt += f"**Max Iterations**: {max_iterations}\n"
@@ -307,11 +327,48 @@ This is attempt {previous_error.get("consecutive_errors", 1) + 1}. If you fail t
response_length=len(response_text),
qa_status=status.get("status") if status else "unknown",
)
# Save QA session insights to memory
qa_discoveries = {
"files_understood": {},
"patterns_found": [],
"gotchas_encountered": [],
}
if status and status.get("status") == "approved":
debug_success("qa_reviewer", "QA APPROVED")
qa_discoveries["patterns_found"].append(
f"QA session {qa_session}: All acceptance criteria validated successfully"
)
# Save successful QA session to memory
await save_session_memory(
spec_dir=spec_dir,
project_dir=project_dir,
subtask_id=f"qa_reviewer_{qa_session}",
session_num=qa_session,
success=True,
subtasks_completed=[f"qa_reviewer_{qa_session}"],
discoveries=qa_discoveries,
)
return "approved", response_text
elif status and status.get("status") == "rejected":
debug_error("qa_reviewer", "QA REJECTED")
# Extract issues found for memory
issues = status.get("issues_found", [])
for issue in issues:
qa_discoveries["gotchas_encountered"].append(
f"QA Issue ({issue.get('type', 'unknown')}): {issue.get('title', 'No title')} at {issue.get('location', 'unknown')}"
)
# Save rejected QA session to memory (learning from failures)
await save_session_memory(
spec_dir=spec_dir,
project_dir=project_dir,
subtask_id=f"qa_reviewer_{qa_session}",
session_num=qa_session,
success=False,
subtasks_completed=[],
discoveries=qa_discoveries,
)
return "rejected", response_text
else:
# Agent didn't update the status properly - provide detailed error
@@ -31,7 +31,7 @@ from claude_agent_sdk import AgentDefinition
try:
from ...core.client import create_client
from ...phase_config import get_thinking_budget
from ..context_gatherer import PRContext
from ..context_gatherer import PRContext, _validate_git_ref
from ..models import (
GitHubRunnerConfig,
MergeVerdict,
@@ -43,7 +43,7 @@ try:
from .pydantic_models import ParallelOrchestratorResponse
from .sdk_utils import process_sdk_stream
except (ImportError, ValueError, SystemError):
from context_gatherer import PRContext
from context_gatherer import PRContext, _validate_git_ref
from core.client import create_client
from models import (
GitHubRunnerConfig,
@@ -126,7 +126,7 @@ class ParallelOrchestratorReviewer:
"""Create a temporary worktree at the PR head commit.
Args:
head_sha: The commit SHA of the PR head
head_sha: The commit SHA of the PR head (validated before use)
pr_number: The PR number for naming
Returns:
@@ -134,7 +134,15 @@ class ParallelOrchestratorReviewer:
Raises:
RuntimeError: If worktree creation fails
ValueError: If head_sha fails validation (command injection prevention)
"""
# SECURITY: Validate git ref before use in subprocess calls
if not _validate_git_ref(head_sha):
raise ValueError(
f"Invalid git ref: '{head_sha}'. "
"Must contain only alphanumeric characters, dots, slashes, underscores, and hyphens."
)
worktree_name = f"pr-{pr_number}-{uuid.uuid4().hex[:8]}"
worktree_dir = self.project_dir / PR_WORKTREE_DIR
@@ -178,6 +186,7 @@ class ParallelOrchestratorReviewer:
cwd=self.project_dir,
capture_output=True,
text=True,
timeout=120, # Worktree add can be slow for large repos
)
if DEBUG_MODE:
@@ -239,6 +248,7 @@ class ParallelOrchestratorReviewer:
cwd=self.project_dir,
capture_output=True,
text=True,
timeout=30,
)
if DEBUG_MODE:
@@ -258,6 +268,7 @@ class ParallelOrchestratorReviewer:
["git", "worktree", "prune"],
cwd=self.project_dir,
capture_output=True,
timeout=30,
)
logger.warning(f"[PRReview] Used shutil fallback for: {worktree_path.name}")
except Exception as e:
@@ -275,12 +286,15 @@ class ParallelOrchestratorReviewer:
cwd=self.project_dir,
capture_output=True,
text=True,
timeout=30,
)
registered = {
Path(line.split(" ", 1)[1])
for line in result.stdout.split("\n")
if line.startswith("worktree ")
}
registered = set()
for line in result.stdout.split("\n"):
if line.startswith("worktree "):
# Safely parse - check bounds to prevent IndexError
parts = line.split(" ", 1)
if len(parts) > 1 and parts[1]:
registered.add(Path(parts[1]))
# Remove unregistered directories
stale_count = 0
@@ -295,6 +309,7 @@ class ParallelOrchestratorReviewer:
["git", "worktree", "prune"],
cwd=self.project_dir,
capture_output=True,
timeout=30,
)
if DEBUG_MODE:
print(
@@ -618,6 +633,15 @@ The SDK will run invoked agents in parallel automatically.
)
print(f"[PRReview] DEBUG: resolved head_sha='{head_sha}'", flush=True)
# SECURITY: Validate the resolved head_sha (whether SHA or branch name)
# This catches invalid refs early before subprocess calls
if head_sha and not _validate_git_ref(head_sha):
logger.warning(
f"[ParallelOrchestrator] Invalid git ref '{head_sha}', "
"using current checkout for safety"
)
head_sha = None
if not head_sha:
if DEBUG_MODE:
print("[PRReview] DEBUG: No head_sha - using fallback", flush=True)
@@ -647,7 +671,7 @@ The SDK will run invoked agents in parallel automatically.
f"project_root={project_root}",
flush=True,
)
except RuntimeError as e:
except (RuntimeError, ValueError) as e:
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: Worktree creation FAILED: {e}",
+17 -4
View File
@@ -11,6 +11,9 @@ import { projectStore } from '../project-store';
import { getClaudeProfileManager } from '../claude-profile-manager';
import { parsePythonCommand, validatePythonPath } from '../python-detector';
import { pythonEnvManager, getConfiguredPythonPath } from '../python-env-manager';
import { buildMemoryEnvVars } from '../memory-env-builder';
import { readSettingsFile } from '../settings-utils';
import type { AppSettings } from '../../shared/types/settings';
/**
* Process spawning and lifecycle management
@@ -574,14 +577,24 @@ export class AgentProcessManager {
* Get combined environment variables for a project
*
* Priority (later sources override earlier):
* 1. Backend source .env (apps/backend/.env) - CLI defaults
* 2. Project's .auto-claude/.env - Frontend-configured settings (memory, integrations)
* 3. Project settings (graphitiMcpUrl, useClaudeMd) - Runtime overrides
* 1. App-wide memory settings from settings.json (NEW - enables memory from onboarding)
* 2. Backend source .env (apps/backend/.env) - CLI defaults
* 3. Project's .auto-claude/.env - Frontend-configured settings (memory, integrations)
* 4. Project settings (graphitiMcpUrl, useClaudeMd) - Runtime overrides
*/
getCombinedEnv(projectPath: string): Record<string, string> {
// Load app-wide memory settings from settings.json
// This bridges onboarding config to backend agents
const appSettings = (readSettingsFile() || {}) as Partial<AppSettings>;
const memoryEnv = buildMemoryEnvVars(appSettings as AppSettings);
// Existing env sources
const autoBuildEnv = this.loadAutoBuildEnv();
const projectFileEnv = this.loadProjectEnv(projectPath);
const projectSettingsEnv = this.getProjectEnvVars(projectPath);
return { ...autoBuildEnv, ...projectFileEnv, ...projectSettingsEnv };
// Priority: app-wide memory -> backend .env -> project .env -> project settings
// Later sources override earlier ones
return { ...memoryEnv, ...autoBuildEnv, ...projectFileEnv, ...projectSettingsEnv };
}
}
@@ -12,6 +12,9 @@ import {
validateEmbeddingConfiguration,
getGraphitiDatabaseDetails
} from './utils';
import { buildMemoryEnvVars } from '../../memory-env-builder';
import { readSettingsFile } from '../../settings-utils';
import type { AppSettings } from '../../../shared/types/settings';
/**
* Load Graphiti state from most recent spec directory
@@ -54,18 +57,31 @@ export function loadGraphitiStateFromSpecs(
/**
* Build memory status from environment configuration
*
* Priority (same as agent-process.ts getCombinedEnv):
* 1. App-wide memory settings from settings.json (from onboarding)
* 2. Project's .env files
*/
export function buildMemoryStatus(
projectPath: string,
autoBuildPath?: string,
memoryState?: GraphitiMemoryState | null
): GraphitiMemoryStatus {
// Load app-wide memory settings from settings.json (set during onboarding)
const appSettings = (readSettingsFile() || {}) as Partial<AppSettings>;
const memoryEnvVars = buildMemoryEnvVars(appSettings as AppSettings);
// Load project-specific env vars
const projectEnvVars = loadProjectEnvVars(projectPath, autoBuildPath);
const globalSettings = loadGlobalSettings();
// Merge: app-wide memory settings -> project env vars
// Project settings can override app-wide settings
const effectiveEnvVars = { ...memoryEnvVars, ...projectEnvVars };
// If we have initialized state from specs, use it
if (memoryState?.initialized) {
const dbDetails = getGraphitiDatabaseDetails(projectEnvVars);
const dbDetails = getGraphitiDatabaseDetails(effectiveEnvVars);
return {
enabled: true,
available: true,
@@ -74,9 +90,9 @@ export function buildMemoryStatus(
};
}
// Check environment configuration
const graphitiEnabled = isGraphitiEnabled(projectEnvVars);
const embeddingValidation = validateEmbeddingConfiguration(projectEnvVars, globalSettings);
// Check environment configuration using merged env vars
const graphitiEnabled = isGraphitiEnabled(effectiveEnvVars);
const embeddingValidation = validateEmbeddingConfiguration(effectiveEnvVars, globalSettings);
if (!graphitiEnabled) {
return {
@@ -94,7 +110,7 @@ export function buildMemoryStatus(
};
}
const dbDetails = getGraphitiDatabaseDetails(projectEnvVars);
const dbDetails = getGraphitiDatabaseDetails(effectiveEnvVars);
return {
enabled: true,
available: true,
@@ -0,0 +1,83 @@
/**
* Memory Environment Variable Builder
*
* Converts app-wide memory settings from settings.json into environment variables
* that can be injected into Python agent processes.
*
* This bridges the gap between frontend settings storage and backend configuration.
*/
import type { AppSettings } from '../shared/types/settings';
/**
* Build environment variables for memory/Graphiti configuration from app settings.
*
* @param settings - App-wide settings from settings.json
* @returns Record of environment variables to inject into agent processes
*/
export function buildMemoryEnvVars(settings: AppSettings): Record<string, string> {
const env: Record<string, string> = {};
// If memory is not enabled, return empty env
if (!settings.memoryEnabled) {
return env;
}
// Enable Graphiti
env.GRAPHITI_ENABLED = 'true';
// Set embedder provider (default to ollama)
const embeddingProvider = settings.memoryEmbeddingProvider || 'ollama';
env.GRAPHITI_EMBEDDER_PROVIDER = embeddingProvider;
// Provider-specific configuration
switch (embeddingProvider) {
case 'ollama':
env.OLLAMA_BASE_URL = settings.ollamaBaseUrl || 'http://localhost:11434';
if (settings.memoryOllamaEmbeddingModel) {
env.OLLAMA_EMBEDDING_MODEL = settings.memoryOllamaEmbeddingModel;
}
if (settings.memoryOllamaEmbeddingDim) {
env.OLLAMA_EMBEDDING_DIM = String(settings.memoryOllamaEmbeddingDim);
}
break;
case 'openai':
if (settings.globalOpenAIApiKey) {
env.OPENAI_API_KEY = settings.globalOpenAIApiKey;
}
break;
case 'voyage':
if (settings.memoryVoyageApiKey) {
env.VOYAGE_API_KEY = settings.memoryVoyageApiKey;
}
break;
case 'google':
if (settings.globalGoogleApiKey) {
env.GOOGLE_API_KEY = settings.globalGoogleApiKey;
}
break;
case 'azure_openai':
if (settings.memoryAzureApiKey) {
env.AZURE_OPENAI_API_KEY = settings.memoryAzureApiKey;
}
if (settings.memoryAzureBaseUrl) {
env.AZURE_OPENAI_BASE_URL = settings.memoryAzureBaseUrl;
}
if (settings.memoryAzureEmbeddingDeployment) {
env.AZURE_OPENAI_EMBEDDING_DEPLOYMENT = settings.memoryAzureEmbeddingDeployment;
}
break;
case 'openrouter':
if (settings.globalOpenRouterApiKey) {
env.OPENROUTER_API_KEY = settings.globalOpenRouterApiKey;
}
break;
}
return env;
}
@@ -140,27 +140,43 @@ export function MemoryStep({ onNext, onBack }: MemoryStepProps) {
setError(null);
try {
// Save the API keys to global settings
const settingsToSave: Record<string, string | undefined> = {};
if (config.openaiApiKey.trim()) {
settingsToSave.globalOpenAIApiKey = config.openaiApiKey.trim();
}
if (config.googleApiKey.trim()) {
settingsToSave.globalGoogleApiKey = config.googleApiKey.trim();
}
if (config.ollamaBaseUrl.trim()) {
settingsToSave.ollamaBaseUrl = config.ollamaBaseUrl.trim();
}
// Save complete memory configuration to global settings
// This includes all settings needed for backend to use memory
const settingsToSave: Record<string, string | number | boolean | undefined> = {
// Core memory settings (CRITICAL - these were missing before)
memoryEnabled: true,
memoryEmbeddingProvider: config.embeddingProvider,
memoryOllamaEmbeddingModel: config.ollamaEmbeddingModel || undefined,
memoryOllamaEmbeddingDim: config.ollamaEmbeddingDim || undefined,
// Ollama base URL
ollamaBaseUrl: config.ollamaBaseUrl.trim() || undefined,
// Global API keys (shared across features)
globalOpenAIApiKey: config.openaiApiKey.trim() || undefined,
globalGoogleApiKey: config.googleApiKey.trim() || undefined,
// Provider-specific keys for memory
memoryVoyageApiKey: config.voyageApiKey.trim() || undefined,
memoryAzureApiKey: config.azureOpenaiApiKey.trim() || undefined,
memoryAzureBaseUrl: config.azureOpenaiBaseUrl.trim() || undefined,
memoryAzureEmbeddingDeployment: config.azureOpenaiEmbeddingDeployment.trim() || undefined,
};
const result = await window.electronAPI.saveSettings(settingsToSave);
if (result?.success) {
// Update local settings store
const storeUpdate: Partial<Pick<AppSettings, 'globalOpenAIApiKey' | 'globalGoogleApiKey' | 'ollamaBaseUrl'>> = {};
if (config.openaiApiKey.trim()) storeUpdate.globalOpenAIApiKey = config.openaiApiKey.trim();
if (config.googleApiKey.trim()) storeUpdate.globalGoogleApiKey = config.googleApiKey.trim();
if (config.ollamaBaseUrl.trim()) storeUpdate.ollamaBaseUrl = config.ollamaBaseUrl.trim();
// Update local settings store with all memory config
const storeUpdate: Partial<AppSettings> = {
memoryEnabled: true,
memoryEmbeddingProvider: config.embeddingProvider,
memoryOllamaEmbeddingModel: config.ollamaEmbeddingModel || undefined,
memoryOllamaEmbeddingDim: config.ollamaEmbeddingDim || undefined,
ollamaBaseUrl: config.ollamaBaseUrl.trim() || undefined,
globalOpenAIApiKey: config.openaiApiKey.trim() || undefined,
globalGoogleApiKey: config.googleApiKey.trim() || undefined,
memoryVoyageApiKey: config.voyageApiKey.trim() || undefined,
memoryAzureApiKey: config.azureOpenaiApiKey.trim() || undefined,
memoryAzureBaseUrl: config.azureOpenaiBaseUrl.trim() || undefined,
memoryAzureEmbeddingDeployment: config.azureOpenaiEmbeddingDeployment.trim() || undefined,
};
updateSettings(storeUpdate);
onNext();
} else {
+11 -2
View File
@@ -2,7 +2,7 @@
* Application settings types
*/
import type { NotificationSettings } from './project';
import type { NotificationSettings, GraphitiEmbeddingProvider } from './project';
import type { ChangelogFormat, ChangelogAudience, ChangelogEmojiLevel } from './changelog';
import type { SupportedLanguage } from '../constants/i18n';
@@ -234,9 +234,18 @@ export interface AppSettings {
globalGoogleApiKey?: string;
globalGroqApiKey?: string;
globalOpenRouterApiKey?: string;
// Graphiti LLM provider settings
// Graphiti LLM provider settings (legacy)
graphitiLlmProvider?: 'openai' | 'anthropic' | 'google' | 'groq' | 'ollama';
ollamaBaseUrl?: string;
// Memory/Graphiti configuration (app-wide, set during onboarding)
memoryEnabled?: boolean;
memoryEmbeddingProvider?: GraphitiEmbeddingProvider;
memoryOllamaEmbeddingModel?: string;
memoryOllamaEmbeddingDim?: number;
memoryVoyageApiKey?: string;
memoryAzureApiKey?: string;
memoryAzureBaseUrl?: string;
memoryAzureEmbeddingDeployment?: string;
// Onboarding wizard completion state
onboardingCompleted?: boolean;
// Selected agent profile for preset model/thinking configurations