alpiecode 0.9.2__tar.gz → 0.9.4__tar.gz
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.
- {alpiecode-0.9.2 → alpiecode-0.9.4}/PKG-INFO +1 -1
- {alpiecode-0.9.2 → alpiecode-0.9.4}/pyproject.toml +1 -1
- {alpiecode-0.9.2 → alpiecode-0.9.4}/src/alpiecode.egg-info/PKG-INFO +1 -1
- {alpiecode-0.9.2 → alpiecode-0.9.4}/src/alpiecode.egg-info/SOURCES.txt +1 -0
- alpiecode-0.9.4/src/codeagent/cache.py +195 -0
- {alpiecode-0.9.2 → alpiecode-0.9.4}/src/codeagent/extension/alpiecode.vsix +0 -0
- {alpiecode-0.9.2 → alpiecode-0.9.4}/src/codeagent/orchestrator.py +33 -1
- {alpiecode-0.9.2 → alpiecode-0.9.4}/src/codeagent/server.py +10 -1
- {alpiecode-0.9.2 → alpiecode-0.9.4}/README.md +0 -0
- {alpiecode-0.9.2 → alpiecode-0.9.4}/setup.cfg +0 -0
- {alpiecode-0.9.2 → alpiecode-0.9.4}/src/alpiecode.egg-info/dependency_links.txt +0 -0
- {alpiecode-0.9.2 → alpiecode-0.9.4}/src/alpiecode.egg-info/entry_points.txt +0 -0
- {alpiecode-0.9.2 → alpiecode-0.9.4}/src/alpiecode.egg-info/requires.txt +0 -0
- {alpiecode-0.9.2 → alpiecode-0.9.4}/src/alpiecode.egg-info/top_level.txt +0 -0
- {alpiecode-0.9.2 → alpiecode-0.9.4}/src/codeagent/__init__.py +0 -0
- {alpiecode-0.9.2 → alpiecode-0.9.4}/src/codeagent/agent.py +0 -0
- {alpiecode-0.9.2 → alpiecode-0.9.4}/src/codeagent/backends/__init__.py +0 -0
- {alpiecode-0.9.2 → alpiecode-0.9.4}/src/codeagent/backends/base.py +0 -0
- {alpiecode-0.9.2 → alpiecode-0.9.4}/src/codeagent/backends/local_backend.py +0 -0
- {alpiecode-0.9.2 → alpiecode-0.9.4}/src/codeagent/backends/openai_backend.py +0 -0
- {alpiecode-0.9.2 → alpiecode-0.9.4}/src/codeagent/cli.py +0 -0
- {alpiecode-0.9.2 → alpiecode-0.9.4}/src/codeagent/client.py +0 -0
- {alpiecode-0.9.2 → alpiecode-0.9.4}/src/codeagent/compaction.py +0 -0
- {alpiecode-0.9.2 → alpiecode-0.9.4}/src/codeagent/config.py +0 -0
- {alpiecode-0.9.2 → alpiecode-0.9.4}/src/codeagent/context.py +0 -0
- {alpiecode-0.9.2 → alpiecode-0.9.4}/src/codeagent/executor.py +0 -0
- {alpiecode-0.9.2 → alpiecode-0.9.4}/src/codeagent/github.py +0 -0
- {alpiecode-0.9.2 → alpiecode-0.9.4}/src/codeagent/guardian.py +0 -0
- {alpiecode-0.9.2 → alpiecode-0.9.4}/src/codeagent/local_model.py +0 -0
- {alpiecode-0.9.2 → alpiecode-0.9.4}/src/codeagent/media.py +0 -0
- {alpiecode-0.9.2 → alpiecode-0.9.4}/src/codeagent/memory.py +0 -0
- {alpiecode-0.9.2 → alpiecode-0.9.4}/src/codeagent/prompt.py +0 -0
- {alpiecode-0.9.2 → alpiecode-0.9.4}/src/codeagent/session.py +0 -0
- {alpiecode-0.9.2 → alpiecode-0.9.4}/src/codeagent/tools.py +0 -0
- {alpiecode-0.9.2 → alpiecode-0.9.4}/src/codeagent/updater.py +0 -0
- {alpiecode-0.9.2 → alpiecode-0.9.4}/src/codeagent/vscode_installer.py +0 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Response cache for AlpieCode.
|
|
3
|
+
|
|
4
|
+
Caches LLM responses on disk (~/.alpiecode/cache/) so identical prompts
|
|
5
|
+
return instantly without model inference. Shared across CLI, IDE, and VS Code.
|
|
6
|
+
|
|
7
|
+
Design:
|
|
8
|
+
- Cache key = SHA-256 hash of normalized task text
|
|
9
|
+
- Only caches "pure" responses (no tool calls — those are context-dependent)
|
|
10
|
+
- In-memory LRU + disk persistence for instant lookups across restarts
|
|
11
|
+
- TTL: 7 days, Max entries: 500
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Dict, Optional
|
|
19
|
+
|
|
20
|
+
CACHE_DIR = Path.home() / ".alpiecode" / "cache"
|
|
21
|
+
MAX_ENTRIES = 500
|
|
22
|
+
TTL_SECONDS = 7 * 24 * 3600 # 7 days
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ResponseCache:
|
|
26
|
+
"""Disk-backed LRU response cache."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
cache_dir: Path = CACHE_DIR,
|
|
31
|
+
max_entries: int = MAX_ENTRIES,
|
|
32
|
+
ttl: int = TTL_SECONDS,
|
|
33
|
+
):
|
|
34
|
+
self.cache_dir = cache_dir
|
|
35
|
+
self.max_entries = max_entries
|
|
36
|
+
self.ttl = ttl
|
|
37
|
+
self._mem: Dict[str, Dict[str, Any]] = {}
|
|
38
|
+
self._hits = 0
|
|
39
|
+
self._misses = 0
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
except OSError:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
self._warm_up()
|
|
47
|
+
|
|
48
|
+
# ── Public API ──────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
def get(self, task: str) -> Optional[Dict[str, Any]]:
|
|
51
|
+
"""
|
|
52
|
+
Look up a cached response for the given task.
|
|
53
|
+
|
|
54
|
+
Returns dict with keys: response, reasoning (optional), cached_at
|
|
55
|
+
Returns None on cache miss or expired entry.
|
|
56
|
+
"""
|
|
57
|
+
key = self._key(task)
|
|
58
|
+
|
|
59
|
+
# Memory check (fast path)
|
|
60
|
+
entry = self._mem.get(key)
|
|
61
|
+
if entry and self._is_valid(entry):
|
|
62
|
+
self._hits += 1
|
|
63
|
+
return entry
|
|
64
|
+
|
|
65
|
+
# Disk check (slow path)
|
|
66
|
+
path = self._path(key)
|
|
67
|
+
if path.exists():
|
|
68
|
+
try:
|
|
69
|
+
entry = json.loads(path.read_text(encoding="utf-8"))
|
|
70
|
+
if self._is_valid(entry):
|
|
71
|
+
self._mem[key] = entry # Promote to memory
|
|
72
|
+
self._hits += 1
|
|
73
|
+
return entry
|
|
74
|
+
else:
|
|
75
|
+
path.unlink(missing_ok=True) # Expired
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
self._misses += 1
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
def put(self, task: str, response: str, reasoning: Optional[str] = None) -> None:
|
|
83
|
+
"""
|
|
84
|
+
Store a response in the cache.
|
|
85
|
+
|
|
86
|
+
Only call this for responses that completed WITHOUT tool calls.
|
|
87
|
+
"""
|
|
88
|
+
if not response or not task.strip():
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
key = self._key(task)
|
|
92
|
+
entry = {
|
|
93
|
+
"task": task.strip(),
|
|
94
|
+
"response": response,
|
|
95
|
+
"reasoning": reasoning,
|
|
96
|
+
"cached_at": time.time(),
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
self._mem[key] = entry
|
|
100
|
+
|
|
101
|
+
# Write to disk
|
|
102
|
+
try:
|
|
103
|
+
path = self._path(key)
|
|
104
|
+
path.write_text(json.dumps(entry, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
105
|
+
except OSError:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
self._enforce_limit()
|
|
109
|
+
|
|
110
|
+
def clear(self) -> int:
|
|
111
|
+
"""Clear all cached responses. Returns number of entries removed."""
|
|
112
|
+
count = 0
|
|
113
|
+
try:
|
|
114
|
+
for f in self.cache_dir.glob("*.json"):
|
|
115
|
+
f.unlink(missing_ok=True)
|
|
116
|
+
count += 1
|
|
117
|
+
except OSError:
|
|
118
|
+
pass
|
|
119
|
+
self._mem.clear()
|
|
120
|
+
return count
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def stats(self) -> Dict[str, Any]:
|
|
124
|
+
"""Return cache statistics."""
|
|
125
|
+
disk_count = len(list(self.cache_dir.glob("*.json"))) if self.cache_dir.exists() else 0
|
|
126
|
+
total = self._hits + self._misses
|
|
127
|
+
return {
|
|
128
|
+
"entries": disk_count,
|
|
129
|
+
"memory": len(self._mem),
|
|
130
|
+
"hits": self._hits,
|
|
131
|
+
"misses": self._misses,
|
|
132
|
+
"hit_rate": f"{self._hits / total * 100:.1f}%" if total > 0 else "0%",
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
# ── Internals ───────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
def _key(self, task: str) -> str:
|
|
138
|
+
"""Normalize and hash the task text into a 16-char hex key."""
|
|
139
|
+
normalized = " ".join(task.strip().lower().split())
|
|
140
|
+
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16]
|
|
141
|
+
|
|
142
|
+
def _path(self, key: str) -> Path:
|
|
143
|
+
return self.cache_dir / f"{key}.json"
|
|
144
|
+
|
|
145
|
+
def _is_valid(self, entry: Dict[str, Any]) -> bool:
|
|
146
|
+
cached_at = entry.get("cached_at", 0)
|
|
147
|
+
return (time.time() - cached_at) < self.ttl
|
|
148
|
+
|
|
149
|
+
def _warm_up(self):
|
|
150
|
+
"""Load the 100 most recent entries into memory on startup."""
|
|
151
|
+
if not self.cache_dir.exists():
|
|
152
|
+
return
|
|
153
|
+
try:
|
|
154
|
+
files = sorted(
|
|
155
|
+
self.cache_dir.glob("*.json"),
|
|
156
|
+
key=lambda f: f.stat().st_mtime,
|
|
157
|
+
reverse=True,
|
|
158
|
+
)
|
|
159
|
+
for f in files[:100]:
|
|
160
|
+
try:
|
|
161
|
+
entry = json.loads(f.read_text(encoding="utf-8"))
|
|
162
|
+
if self._is_valid(entry):
|
|
163
|
+
self._mem[f.stem] = entry
|
|
164
|
+
else:
|
|
165
|
+
f.unlink(missing_ok=True) # Clean expired
|
|
166
|
+
except Exception:
|
|
167
|
+
pass
|
|
168
|
+
except OSError:
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
def _enforce_limit(self):
|
|
172
|
+
"""Evict oldest entries if over max_entries."""
|
|
173
|
+
if not self.cache_dir.exists():
|
|
174
|
+
return
|
|
175
|
+
try:
|
|
176
|
+
files = sorted(self.cache_dir.glob("*.json"), key=lambda f: f.stat().st_mtime)
|
|
177
|
+
while len(files) > self.max_entries:
|
|
178
|
+
oldest = files.pop(0)
|
|
179
|
+
key = oldest.stem
|
|
180
|
+
oldest.unlink(missing_ok=True)
|
|
181
|
+
self._mem.pop(key, None)
|
|
182
|
+
except OSError:
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ── Singleton ───────────────────────────────────────────────────────────
|
|
187
|
+
_CACHE: Optional[ResponseCache] = None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def get_cache() -> ResponseCache:
|
|
191
|
+
"""Get or create the global response cache singleton."""
|
|
192
|
+
global _CACHE
|
|
193
|
+
if _CACHE is None:
|
|
194
|
+
_CACHE = ResponseCache()
|
|
195
|
+
return _CACHE
|
|
Binary file
|
|
@@ -13,6 +13,7 @@ from typing import Any, Dict, Iterator, Optional
|
|
|
13
13
|
from .backends.base import ChatResponse, InferenceBackend, ToolCall
|
|
14
14
|
from .backends.local_backend import LocalBackend
|
|
15
15
|
from .backends.openai_backend import OpenAIBackend
|
|
16
|
+
from .cache import get_cache
|
|
16
17
|
from .config import Config, is_server_reachable
|
|
17
18
|
from .context import ContextManager
|
|
18
19
|
from .executor import ToolExecutor, ToolResult
|
|
@@ -63,6 +64,29 @@ class AgentOrchestrator:
|
|
|
63
64
|
"""
|
|
64
65
|
Run full agent task loop for a session. Yields AgentEvents.
|
|
65
66
|
"""
|
|
67
|
+
# ── Response cache check (instant return for repeated prompts) ──
|
|
68
|
+
# Only cache pure text prompts (no images, videos, URLs, or GitHub repos)
|
|
69
|
+
is_cacheable = not any([image_path, video_path, url, github_repo])
|
|
70
|
+
if is_cacheable:
|
|
71
|
+
cache = get_cache()
|
|
72
|
+
cached = cache.get(task)
|
|
73
|
+
if cached:
|
|
74
|
+
yield AgentEvent("start", {
|
|
75
|
+
"task": task,
|
|
76
|
+
"workdir": str(session.workdir),
|
|
77
|
+
"backend": "cache",
|
|
78
|
+
"is_offline": False,
|
|
79
|
+
"tool_count": 0,
|
|
80
|
+
})
|
|
81
|
+
yield AgentEvent("cache_hit", {
|
|
82
|
+
"message": "Returning cached response (instant)",
|
|
83
|
+
})
|
|
84
|
+
if cached.get("reasoning"):
|
|
85
|
+
yield AgentEvent("thinking", {"content": cached["reasoning"]})
|
|
86
|
+
yield AgentEvent("message", {"content": cached["response"]})
|
|
87
|
+
yield AgentEvent("done", {"summary": cached["response"]})
|
|
88
|
+
return
|
|
89
|
+
|
|
66
90
|
# Dynamic backend re-check: if currently on LocalBackend but remote
|
|
67
91
|
# API is now reachable, switch to OnlineBackend automatically.
|
|
68
92
|
# This handles the case where the server started offline but the
|
|
@@ -176,8 +200,16 @@ class AgentOrchestrator:
|
|
|
176
200
|
|
|
177
201
|
continue
|
|
178
202
|
|
|
179
|
-
# Assistant text response
|
|
203
|
+
# Assistant text response (no tool calls = cacheable)
|
|
180
204
|
if resp.content:
|
|
205
|
+
# Cache this response — it completed in a single turn without tools
|
|
206
|
+
if is_cacheable and turn == 0:
|
|
207
|
+
try:
|
|
208
|
+
cache = get_cache()
|
|
209
|
+
cache.put(task, resp.content, reasoning=resp.reasoning)
|
|
210
|
+
except Exception:
|
|
211
|
+
pass
|
|
212
|
+
|
|
181
213
|
yield AgentEvent("message", {"content": resp.content})
|
|
182
214
|
extract_and_save_memories(session.workdir, session.context.messages)
|
|
183
215
|
yield AgentEvent("done", {"summary": resp.content})
|
|
@@ -69,12 +69,21 @@ if HAS_FASTAPI:
|
|
|
69
69
|
backend_name = app.state.backend.name if hasattr(app.state, "backend") else "unknown"
|
|
70
70
|
is_avail = app.state.backend.is_available if hasattr(app.state, "backend") else False
|
|
71
71
|
uptime = time.time() - app.state.start_time if hasattr(app.state, "start_time") else 0.0
|
|
72
|
+
|
|
73
|
+
# Cache stats
|
|
74
|
+
try:
|
|
75
|
+
from .cache import get_cache
|
|
76
|
+
cache_stats = get_cache().stats
|
|
77
|
+
except Exception:
|
|
78
|
+
cache_stats = {}
|
|
79
|
+
|
|
72
80
|
return {
|
|
73
81
|
"status": "online",
|
|
74
82
|
"backend": backend_name,
|
|
75
83
|
"available": is_avail,
|
|
76
84
|
"uptime_seconds": round(uptime, 2),
|
|
77
|
-
"version": "0.
|
|
85
|
+
"version": "0.9.2",
|
|
86
|
+
"cache": cache_stats,
|
|
78
87
|
}
|
|
79
88
|
|
|
80
89
|
@app.get("/sessions")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|