Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 58 additions & 2 deletions src/appliers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,21 @@ class BaseApplier(ABC):
# applier forgets to set the guard.
MEMORY_ALLOWED_BASE: Optional[Path] = None

# Subclasses may set MEMORY_TARGET_FILE to a primary memory file path.
# When set, apply_memory_via_llm() falls back to writing a simple formatted
# section using memory_section.write_memory_file() when the LLM is
# unavailable, rather than returning -1 (#38-#43).
MEMORY_TARGET_FILE: Optional[Path] = None

# Category headers for the no-LLM fallback section layout.
MEMORY_CATEGORY_HEADERS: Dict[str, str] = {
"preference": "Preferences",
"project": "Project Context",
"rule": "Rules",
"fact": "Facts",
"workflow": "Workflow",
}

def get_manifest(self) -> ToolManifest:
"""Return (or create) the manifest for this tool."""
return ToolManifest(self.TOOL_NAME)
Expand Down Expand Up @@ -187,7 +202,7 @@ def apply_memory_via_llm(self, collected_memory: List[Dict], manifest: ToolManif
f"LLM not available for memory sync to {self.TOOL_NAME}. "
"Run 'apc configure' to set up an LLM provider."
)
return 0
return self._apply_memory_fallback(collected_memory, manifest)

# Read existing files
existing = self._read_existing_memory_files()
Expand Down Expand Up @@ -216,7 +231,7 @@ def apply_memory_via_llm(self, collected_memory: List[Dict], manifest: ToolManif
)
else:
warning(f"LLM call failed ({e}), skipping memory sync for {self.TOOL_NAME}")
return 0
return self._apply_memory_fallback(collected_memory, manifest)

