alpiecode 8.0.9__tar.gz → 9.0.1__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.
Files changed (47) hide show
  1. {alpiecode-8.0.9 → alpiecode-9.0.1}/PKG-INFO +1 -1
  2. {alpiecode-8.0.9 → alpiecode-9.0.1}/pyproject.toml +1 -1
  3. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/alpiecode.egg-info/PKG-INFO +1 -1
  4. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/alpiecode.egg-info/SOURCES.txt +3 -1
  5. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/__init__.py +1 -1
  6. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/backends/local_backend.py +55 -1
  7. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/backends/openai_backend.py +97 -11
  8. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/client.py +19 -11
  9. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/context.py +65 -17
  10. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/extension/alpiecode.vsix +0 -0
  11. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/memory.py +48 -16
  12. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/orchestrator.py +48 -17
  13. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/prompt.py +2 -2
  14. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/tools.py +17 -3
  15. {alpiecode-8.0.9 → alpiecode-9.0.1}/test/test_phase1.py +56 -1
  16. alpiecode-9.0.1/test/test_phase2.py +231 -0
  17. alpiecode-9.0.1/test/test_phase3.py +154 -0
  18. {alpiecode-8.0.9 → alpiecode-9.0.1}/README.md +0 -0
  19. {alpiecode-8.0.9 → alpiecode-9.0.1}/setup.cfg +0 -0
  20. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/alpiecode/__init__.py +0 -0
  21. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/alpiecode.egg-info/dependency_links.txt +0 -0
  22. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/alpiecode.egg-info/entry_points.txt +0 -0
  23. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/alpiecode.egg-info/requires.txt +0 -0
  24. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/alpiecode.egg-info/top_level.txt +0 -0
  25. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/agent.py +0 -0
  26. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/backends/__init__.py +0 -0
  27. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/backends/base.py +0 -0
  28. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/cache.py +0 -0
  29. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/cli.py +0 -0
  30. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/compaction.py +0 -0
  31. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/config.py +0 -0
  32. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/discovery.py +0 -0
  33. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/doctor.py +0 -0
  34. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/executor.py +0 -0
  35. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/git_ops.py +0 -0
  36. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/github.py +0 -0
  37. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/guardian.py +0 -0
  38. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/guardrails.py +0 -0
  39. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/ipython_ext.py +0 -0
  40. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/local_model.py +0 -0
  41. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/media.py +0 -0
  42. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/progress.py +0 -0
  43. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/rephraser.py +0 -0
  44. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/server.py +0 -0
  45. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/session.py +0 -0
  46. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/updater.py +0 -0
  47. {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/vscode_installer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alpiecode
3
- Version: 8.0.9
3
+ Version: 9.0.1
4
4
  Summary: AlpieCode — Autonomous AI Coding Agent CLI powered by local 169Pi GGUF VLM
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: huggingface_hub>=0.20.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "alpiecode"
7
- version = "8.0.9"
7
+ version = "9.0.1"
8
8
  description = "AlpieCode — Autonomous AI Coding Agent CLI powered by local 169Pi GGUF VLM"
9
9
  requires-python = ">=3.9"
10
10
  dependencies = [
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alpiecode
3
- Version: 8.0.9
3
+ Version: 9.0.1
4
4
  Summary: AlpieCode — Autonomous AI Coding Agent CLI powered by local 169Pi GGUF VLM
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: huggingface_hub>=0.20.0
@@ -40,4 +40,6 @@ src/codeagent/backends/base.py
40
40
  src/codeagent/backends/local_backend.py
41
41
  src/codeagent/backends/openai_backend.py
42
42
  src/codeagent/extension/alpiecode.vsix
43
- test/test_phase1.py
43
+ test/test_phase1.py
44
+ test/test_phase2.py
45
+ test/test_phase3.py
@@ -16,7 +16,7 @@ Supports:
16
16
  from pathlib import Path
17
17
  from typing import Optional, Any, Dict
18
18
 
19
- __version__ = "8.0.9"
19
+ __version__ = "9.0.1"
20
20
 
21
21
  def run(task: str, workdir: str = ".", reasoning_level: str = "thinking", **kwargs):
22
22
  """Run an autonomous coding task programmatically."""
@@ -43,6 +43,59 @@ class LocalBackend:
43
43
  model = self._ensure_model()
44
44
  model.load()
45
45
 
46
+ @staticmethod
47
+ def _sanitize_messages_for_gguf(messages: List[dict]) -> List[dict]:
48
+ """
49
+ Sanitize OpenAI-format messages for GGUF Jinja2 chat templates.
50
+ Fixes 'Can only get item pairs from a mapping' errors by:
51
+ - Ensuring content is always a string (never None or list)
52
+ - Stripping tool_calls and tool messages (GGUF templates don't support them)
53
+ - Removing tool-related role messages
54
+ """
55
+ sanitized = []
56
+ for msg in messages:
57
+ role = msg.get("role", "")
58
+ # Skip tool result messages entirely — GGUF models don't understand them
59
+ if role == "tool":
60
+ continue
61
+
62
+ clean = {"role": role}
63
+
64
+ # Ensure content is always a string
65
+ content = msg.get("content")
66
+ if content is None:
67
+ content = ""
68
+ elif isinstance(content, list):
69
+ # Multimodal content (image_url + text blocks) — extract text parts
70
+ text_parts = []
71
+ for part in content:
72
+ if isinstance(part, dict):
73
+ if part.get("type") == "text":
74
+ text_parts.append(part.get("text", ""))
75
+ elif isinstance(part, str):
76
+ text_parts.append(part)
77
+ content = "\n".join(text_parts) if text_parts else ""
78
+ elif not isinstance(content, str):
79
+ content = str(content)
80
+
81
+ # For assistant messages with tool_calls, embed tool call info in content text
82
+ if role == "assistant" and msg.get("tool_calls"):
83
+ tc_descriptions = []
84
+ for tc in msg["tool_calls"]:
85
+ if isinstance(tc, dict):
86
+ fn = tc.get("function", {})
87
+ name = fn.get("name", "unknown")
88
+ args = fn.get("arguments", "{}")
89
+ tc_descriptions.append(f"[Called tool: {name}({args})]")
90
+ if tc_descriptions:
91
+ content = (content + "\n" + "\n".join(tc_descriptions)).strip()
92
+ # Do NOT include tool_calls key — GGUF template will crash on it
93
+
94
+ clean["content"] = content
95
+ sanitized.append(clean)
96
+
97
+ return sanitized
98
+
46
99
  def chat_completion(
47
100
  self,
48
101
  messages: List[dict],
@@ -52,8 +105,9 @@ class LocalBackend:
52
105
  enable_thinking: bool = True,
53
106
  ) -> ChatResponse:
54
107
  model = self._ensure_model()
108
+ sanitized_msgs = self._sanitize_messages_for_gguf(messages)
55
109
  resp = model.create_chat_completion(
56
- messages=messages,
110
+ messages=sanitized_msgs,
57
111
  tools=tools,
58
112
  tool_choice="auto",
59
113
  temperature=temperature,
@@ -2,9 +2,103 @@
2
2
  OpenAI-compatible API inference backend with smart model resolution.
3
3
  """
4
4
 
5
+ import ast
6
+ import json
7
+ import logging
8
+ import re
5
9
  from typing import Any, List, Optional
6
10
  from openai import OpenAI, NotFoundError
7
11
 
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def _repair_and_parse_json_arguments(raw_args: str) -> dict:
16
+ """
17
+ Robustly parse tool call arguments from potentially partial or malformed streaming strings.
18
+ Handles:
19
+ - Clean JSON
20
+ - Single-quoted JSON / Python dict strings via ast.literal_eval
21
+ - Truncated strings / missing closing quotes & braces from stream cutoffs
22
+ - Trailing commas
23
+ Returns a dict of parsed arguments. If unparseable, returns a dict preserving _raw_arguments
24
+ and _parse_error so the tool dispatcher can provide actionable error reporting.
25
+ """
26
+ if not raw_args or not raw_args.strip():
27
+ return {}
28
+
29
+ text = raw_args.strip()
30
+
31
+ # 1. Direct standard JSON
32
+ try:
33
+ parsed = json.loads(text)
34
+ if isinstance(parsed, dict):
35
+ return parsed
36
+ return {"value": parsed}
37
+ except Exception:
38
+ pass
39
+
40
+ # 2. Try ast.literal_eval (handles Python single quotes, True/False/None)
41
+ try:
42
+ parsed = ast.literal_eval(text)
43
+ if isinstance(parsed, dict):
44
+ return parsed
45
+ except Exception:
46
+ pass
47
+
48
+ # 3. Structural repair for streaming cutoffs / trailing commas
49
+ repaired = text
50
+
51
+ # Remove trailing commas like `..., }` or `..., ]`
52
+ repaired = re.sub(r",\s*([\}\]])", r"\1", repaired)
53
+
54
+ # If trailing comma at end: `{"a": 1,` -> `{"a": 1`
55
+ repaired = re.sub(r",\s*$", "", repaired)
56
+
57
+ # Check unclosed quotes: count non-escaped double quotes
58
+ quote_count = len(re.findall(r'(?<!\\)"', repaired))
59
+ if quote_count % 2 != 0:
60
+ repaired += '"'
61
+
62
+ # Balance unclosed brackets and braces
63
+ open_curly = repaired.count("{") - repaired.count("}")
64
+ open_square = repaired.count("[") - repaired.count("]")
65
+
66
+ if open_square > 0:
67
+ repaired += "]" * open_square
68
+ if open_curly > 0:
69
+ repaired += "}" * open_curly
70
+
71
+ try:
72
+ parsed = json.loads(repaired)
73
+ if isinstance(parsed, dict):
74
+ return parsed
75
+ except Exception:
76
+ pass
77
+
78
+ # 4. If still failing, attempt regex key-value extraction for simple {"key": "val"} forms
79
+ extracted = {}
80
+ pattern = r"""['"]([a-zA-Z0-9_-]+)['"]\s*:\s*(?:['"](.*?)['"]|([0-9.]+|true|false|null))"""
81
+ for kv in re.finditer(pattern, text, re.IGNORECASE):
82
+ k = kv.group(1)
83
+ v_str = kv.group(2)
84
+ v_lit = kv.group(3)
85
+ if v_str is not None:
86
+ extracted[k] = v_str
87
+ elif v_lit is not None:
88
+ try:
89
+ extracted[k] = json.loads(v_lit.lower())
90
+ except Exception:
91
+ extracted[k] = v_lit
92
+
93
+ if extracted:
94
+ return extracted
95
+
96
+ logger.warning("Failed to parse tool call arguments: %s", raw_args[:200])
97
+ return {
98
+ "_raw_arguments": raw_args,
99
+ "_parse_error": "Failed to parse streaming JSON arguments",
100
+ }
101
+
8
102
  from ..config import Config, get_shared_http_client, is_server_reachable
9
103
  from .base import ChatResponse, ToolCall
10
104
 
@@ -185,10 +279,7 @@ class OpenAIBackend:
185
279
  tool_calls = []
186
280
  for idx in sorted(tool_calls_dict.keys()):
187
281
  item = tool_calls_dict[idx]
188
- try:
189
- args = json.loads(item["arguments"])
190
- except Exception:
191
- args = {}
282
+ args = _repair_and_parse_json_arguments(item.get("arguments", ""))
192
283
  tool_calls.append(ToolCall(id=item["id"], name=item["name"], arguments=args))
193
284
 
194
285
  final_content_str = "".join(full_content).strip()
@@ -260,15 +351,10 @@ class OpenAIBackend:
260
351
 
261
352
  tool_calls = None
262
353
  if msg.tool_calls:
263
- import json
264
354
  tool_calls = []
265
355
  for tc in msg.tool_calls:
266
- try:
267
- args = json.loads(tc.function.arguments or "{}")
268
- except (json.JSONDecodeError, TypeError):
269
- args = {}
270
- if not isinstance(args, dict):
271
- args = {}
356
+ raw_args = tc.function.arguments or "{}"
357
+ args = _repair_and_parse_json_arguments(raw_args)
272
358
  tool_calls.append(
273
359
  ToolCall(
274
360
  id=tc.id,
@@ -102,31 +102,39 @@ class AlpieCodeClient:
102
102
  try:
103
103
  with urllib.request.urlopen(req, timeout=self.timeout) as resp:
104
104
  current_event_type = "message"
105
- current_data_str = ""
105
+ current_data_lines: List[str] = []
106
106
 
107
107
  for raw_line in resp:
108
- line = raw_line.decode("utf-8").strip()
108
+ line = raw_line.decode("utf-8").rstrip("\r\n")
109
109
  if not line:
110
- if current_data_str:
110
+ if current_data_lines:
111
+ raw_data = "\n".join(current_data_lines)
111
112
  try:
112
- event_data = json.loads(current_data_str)
113
+ event_data = json.loads(raw_data)
113
114
  except Exception:
114
- event_data = {"content": current_data_str}
115
+ event_data = {"content": raw_data}
115
116
  yield AgentEvent(type=current_event_type, data=event_data)
116
117
  current_event_type = "message"
117
- current_data_str = ""
118
+ current_data_lines = []
118
119
  continue
119
120
 
120
- if line.startswith("event:"):
121
+ if line.startswith(":"):
122
+ # SSE comment/keepalive ping - ignore
123
+ continue
124
+ elif line.startswith("event:"):
121
125
  current_event_type = line[6:].strip()
122
126
  elif line.startswith("data:"):
123
- current_data_str = line[5:].strip()
127
+ chunk = line[5:]
128
+ if chunk.startswith(" "):
129
+ chunk = chunk[1:]
130
+ current_data_lines.append(chunk)
124
131
 
125
- if current_data_str:
132
+ if current_data_lines:
133
+ raw_data = "\n".join(current_data_lines)
126
134
  try:
127
- event_data = json.loads(current_data_str)
135
+ event_data = json.loads(raw_data)
128
136
  except Exception:
129
- event_data = {"content": current_data_str}
137
+ event_data = {"content": raw_data}
130
138
  yield AgentEvent(type=current_event_type, data=event_data)
131
139
  except Exception as e:
132
140
  yield AgentEvent(type="error", data={"error": f"Server connection failed: {e}"})
@@ -6,6 +6,7 @@ and dynamically assembles token-budgeted context windows via build_context()
6
6
  for OpenAI-compatible chat completion endpoints.
7
7
  """
8
8
 
9
+ import copy
9
10
  import json
10
11
  import time
11
12
  from typing import Any, Dict, List, Optional
@@ -52,9 +53,19 @@ class ContextManager:
52
53
  def __init__(self, max_tokens: int = 262_144):
53
54
  self.max_tokens = max_tokens
54
55
  self._messages: List[dict] = []
56
+ self._raw_messages: List[dict] = []
55
57
  self._metadata: List[Dict[str, Any]] = []
56
58
  self._turn_counter: int = 0
57
59
  self._cached_summary: Optional[str] = None
60
+ self._cached_summary_len: int = 0
61
+ self._cached_context: Optional[List[dict]] = None
62
+
63
+ def invalidate_cache(self, reset_summary: bool = False) -> None:
64
+ """Invalidate cached context assembly."""
65
+ self._cached_context = None
66
+ if reset_summary:
67
+ self._cached_summary = None
68
+ self._cached_summary_len = 0
58
69
 
59
70
  def _estimate_single_message_tokens(self, msg: dict) -> int:
60
71
  """Estimate token count for an individual message."""
@@ -141,27 +152,37 @@ class ContextManager:
141
152
 
142
153
  def set_system_prompt(self, prompt: str) -> None:
143
154
  """Set or update the root system prompt (Layer 1)."""
155
+ self.invalidate_cache(reset_summary=True)
144
156
  sys_msg = {"role": "system", "content": prompt}
145
157
  if self._messages and self._messages[0].get("role") == "system":
146
- self._messages[0] = sys_msg
158
+ self._messages[0] = dict(sys_msg)
159
+ if self._raw_messages and self._raw_messages[0].get("role") == "system":
160
+ self._raw_messages[0] = copy.deepcopy(sys_msg)
161
+ else:
162
+ self._raw_messages.insert(0, copy.deepcopy(sys_msg))
147
163
  if self._metadata:
148
164
  self._metadata[0] = self._build_metadata_entry(sys_msg, importance="high")
149
165
  else:
150
- self._messages.insert(0, sys_msg)
166
+ self._messages.insert(0, dict(sys_msg))
167
+ self._raw_messages.insert(0, copy.deepcopy(sys_msg))
151
168
  self._metadata.insert(0, self._build_metadata_entry(sys_msg, importance="high"))
152
169
 
153
170
  def add_user_message(self, content: Any, metadata: Optional[dict] = None) -> None:
154
171
  """Append a user request and record metadata."""
172
+ self.invalidate_cache()
155
173
  self._turn_counter += 1
156
174
  msg = {"role": "user", "content": content}
157
- self._messages.append(msg)
175
+ self._messages.append(dict(msg))
176
+ self._raw_messages.append(copy.deepcopy(msg))
158
177
  self._metadata.append(self._build_metadata_entry(msg, custom=metadata))
159
178
 
160
179
  def add_assistant_response(self, response: ChatResponse, metadata: Optional[dict] = None) -> None:
161
180
  """Append an assistant response with tool calls and record metadata."""
162
181
  if response.tool_calls or response.content:
182
+ self.invalidate_cache()
163
183
  msg = _serialize_assistant_message(response)
164
- self._messages.append(msg)
184
+ self._messages.append(dict(msg))
185
+ self._raw_messages.append(copy.deepcopy(msg))
165
186
  tool_names = [tc.name for tc in response.tool_calls] if response.tool_calls else []
166
187
  custom_meta = metadata.copy() if metadata else {}
167
188
  custom_meta["tool_names"] = tool_names
@@ -176,12 +197,14 @@ class ContextManager:
176
197
  metadata: Optional[dict] = None,
177
198
  ) -> None:
178
199
  """Append a tool execution result and record metadata."""
200
+ self.invalidate_cache()
179
201
  msg = {
180
202
  "role": "tool",
181
203
  "tool_call_id": tool_call_id,
182
204
  "content": content,
183
205
  }
184
- self._messages.append(msg)
206
+ self._messages.append(dict(msg))
207
+ self._raw_messages.append(copy.deepcopy(msg))
185
208
  self._metadata.append(
186
209
  self._build_metadata_entry(msg, tool_name=tool_name, custom=metadata)
187
210
  )
@@ -190,7 +213,9 @@ class ContextManager:
190
213
  """
191
214
  Truncate large historical tool outputs from turns older than keep_last_turns.
192
215
  Preserves update_plan and short outputs.
216
+ Note: Operates on working _messages, while _raw_messages / raw_history remains pristine.
193
217
  """
218
+ self.invalidate_cache()
194
219
  assistant_indices = [
195
220
  i for i, m in enumerate(self._messages)
196
221
  if isinstance(m, dict) and m.get("role") == "assistant"
@@ -206,6 +231,8 @@ class ContextManager:
206
231
  # Never truncate update_plan output
207
232
  if "[Plan updated]" in content or "Plan Status:" in content or len(content) <= 300:
208
233
  continue
234
+ msg = dict(msg)
235
+ self._messages[i] = msg
209
236
  lines = content.splitlines()
210
237
  if len(lines) > 8:
211
238
  preview_start = "\n".join(lines[:3])
@@ -215,6 +242,7 @@ class ContextManager:
215
242
  msg["content"] = content[:150] + f"... [truncated {len(content)} chars]"
216
243
  # Update tokens in metadata
217
244
  if i < len(self._metadata):
245
+ self._metadata[i] = dict(self._metadata[i])
218
246
  self._metadata[i]["tokens"] = self._estimate_single_message_tokens(msg)
219
247
 
220
248
  def build_context(
@@ -230,6 +258,10 @@ class ContextManager:
230
258
  4. recent messages (intact tool-call pairs)
231
259
  5. current request (tail of conversation)
232
260
  """
261
+ is_default_params = (max_tokens is None or max_tokens == self.max_tokens) and recent_turns == 4
262
+ if is_default_params and self._cached_context is not None:
263
+ return [dict(m) for m in self._cached_context]
264
+
233
265
  if not self._messages:
234
266
  return []
235
267
 
@@ -276,10 +308,15 @@ class ContextManager:
276
308
  context_blocks.append(system_msg)
277
309
 
278
310
  if distant_messages:
279
- # Layer 2: Distant Summary
280
- summary_text = self._cached_summary or extract_conversation_summary(
281
- distant_messages, distant_metadata
282
- )
311
+ # Layer 2: Distant Summary (Lazy-cached)
312
+ if self._cached_summary is not None and len(distant_messages) == self._cached_summary_len:
313
+ summary_text = self._cached_summary
314
+ else:
315
+ summary_text = extract_conversation_summary(
316
+ distant_messages, distant_metadata
317
+ )
318
+ self._cached_summary = summary_text
319
+ self._cached_summary_len = len(distant_messages)
283
320
  # Layer 3: Relevant History Anchors
284
321
  relevant_anchors = select_relevant_history(
285
322
  distant_messages, distant_metadata
@@ -292,10 +329,16 @@ class ContextManager:
292
329
  supplemental_parts.append(relevant_anchors)
293
330
 
294
331
  if supplemental_parts:
295
- context_blocks.append({
296
- "role": "system",
297
- "content": "\n\n".join(supplemental_parts),
298
- })
332
+ supp_text = "=== CONVERSATION SUMMARY & RELEVANT HISTORY ===\n" + "\n\n".join(supplemental_parts)
333
+ if context_blocks and context_blocks[0].get("role") == "system":
334
+ # Merge into the existing primary system prompt to ensure strictly one system message at the beginning
335
+ context_blocks[0] = dict(context_blocks[0])
336
+ context_blocks[0]["content"] = (context_blocks[0].get("content") or "") + "\n\n" + supp_text
337
+ else:
338
+ context_blocks.insert(0, {
339
+ "role": "system",
340
+ "content": supp_text,
341
+ })
299
342
 
300
343
  # Ensure user query anchor: endpoint requires at least one 'user' message
301
344
  has_user_in_recent = any(m.get("role") == "user" for m in recent_messages)
@@ -325,7 +368,10 @@ class ContextManager:
325
368
  if estimate_tokens(context_blocks) > effective_limit:
326
369
  context_blocks = compact_messages(context_blocks)
327
370
 
328
- return context_blocks
371
+ if is_default_params:
372
+ self._cached_context = [dict(m) for m in context_blocks]
373
+
374
+ return [dict(m) for m in context_blocks]
329
375
 
330
376
  @property
331
377
  def messages(self) -> List[dict]:
@@ -335,18 +381,20 @@ class ContextManager:
335
381
  @messages.setter
336
382
  def messages(self, msgs: List[dict]) -> None:
337
383
  """Replace messages and rebuild metadata."""
338
- self._messages = list(msgs)
384
+ self.invalidate_cache(reset_summary=True)
385
+ self._messages = [dict(m) for m in msgs]
386
+ self._raw_messages = [copy.deepcopy(m) for m in msgs]
339
387
  self._rebuild_metadata()
340
388
 
341
389
  @property
342
390
  def all_messages(self) -> List[dict]:
343
391
  """Returns the complete, uncompressed historical messages."""
344
- return list(self._messages)
392
+ return [copy.deepcopy(m) for m in self._raw_messages]
345
393
 
346
394
  @property
347
395
  def raw_history(self) -> List[dict]:
348
396
  """Alias for all_messages."""
349
- return list(self._messages)
397
+ return [copy.deepcopy(m) for m in self._raw_messages]
350
398
 
351
399
  @property
352
400
  def metadata(self) -> List[Dict[str, Any]]:
@@ -47,36 +47,64 @@ def load_memories(workdir: Path) -> List[dict]:
47
47
  return []
48
48
 
49
49
 
50
- def save_memory(workdir: Path, content: str, memory_type: str = "learning") -> None:
50
+ def save_memories_batch(workdir: Path, entries: List[dict]) -> None:
51
51
  """
52
- Save a memory entry for a project.
52
+ Save multiple memory entries for a project in a single atomic disk write.
53
53
 
54
54
  Args:
55
55
  workdir: Project directory
56
- content: The memory content to save
57
- memory_type: Type of memory (learning, structure, command, pattern)
56
+ entries: List of dicts, each with 'content' and optional 'type'
58
57
  """
58
+ if not entries:
59
+ return
60
+
59
61
  MEMORY_DIR.mkdir(parents=True, exist_ok=True)
60
62
  path = _memory_path(workdir)
61
63
 
62
64
  existing = load_memories(workdir)
63
- existing.append({
64
- "content": content,
65
- "type": memory_type,
66
- "timestamp": time.time(),
67
- "workdir": str(workdir.resolve()),
68
- })
65
+ existing_contents = {m.get("content") for m in existing}
66
+ now = time.time()
67
+ workdir_str = str(workdir.resolve())
68
+
69
+ added = False
70
+ for entry in entries:
71
+ c = entry.get("content", "").strip()
72
+ if not c or c in existing_contents:
73
+ continue
74
+ existing.append({
75
+ "content": c,
76
+ "type": entry.get("type", "learning"),
77
+ "timestamp": now,
78
+ "workdir": workdir_str,
79
+ })
80
+ existing_contents.add(c)
81
+ added = True
82
+
83
+ if not added:
84
+ return
69
85
 
70
86
  # Keep only the last 20 memories per project (FIFO)
71
87
  if len(existing) > 20:
72
88
  existing = existing[-20:]
73
89
 
74
90
  path.write_text(json.dumps({
75
- "project": str(workdir.resolve()),
91
+ "project": workdir_str,
76
92
  "memories": existing,
77
93
  }, indent=2))
78
94
 
79
95
 
96
+ def save_memory(workdir: Path, content: str, memory_type: str = "learning") -> None:
97
+ """
98
+ Save a single memory entry for a project.
99
+
100
+ Args:
101
+ workdir: Project directory
102
+ content: The memory content to save
103
+ memory_type: Type of memory (learning, structure, command, pattern)
104
+ """
105
+ save_memories_batch(workdir, [{"content": content, "type": memory_type}])
106
+
107
+
80
108
  def format_memories_for_prompt(workdir: Path) -> Optional[str]:
81
109
  """
82
110
  Format memories into a string suitable for injection into the system prompt.
@@ -102,13 +130,14 @@ def format_memories_for_prompt(workdir: Path) -> Optional[str]:
102
130
  def extract_and_save_memories(workdir: Path, messages: list) -> None:
103
131
  """
104
132
  After a session ends, extract key learnings from the conversation
105
- and save them as memories.
133
+ and save them as memories in a single atomic batch.
106
134
 
107
135
  Scans tool results for commonly useful information like:
108
136
  - Successful build/test commands
109
137
  - Project structure (from list_files results)
110
138
  - Completion summaries
111
139
  """
140
+ to_save: List[dict] = []
112
141
  saved_cmds = set() # Avoid duplicate command memories
113
142
 
114
143
  for idx, msg in enumerate(messages):
@@ -137,20 +166,23 @@ def extract_and_save_memories(workdir: Path, messages: list) -> None:
137
166
  cmd = ""
138
167
  if cmd and cmd not in saved_cmds:
139
168
  if any(kw in cmd for kw in ["pytest", "test", "unittest", "npm run test", "cargo test", "go test"]):
140
- save_memory(workdir, f"Working test command: {cmd}", "command")
169
+ to_save.append({"content": f"Working test command: {cmd}", "type": "command"})
141
170
  saved_cmds.add(cmd)
142
171
  elif any(kw in cmd for kw in ["build", "compile", "g++", "gcc", "make", "cargo build", "npm run build"]):
143
- save_memory(workdir, f"Working build command: {cmd}", "command")
172
+ to_save.append({"content": f"Working build command: {cmd}", "type": "command"})
144
173
  saved_cmds.add(cmd)
145
174
  break
146
175
 
147
176
  # ── Save project structure from list_files / tree output ──
148
177
  if "├" in content or "└" in content:
149
178
  if len(content) < 2000:
150
- save_memory(workdir, f"Project structure:\n{content[:500]}", "structure")
179
+ to_save.append({"content": f"Project structure:\n{content[:500]}", "type": "structure"})
151
180
 
152
181
  # ── Save completion summaries ──
153
182
  if msg.get("role") == "assistant" and msg.get("content"):
154
183
  content = msg["content"]
155
184
  if content.strip().startswith("DONE"):
156
- save_memory(workdir, content.strip()[:200], "completion_summary")
185
+ to_save.append({"content": content.strip()[:200], "type": "completion_summary"})
186
+
187
+ if to_save:
188
+ save_memories_batch(workdir, to_save)