# Parse structured output
try:
Expand Down Expand Up @@ -279,6 +294,47 @@ def apply_memory_via_llm(self, collected_memory: List[Dict], manifest: ToolManif

return count

def _apply_memory_fallback(self, collected_memory: List[Dict], manifest: ToolManifest) -> int:
"""Write collected memory to MEMORY_TARGET_FILE without LLM.

Used when the LLM is unavailable. If MEMORY_TARGET_FILE is not set
on this applier, returns 0 (no file written, no success indicator).

The file is written using memory_section.write_memory_file() which
preserves any existing user content outside the APC-managed section.
Returns 1 if the file was written, 0 otherwise (#38-#43).
"""
target = self.MEMORY_TARGET_FILE
if target is None:
return 0

# Security: resolved path must be inside MEMORY_ALLOWED_BASE
if self.MEMORY_ALLOWED_BASE is not None:
allowed_base = self.MEMORY_ALLOWED_BASE.resolve()
resolved = target.expanduser().resolve()
if not str(resolved).startswith(str(allowed_base) + "/") and resolved != allowed_base:
return 0
else:
resolved = target.expanduser().resolve()

try:
from appliers.memory_section import write_memory_file

write_memory_file(
resolved,
collected_memory,
self.MEMORY_CATEGORY_HEADERS,
title=f"AI Context — Synced by apc (no LLM, {self.TOOL_NAME})",
)
manifest.record_memory(
file_path=str(resolved),
content="",
entry_ids=[e.get("id") or e.get("entry_id", "") for e in collected_memory],
)
return 1
except Exception:
return 0

def _read_existing_memory_files(self) -> Dict[str, str]:
"""Return {file_path: content} for this tool's memory files.

Expand Down
4 changes: 4 additions & 0 deletions src/appliers/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ def SKILL_DIR(self, value):
def MEMORY_ALLOWED_BASE(self) -> "Path": # noqa: N802
return _claude_dir()

@property # type: ignore[override]
def MEMORY_TARGET_FILE(self) -> "Path": # noqa: N802
return _claude_md()

def apply_skills(self, skills: List[Dict], manifest: ToolManifest) -> int:
_claude_commands_dir().mkdir(parents=True, exist_ok=True)
count = 0
Expand Down
4 changes: 4 additions & 0 deletions src/appliers/copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ def MEMORY_ALLOWED_BASE(self) -> "Path": # noqa: N802
# calling process later changes directory (#42).
return Path.cwd().resolve()

@property # type: ignore[override]
def MEMORY_TARGET_FILE(self) -> "Path": # noqa: N802
return _copilot_instructions()

def apply_skills(self, skills: List[Dict], manifest: ToolManifest) -> int:
count = 0
instructions = _copilot_instructions()
Expand Down
8 changes: 8 additions & 0 deletions src/appliers/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ def _cursor_mcp_json() -> Path:
return Path.home() / ".cursor" / "mcp.json"


def _cursor_memory_file() -> Path:
return _cursor_rules_dir() / "apc-memory.mdc"


class CursorApplier(BaseApplier):
TOOL_NAME = "cursor"
MEMORY_SCHEMA = CURSOR_MEMORY_SCHEMA
Expand All @@ -82,6 +86,10 @@ class CursorApplier(BaseApplier):
def MEMORY_ALLOWED_BASE(self) -> "Path": # noqa: N802
return _cursor_dir()

@property # type: ignore[override]
def MEMORY_TARGET_FILE(self) -> "Path": # noqa: N802
return _cursor_memory_file()

@property
def SKILL_DIR(self) -> Path: # type: ignore[override]
return _cursor_rules_dir()
Expand Down
4 changes: 4 additions & 0 deletions src/appliers/gemini.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ class GeminiApplier(BaseApplier):
def MEMORY_ALLOWED_BASE(self) -> "Path": # noqa: N802
return _gemini_dir()

@property # type: ignore[override]
def MEMORY_TARGET_FILE(self) -> "Path": # noqa: N802
return _gemini_md()

def apply_skills(self, skills: List[Dict], manifest: ToolManifest) -> int:
return 0 # Gemini doesn't have a skills format

Expand Down
4 changes: 4 additions & 0 deletions src/appliers/openclaw.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ def SKILL_DIR(self, value):
def MEMORY_ALLOWED_BASE(self) -> "Path": # noqa: N802
return _openclaw_workspace()

@property # type: ignore[override]
def MEMORY_TARGET_FILE(self) -> "Path": # noqa: N802
return _openclaw_memory_md()

def apply_skills(self, skills: List[Dict], manifest: ToolManifest) -> int:
_openclaw_skills_dir().mkdir(parents=True, exist_ok=True)
count = 0
Expand Down
4 changes: 4 additions & 0 deletions src/appliers/windsurf.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ class WindsurfApplier(BaseApplier):
def MEMORY_ALLOWED_BASE(self) -> "Path": # noqa: N802
return _windsurf_dir()

@property # type: ignore[override]
def MEMORY_TARGET_FILE(self) -> "Path": # noqa: N802
return _windsurf_global_rules()

def apply_skills(self, skills: List[Dict], manifest: ToolManifest) -> int:
return 0

Expand Down
9 changes: 6 additions & 3 deletions tests/test_appliers.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,23 +143,26 @@ def test_apply_memory_via_llm(self):
content = self.claude_md.read_text()
self.assertIn("Prefers TypeScript", content)

def test_apply_memory_via_llm_returns_zero_on_failure(self):
"""When LLM fails, returns 0 (no fallback to legacy)."""
def test_apply_memory_via_llm_uses_fallback_on_llm_failure(self):
"""When LLM fails, falls back to no-LLM write if MEMORY_TARGET_FILE is set."""
collected = [
{"id": "abc", "source_tool": "openclaw", "content": "test"},
]
manifest = self._manifest()

with (
patch("appliers.claude._claude_md", return_value=self.claude_md),
patch("appliers.claude._claude_dir", return_value=self.claude_dir),
patch("appliers.base.BaseApplier._apply_memory_fallback", return_value=1),
patch("llm_client.call_llm", side_effect=Exception("No LLM")),
):
from appliers.claude import ClaudeApplier

applier = ClaudeApplier()
count = applier.apply_memory_via_llm(collected, manifest)

self.assertEqual(count, 0)
# Fallback was invoked and returned 1
self.assertEqual(count, 1)

def test_apply_memory_via_llm_handles_markdown_fencing(self):
"""LLM sometimes wraps response in markdown code blocks."""
Expand Down
4 changes: 3 additions & 1 deletion tests/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ def test_no_llm_configured_shows_warning(self):
applier = ClaudeApplier()
count = applier.apply_memory_via_llm(collected, manifest)

self.assertEqual(count, 0)
# With no-LLM fallback: writes a simple formatted section, returns >= 0
# (1 if MEMORY_TARGET_FILE is set, 0 otherwise)
self.assertGreaterEqual(count, 0)


if __name__ == "__main__":
Expand Down
Loading