memocode 0.2.2__tar.gz → 0.3.0__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.
- {memocode-0.2.2 → memocode-0.3.0}/PKG-INFO +1 -2
- {memocode-0.2.2 → memocode-0.3.0}/README.md +0 -1
- {memocode-0.2.2 → memocode-0.3.0}/control/brain.py +247 -40
- memocode-0.3.0/control/chatmem/compressor.py +176 -0
- {memocode-0.2.2 → memocode-0.3.0}/control/chatmem/context_manager.py +135 -11
- {memocode-0.2.2 → memocode-0.3.0}/control/chatmem/memory/consolidation.py +2 -6
- {memocode-0.2.2 → memocode-0.3.0}/control/chatmem/memory/core_memory.py +62 -24
- {memocode-0.2.2 → memocode-0.3.0}/control/chatmem/memory/recent_memory.py +49 -0
- {memocode-0.2.2 → memocode-0.3.0}/memocode.egg-info/PKG-INFO +1 -2
- {memocode-0.2.2 → memocode-0.3.0}/pyproject.toml +1 -1
- {memocode-0.2.2 → memocode-0.3.0}/run.py +86 -54
- {memocode-0.2.2 → memocode-0.3.0}/safety/safety.py +9 -3
- memocode-0.2.2/control/chatmem/compressor.py +0 -288
- {memocode-0.2.2 → memocode-0.3.0}/control/__init__.py +0 -0
- {memocode-0.2.2 → memocode-0.3.0}/control/audit.py +0 -0
- {memocode-0.2.2 → memocode-0.3.0}/control/chatmem/__init__.py +0 -0
- {memocode-0.2.2 → memocode-0.3.0}/control/chatmem/cli.py +0 -0
- {memocode-0.2.2 → memocode-0.3.0}/control/chatmem/config.py +0 -0
- {memocode-0.2.2 → memocode-0.3.0}/control/chatmem/mcp_server.py +0 -0
- {memocode-0.2.2 → memocode-0.3.0}/control/chatmem/memory/__init__.py +0 -0
- {memocode-0.2.2 → memocode-0.3.0}/control/chatmem/memory/forgetting.py +0 -0
- {memocode-0.2.2 → memocode-0.3.0}/control/chatmem/server.py +0 -0
- {memocode-0.2.2 → memocode-0.3.0}/control/chatmem/token_counter.py +0 -0
- {memocode-0.2.2 → memocode-0.3.0}/control/fmt.py +0 -0
- {memocode-0.2.2 → memocode-0.3.0}/control/llm.py +0 -0
- {memocode-0.2.2 → memocode-0.3.0}/control/planner.py +0 -0
- {memocode-0.2.2 → memocode-0.3.0}/control/project_manager.py +0 -0
- {memocode-0.2.2 → memocode-0.3.0}/memocode.egg-info/SOURCES.txt +0 -0
- {memocode-0.2.2 → memocode-0.3.0}/memocode.egg-info/dependency_links.txt +0 -0
- {memocode-0.2.2 → memocode-0.3.0}/memocode.egg-info/entry_points.txt +0 -0
- {memocode-0.2.2 → memocode-0.3.0}/memocode.egg-info/requires.txt +0 -0
- {memocode-0.2.2 → memocode-0.3.0}/memocode.egg-info/top_level.txt +0 -0
- {memocode-0.2.2 → memocode-0.3.0}/safety/__init__.py +0 -0
- {memocode-0.2.2 → memocode-0.3.0}/safety/backup.py +0 -0
- {memocode-0.2.2 → memocode-0.3.0}/safety/policy.py +0 -0
- {memocode-0.2.2 → memocode-0.3.0}/setup.cfg +0 -0
- {memocode-0.2.2 → memocode-0.3.0}/tools/__init__.py +0 -0
- {memocode-0.2.2 → memocode-0.3.0}/tools/file.py +0 -0
- {memocode-0.2.2 → memocode-0.3.0}/tools/loader.py +0 -0
- {memocode-0.2.2 → memocode-0.3.0}/tools/registry.py +0 -0
- {memocode-0.2.2 → memocode-0.3.0}/tools/shell.py +0 -0
- {memocode-0.2.2 → memocode-0.3.0}/tools/web.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memocode
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Personal AI coding agent with memory, tool execution, and safety controls
|
|
5
5
|
Author: AssassinCHN
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -14,7 +14,6 @@ Requires-Dist: certifi>=2024.0
|
|
|
14
14
|
|
|
15
15
|
Personal AI coding agent CLI with memory, tool execution, and safety controls.
|
|
16
16
|
|
|
17
|
-
**Author:** AssassinCHN
|
|
18
17
|
|
|
19
18
|
## Features
|
|
20
19
|
|
|
@@ -68,6 +68,8 @@ def _tool_hint(name: str, args: dict) -> str:
|
|
|
68
68
|
return dim(args.get("pattern", ""))
|
|
69
69
|
if name == "web_fetch":
|
|
70
70
|
return dim(args.get("url", ""))
|
|
71
|
+
if name == "memory_search":
|
|
72
|
+
return dim(args.get("query", ""))
|
|
71
73
|
return ""
|
|
72
74
|
|
|
73
75
|
|
|
@@ -93,42 +95,106 @@ def _print_tool_header_simple(name: str, args: dict):
|
|
|
93
95
|
print(dim(f" {extra}"))
|
|
94
96
|
|
|
95
97
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
_PROJECT_MEMORY_JSON = os.path.join(_DATA_DIR, "project_memory.json")
|
|
101
|
+
|
|
102
|
+
_DEFAULT_PROJECT_MEMORY = {
|
|
103
|
+
"dimensions": {
|
|
104
|
+
"architecture": {"label": "Tech stack, modules, structure", "stability": 40.0},
|
|
105
|
+
"decisions": {"label": "Confirmed design decisions and rationale", "stability": 35.0},
|
|
106
|
+
"conventions": {"label": "Naming, interface, format conventions", "stability": 35.0},
|
|
107
|
+
"context": {"label": "Project background, goals, constraints", "stability": 30.0},
|
|
108
|
+
"progress": {"label": "Current phase, pending key tasks", "stability": 20.0},
|
|
109
|
+
"completed": {"label": "Completed tasks and milestones", "stability": 25.0},
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
def _build_project_judge_prompt(dimensions: dict) -> str:
|
|
114
|
+
dim_lines = "\n".join(f"- {name}: {cfg.label}" for name, cfg in dimensions.items())
|
|
115
|
+
dim_names = ", ".join(f'"{name}"' for name in dimensions)
|
|
116
|
+
return f"""You are a project memory editor. Review the conversation and update the project's knowledge base.
|
|
117
|
+
|
|
118
|
+
Project memory dimensions:
|
|
119
|
+
{dim_lines}
|
|
120
|
+
|
|
121
|
+
Existing project memory:
|
|
122
|
+
{{existing}}
|
|
123
|
+
|
|
124
|
+
New conversation:
|
|
125
|
+
{{text}}
|
|
126
|
+
|
|
127
|
+
Rules:
|
|
128
|
+
- One entry per dimension. Key must equal the dimension name (e.g. key="decisions").
|
|
129
|
+
- For each dimension, choose one action:
|
|
130
|
+
SKIP — no new project-relevant information for this dimension.
|
|
131
|
+
SUPPLEMENT — new info extends existing (merge into one updated value).
|
|
132
|
+
UPDATE — existing info is clearly superseded or corrected.
|
|
133
|
+
- Special rules by dimension:
|
|
134
|
+
completed: always SUPPLEMENT (append new completions, never erase old ones).
|
|
135
|
+
progress: UPDATE freely when the current phase or pending tasks change.
|
|
136
|
+
ONLY record what the user explicitly stated. Do NOT infer, extrapolate,
|
|
137
|
+
or add typical/expected follow-up tasks not mentioned in the conversation.
|
|
138
|
+
decisions: SUPPLEMENT by default; UPDATE only if the user makes an explicit statement reversing a prior decision
|
|
139
|
+
(e.g. "we're switching from X to Y", "decided to drop X"). Do NOT update based on the assistant
|
|
140
|
+
discussing alternatives, or the user asking hypothetical questions about other options.
|
|
141
|
+
architecture/conventions: SUPPLEMENT by default; UPDATE on explicit refactor or migration.
|
|
142
|
+
- Return ONLY entries that changed. Omit SKIPped ones.
|
|
143
|
+
- Exclude: user personal preferences, one-time questions, reasoning processes.
|
|
144
|
+
- Never fabricate or infer information not present in the conversation.
|
|
145
|
+
|
|
146
|
+
Return a JSON array (empty if nothing changed):
|
|
147
|
+
[{{"key": "<dimension_name>", "value": "<merged or new value>", "dimension": "<dimension_name>"}}]
|
|
148
|
+
Dimension name must be one of: {dim_names}
|
|
149
|
+
|
|
150
|
+
Output JSON only:"""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _build_project_judge_prompt_zh(dimensions: dict) -> str:
|
|
154
|
+
dim_lines = "\n".join(f"- {name}: {cfg.label}" for name, cfg in dimensions.items())
|
|
155
|
+
dim_names = ", ".join(f'"{name}"' for name in dimensions)
|
|
156
|
+
return f"""你是一个项目记忆编辑器。审查对话内容,更新项目知识库。
|
|
157
|
+
|
|
158
|
+
项目记忆维度:
|
|
159
|
+
{dim_lines}
|
|
160
|
+
|
|
161
|
+
当前项目记忆:
|
|
162
|
+
{{existing}}
|
|
163
|
+
|
|
164
|
+
新对话:
|
|
165
|
+
{{text}}
|
|
166
|
+
|
|
167
|
+
规则:
|
|
168
|
+
- 每个维度只有一条记录,key 等于维度名(如 key="decisions")。
|
|
169
|
+
- 对每个维度,选择以下操作之一:
|
|
170
|
+
跳过 — 该维度没有新的项目相关信息。
|
|
171
|
+
补充 — 新信息扩展了已有内容(合并为一个更新后的 value)。
|
|
172
|
+
修正 — 已有信息被明确推翻或替换。
|
|
173
|
+
- 各维度特殊规则:
|
|
174
|
+
completed:始终补充(追加新完成内容,不删除旧记录)。
|
|
175
|
+
progress:当前阶段或待办任务变化时,直接修正。
|
|
176
|
+
只记录用户明确说出的内容,严禁推断、外推或添加"典型后续任务"。
|
|
177
|
+
用户未提及的待办事项一律不写。
|
|
178
|
+
decisions:默认补充;仅当用户明确陈述推翻先前决策时才修正
|
|
179
|
+
(如"我们从X换成Y了"、"决定放弃X")。不得因为
|
|
180
|
+
AI 讨论替代方案、或用户询问假设性问题而修正。
|
|
181
|
+
architecture/conventions:默认补充;仅在明确重构或迁移时修正。
|
|
182
|
+
- 只返回发生变化的条目,跳过的不返回。
|
|
183
|
+
- 不包括:用户个人偏好、一次性问题、推理过程。
|
|
184
|
+
- 严禁捏造或推断对话中未出现的信息。
|
|
185
|
+
|
|
186
|
+
返回JSON数组(无变化则返回空数组):
|
|
187
|
+
[{{"key": "<维度名>", "value": "<合并后或新的内容>", "dimension": "<维度名>"}}]
|
|
188
|
+
维度名必须是以下之一:{dim_names}
|
|
189
|
+
|
|
190
|
+
只输出JSON:"""
|
|
120
191
|
|
|
121
192
|
|
|
122
193
|
_DEFAULT_CHATMEM = {
|
|
123
194
|
"llm": {},
|
|
124
195
|
"dimensions": {
|
|
125
|
-
"
|
|
126
|
-
"
|
|
127
|
-
"goals": {"label": "What I want", "stability": 30.0},
|
|
128
|
-
"preferences": {"label": "Likes / dislikes", "stability": 25.0},
|
|
129
|
-
"capabilities": {"label": "What I can do", "stability": 30.0},
|
|
130
|
-
"emotional": {"label": "How I react", "stability": 20.0},
|
|
131
|
-
"autobiography": {"label": "Key experiences", "stability": 35.0},
|
|
196
|
+
"communication": {"label": "Expression style, language preference, tone, detail level", "stability": 45.0},
|
|
197
|
+
"autonomy": {"label": "How much decision authority delegated to LLM: executes directly vs presents options vs validates", "stability": 40.0},
|
|
132
198
|
},
|
|
133
199
|
}
|
|
134
200
|
|
|
@@ -170,11 +236,11 @@ _DEFAULT_AGENT = {
|
|
|
170
236
|
}
|
|
171
237
|
|
|
172
238
|
|
|
173
|
-
def _init_data_dir() -> tuple[str, str]:
|
|
239
|
+
def _init_data_dir() -> tuple[str, str, str]:
|
|
174
240
|
"""
|
|
175
|
-
Ensure ~/.mcode/ exists with agent.json
|
|
241
|
+
Ensure ~/.mcode/ exists with agent.json, chatmem.json, project_memory.json.
|
|
176
242
|
Migrates from control/ if old files exist and new ones don't.
|
|
177
|
-
Returns (agent_json_path, chatmem_json_path).
|
|
243
|
+
Returns (agent_json_path, chatmem_json_path, project_memory_json_path).
|
|
178
244
|
"""
|
|
179
245
|
import json as _json
|
|
180
246
|
import shutil as _shutil
|
|
@@ -184,6 +250,7 @@ def _init_data_dir() -> tuple[str, str]:
|
|
|
184
250
|
|
|
185
251
|
agent_path = os.path.join(_DATA_DIR, "agent.json")
|
|
186
252
|
chatmem_path = os.path.join(_DATA_DIR, "chatmem.json")
|
|
253
|
+
project_memory_path = _PROJECT_MEMORY_JSON
|
|
187
254
|
|
|
188
255
|
# Migrate agent.json from old location
|
|
189
256
|
old_agent = os.path.join(_CONTROL, "agent.json")
|
|
@@ -205,7 +272,13 @@ def _init_data_dir() -> tuple[str, str]:
|
|
|
205
272
|
_json.dump(_DEFAULT_CHATMEM, f, indent=2, ensure_ascii=False)
|
|
206
273
|
f.write("\n")
|
|
207
274
|
|
|
208
|
-
|
|
275
|
+
# Create project_memory.json with default project dimensions
|
|
276
|
+
if not os.path.exists(project_memory_path):
|
|
277
|
+
with open(project_memory_path, "w") as f:
|
|
278
|
+
_json.dump(_DEFAULT_PROJECT_MEMORY, f, indent=2, ensure_ascii=False)
|
|
279
|
+
f.write("\n")
|
|
280
|
+
|
|
281
|
+
return agent_path, chatmem_path, project_memory_path
|
|
209
282
|
|
|
210
283
|
|
|
211
284
|
def _sync_active_model(agent_cfg: dict, chatmem_json_path: str | None):
|
|
@@ -227,7 +300,7 @@ def _sync_active_model(agent_cfg: dict, chatmem_json_path: str | None):
|
|
|
227
300
|
profile = model_cfg
|
|
228
301
|
agent_cfg["active_model"] = active
|
|
229
302
|
# Save the updated active_model back to agent.json
|
|
230
|
-
agent_path, _ = _init_data_dir()
|
|
303
|
+
agent_path, *_ = _init_data_dir()
|
|
231
304
|
with open(agent_path) as f:
|
|
232
305
|
import json as _json
|
|
233
306
|
data = _json.load(f)
|
|
@@ -257,9 +330,10 @@ class Brain:
|
|
|
257
330
|
config_path: str | None = None,
|
|
258
331
|
verbose: bool | None = None,
|
|
259
332
|
project: str | None = None,
|
|
333
|
+
no_lock: bool = False,
|
|
260
334
|
):
|
|
261
335
|
# Ensure ~/.mcode/ exists; migrate old files if needed
|
|
262
|
-
agent_path, chatmem_path = _init_data_dir()
|
|
336
|
+
agent_path, chatmem_path, project_memory_path = _init_data_dir()
|
|
263
337
|
|
|
264
338
|
self._agent_cfg_path = agent_path
|
|
265
339
|
self._config_path = config_path or chatmem_path
|
|
@@ -319,12 +393,25 @@ class Brain:
|
|
|
319
393
|
else:
|
|
320
394
|
self.projects.find_or_create_for_cwd()
|
|
321
395
|
|
|
396
|
+
# Project memory — per-project CoreMemory in the same project DB
|
|
397
|
+
self.project_memory = self._make_project_memory(
|
|
398
|
+
cfg, _api_key, project_memory_path
|
|
399
|
+
)
|
|
400
|
+
|
|
322
401
|
# Memory layer — one DB per project, core DB shared
|
|
323
402
|
self.memory = ContextManager.create(
|
|
324
403
|
config=cfg,
|
|
325
404
|
api_key=_api_key,
|
|
326
405
|
db_path=self.projects.project_db_path,
|
|
327
406
|
core_db_path=self.projects.core_db_path,
|
|
407
|
+
no_lock=no_lock,
|
|
408
|
+
extra_context_fn=(
|
|
409
|
+
self.project_memory.to_context_string if self.project_memory else None
|
|
410
|
+
),
|
|
411
|
+
on_compress_fn=(
|
|
412
|
+
self._run_project_judge if self.project_memory else None
|
|
413
|
+
),
|
|
414
|
+
auto_recall=False,
|
|
328
415
|
)
|
|
329
416
|
|
|
330
417
|
# Audit log
|
|
@@ -339,6 +426,7 @@ class Brain:
|
|
|
339
426
|
self.registry.register(GLOB_TOOL)
|
|
340
427
|
self.registry.register(GREP_TOOL)
|
|
341
428
|
self.registry.register(WEB_FETCH_TOOL)
|
|
429
|
+
self.registry.register(self._make_memory_search_tool())
|
|
342
430
|
load_external_tools(self.registry)
|
|
343
431
|
|
|
344
432
|
self._max_tool_iter: int = agent_cfg.get("max_tool_iter", 40)
|
|
@@ -625,7 +713,8 @@ class Brain:
|
|
|
625
713
|
return True
|
|
626
714
|
|
|
627
715
|
if self.auto_mode:
|
|
628
|
-
|
|
716
|
+
user_wl = self._agent_cfg.get("auto_whitelist", None)
|
|
717
|
+
whitelist = list(set(_safety.DEFAULT_AUTO_WHITELIST) | set(user_wl)) if user_wl else None
|
|
629
718
|
result = _safety.check_auto(task, work_dir=work_dir, whitelist=whitelist)
|
|
630
719
|
if result.violation:
|
|
631
720
|
print(red(f"\n🛑 AUTO-BLOCKED {result.reason}"))
|
|
@@ -787,20 +876,111 @@ class Brain:
|
|
|
787
876
|
# ------------------------------------------------------------------
|
|
788
877
|
|
|
789
878
|
def switch_project(self, name: str):
|
|
790
|
-
self.
|
|
879
|
+
self.end_session()
|
|
791
880
|
self.projects.switch_project(name)
|
|
792
881
|
self._reinit_memory()
|
|
793
882
|
|
|
883
|
+
def _make_memory_search_tool(self):
|
|
884
|
+
"""Build a memory_search Tool that searches recent_memory for relevant past context."""
|
|
885
|
+
from tools.registry import Tool, ToolSchema
|
|
886
|
+
recent_memory = self.memory.recent_memory
|
|
887
|
+
|
|
888
|
+
def _search(query: str, top_k: int = 5) -> str:
|
|
889
|
+
results = recent_memory.search(query, top_k=top_k, min_score=0.0)
|
|
890
|
+
if not results:
|
|
891
|
+
return "No relevant memory found."
|
|
892
|
+
return "\n\n---\n\n".join(results)
|
|
893
|
+
|
|
894
|
+
return Tool(
|
|
895
|
+
schema=ToolSchema(
|
|
896
|
+
name="memory_search",
|
|
897
|
+
description=(
|
|
898
|
+
"Search compressed summaries of past conversation sessions for relevant context. "
|
|
899
|
+
"Call this when the user references something that may have been discussed in a previous session "
|
|
900
|
+
"and you don't have that information in the current context. "
|
|
901
|
+
"Use key terms from the user's question as the query."
|
|
902
|
+
),
|
|
903
|
+
parameters={
|
|
904
|
+
"type": "object",
|
|
905
|
+
"properties": {
|
|
906
|
+
"query": {
|
|
907
|
+
"type": "string",
|
|
908
|
+
"description": "Key terms to search for (e.g. '8080 端口', 'authentication design')",
|
|
909
|
+
},
|
|
910
|
+
"top_k": {
|
|
911
|
+
"type": "integer",
|
|
912
|
+
"description": "Max results to return (default 5)",
|
|
913
|
+
"default": 5,
|
|
914
|
+
},
|
|
915
|
+
},
|
|
916
|
+
"required": ["query"],
|
|
917
|
+
},
|
|
918
|
+
),
|
|
919
|
+
fn=_search,
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
def _make_project_memory(self, cfg: "ContextConfig", api_key: str, project_memory_path: str):
|
|
923
|
+
"""Instantiate a CoreMemory for project-scoped knowledge."""
|
|
924
|
+
import json as _json
|
|
925
|
+
from chatmem.memory.core_memory import CoreMemory
|
|
926
|
+
from chatmem.config import DimensionConfig
|
|
927
|
+
|
|
928
|
+
try:
|
|
929
|
+
with open(project_memory_path) as f:
|
|
930
|
+
pm_data = _json.load(f)
|
|
931
|
+
dims = {
|
|
932
|
+
k: DimensionConfig(label=v["label"], stability=float(v["stability"]))
|
|
933
|
+
for k, v in pm_data.get("dimensions", {}).items()
|
|
934
|
+
}
|
|
935
|
+
except Exception:
|
|
936
|
+
dims = {}
|
|
937
|
+
if not dims:
|
|
938
|
+
return None
|
|
939
|
+
|
|
940
|
+
compress_model = cfg.llm.compress_model or cfg.llm.model
|
|
941
|
+
_THINKING_KEYS = {"reasoning_split", "thinking", "reasoning_effort"}
|
|
942
|
+
extra_body = (
|
|
943
|
+
{k: v for k, v in cfg.llm.extra_body.items() if k not in _THINKING_KEYS}
|
|
944
|
+
if cfg.llm.extra_body else None
|
|
945
|
+
) or None
|
|
946
|
+
provider = getattr(cfg.llm, "provider", "openai") or "openai"
|
|
947
|
+
|
|
948
|
+
pm = CoreMemory(
|
|
949
|
+
db_path=self.projects.project_db_path,
|
|
950
|
+
api_key=api_key,
|
|
951
|
+
model=compress_model,
|
|
952
|
+
base_url=cfg.llm.base_url,
|
|
953
|
+
dimensions=dims,
|
|
954
|
+
extra_body=extra_body,
|
|
955
|
+
provider=provider,
|
|
956
|
+
context_label="Project Memory",
|
|
957
|
+
)
|
|
958
|
+
|
|
959
|
+
# Override judge prompts with project-specific framing
|
|
960
|
+
pm._judge_prompt_en = _build_project_judge_prompt(dims)
|
|
961
|
+
pm._judge_prompt_zh = _build_project_judge_prompt_zh(dims)
|
|
962
|
+
return pm
|
|
963
|
+
|
|
794
964
|
def _reinit_memory(self):
|
|
795
965
|
if getattr(self, "memory", None) is not None:
|
|
796
966
|
self.memory.close()
|
|
797
967
|
cfg = ContextConfig.from_json(self._config_path) if self._config_path else ContextConfig()
|
|
968
|
+
api_key = cfg.llm.resolve_api_key()
|
|
969
|
+
self.project_memory = self._make_project_memory(cfg, api_key, _PROJECT_MEMORY_JSON)
|
|
798
970
|
self.memory = ContextManager.create(
|
|
799
971
|
config=cfg,
|
|
800
|
-
api_key=
|
|
972
|
+
api_key=api_key,
|
|
801
973
|
db_path=self.projects.project_db_path,
|
|
802
974
|
core_db_path=self.projects.core_db_path,
|
|
975
|
+
extra_context_fn=(
|
|
976
|
+
self.project_memory.to_context_string if self.project_memory else None
|
|
977
|
+
),
|
|
978
|
+
on_compress_fn=(
|
|
979
|
+
self._run_project_judge if self.project_memory else None
|
|
980
|
+
),
|
|
981
|
+
auto_recall=False,
|
|
803
982
|
)
|
|
983
|
+
self.registry.register(self._make_memory_search_tool())
|
|
804
984
|
|
|
805
985
|
def _save_agent_cfg(self):
|
|
806
986
|
"""Persist current _agent_cfg to agent.json."""
|
|
@@ -813,5 +993,32 @@ class Brain:
|
|
|
813
993
|
# Session lifecycle
|
|
814
994
|
# ------------------------------------------------------------------
|
|
815
995
|
|
|
996
|
+
def _run_project_judge(self, text: str):
|
|
997
|
+
if not self.project_memory:
|
|
998
|
+
return
|
|
999
|
+
import logging as _logging
|
|
1000
|
+
logger = _logging.getLogger("chatmem")
|
|
1001
|
+
try:
|
|
1002
|
+
written = self.project_memory.judge_and_update(text)
|
|
1003
|
+
if written:
|
|
1004
|
+
for e in written:
|
|
1005
|
+
logger.info("[project] judge wrote key=%r dim=%s value=%r",
|
|
1006
|
+
e["key"], e.get("dimension"), e["value"][:60])
|
|
1007
|
+
else:
|
|
1008
|
+
logger.info("[project] judge returned empty")
|
|
1009
|
+
except Exception as exc:
|
|
1010
|
+
logger.warning("[project] judge error: %s", exc)
|
|
1011
|
+
|
|
816
1012
|
def end_session(self):
|
|
1013
|
+
# Capture verbatim history before end_session clears it
|
|
1014
|
+
verbatim = [m for m in self.memory._history if not m.get("_is_summary")]
|
|
1015
|
+
conv_text = "\n".join(
|
|
1016
|
+
f"{m['role']}: {m.get('content', '')}" for m in verbatim
|
|
1017
|
+
) if verbatim else ""
|
|
1018
|
+
|
|
817
1019
|
self.memory.end_session()
|
|
1020
|
+
|
|
1021
|
+
if conv_text:
|
|
1022
|
+
threading.Thread(
|
|
1023
|
+
target=self._run_project_judge, args=(conv_text,), daemon=True
|
|
1024
|
+
).start()
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Compression strategy: LLM structured summarization.
|
|
3
|
+
Captures decisions, rationale, and context — not code or config values.
|
|
4
|
+
|
|
5
|
+
Compatible with any OpenAI-compatible API.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from openai import OpenAI
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Unified LLM completion (OpenAI-compatible or Anthropic native)
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
def _llm_complete(
|
|
19
|
+
messages: list[dict],
|
|
20
|
+
max_tokens: int,
|
|
21
|
+
api_key: str,
|
|
22
|
+
model: str,
|
|
23
|
+
base_url: str | None = None,
|
|
24
|
+
extra_body: dict | None = None,
|
|
25
|
+
provider: str = "openai",
|
|
26
|
+
) -> str:
|
|
27
|
+
if provider == "anthropic":
|
|
28
|
+
import anthropic
|
|
29
|
+
system = None
|
|
30
|
+
filtered = []
|
|
31
|
+
for m in messages:
|
|
32
|
+
if m["role"] == "system":
|
|
33
|
+
system = m["content"]
|
|
34
|
+
else:
|
|
35
|
+
filtered.append(m)
|
|
36
|
+
kwargs: dict = dict(model=model, max_tokens=max_tokens, messages=filtered)
|
|
37
|
+
if system:
|
|
38
|
+
kwargs["system"] = system
|
|
39
|
+
client = anthropic.Anthropic(api_key=api_key, base_url=base_url)
|
|
40
|
+
response = client.messages.create(**kwargs)
|
|
41
|
+
raw = "".join(b.text for b in response.content if b.type == "text").strip()
|
|
42
|
+
else:
|
|
43
|
+
client = OpenAI(api_key=api_key, base_url=base_url)
|
|
44
|
+
kwargs = dict(model=model, messages=messages, max_tokens=max_tokens)
|
|
45
|
+
if extra_body:
|
|
46
|
+
kwargs["extra_body"] = extra_body
|
|
47
|
+
response = client.chat.completions.create(**kwargs)
|
|
48
|
+
raw = (response.choices[0].message.content or "").strip()
|
|
49
|
+
return re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Helpers
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
def _extract_json_array(raw: str) -> list:
|
|
57
|
+
"""Extract a JSON array from raw LLM output that may contain <think> reasoning blocks."""
|
|
58
|
+
import json
|
|
59
|
+
clean = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip()
|
|
60
|
+
m = re.search(r"\[.*\]", clean, re.DOTALL)
|
|
61
|
+
if not m:
|
|
62
|
+
return []
|
|
63
|
+
try:
|
|
64
|
+
return json.loads(m.group(0))
|
|
65
|
+
except json.JSONDecodeError:
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _is_chinese(text: str, threshold: float = 0.3) -> bool:
|
|
70
|
+
"""
|
|
71
|
+
Return True if Chinese characters make up >= threshold of non-whitespace chars.
|
|
72
|
+
threshold=0.3 catches mixed Chinese+code conversations while excluding pure English.
|
|
73
|
+
"""
|
|
74
|
+
chinese_chars = len(re.findall(r"[\u4e00-\u9fff]", text))
|
|
75
|
+
total_chars = len(text.replace(" ", "").replace("\n", ""))
|
|
76
|
+
return total_chars > 0 and (chinese_chars / total_chars) >= threshold
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# Structured session context summarization
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
_SESSION_CONTEXT_PROMPT = """You are a conversation summarization assistant. Compress the following conversation into a structured summary for restoring context in subsequent conversations.
|
|
84
|
+
|
|
85
|
+
Rules:
|
|
86
|
+
- Focus on decisions, rationale, context, and outcomes — not implementation details
|
|
87
|
+
- EXCLUDE: code snippets, config file contents, file paths, specific values that can be looked up locally in the project
|
|
88
|
+
- INCLUDE: why decisions were made, what was agreed on, what problems were encountered
|
|
89
|
+
- Use concise bullet points, no long paragraphs
|
|
90
|
+
- Output only the following structure, omit sections with no content:
|
|
91
|
+
[Topics] Main topics and tasks covered
|
|
92
|
+
[Decisions/Conclusions] Confirmed plans, choices, conclusions — include rationale
|
|
93
|
+
[Progress] What was completed, current stage
|
|
94
|
+
[Pending] Unresolved issues, next steps
|
|
95
|
+
[Key Context] Background, constraints, and non-obvious facts not findable in local files
|
|
96
|
+
- Omit small talk, transitional content, and anything already in project files
|
|
97
|
+
- Goal: preserve knowledge that would otherwise be lost between sessions
|
|
98
|
+
|
|
99
|
+
Conversation:
|
|
100
|
+
{text}
|
|
101
|
+
|
|
102
|
+
Structured summary:"""
|
|
103
|
+
|
|
104
|
+
_SESSION_CONTEXT_PROMPT_ZH = """你是一个对话摘要助手。请将以下对话内容压缩为结构化摘要,用于后续对话的上下文恢复。
|
|
105
|
+
|
|
106
|
+
规则:
|
|
107
|
+
- 聚焦于决策、理由、背景和结论——不是实现细节
|
|
108
|
+
- 排除:代码片段、配置文件内容、文件路径、可以在本地项目中查到的具体数值
|
|
109
|
+
- 包含:决策的原因、达成的共识、遇到的问题
|
|
110
|
+
- 用简洁要点,不要长段落
|
|
111
|
+
- 只输出以下结构,没有内容的section直接省略:
|
|
112
|
+
【话题】涉及的主要话题和任务
|
|
113
|
+
【决策/结论】已确定的方案、选择、结论——包含理由
|
|
114
|
+
【进展】完成了什么、进行到哪一步
|
|
115
|
+
【待解决】未解决的问题、下一步计划
|
|
116
|
+
【关键背景】背景信息、约束条件、在本地文件里找不到的非显而易见的事实
|
|
117
|
+
- 忽略闲聊、过渡性内容、以及项目文件里已有的内容
|
|
118
|
+
- 目标:保留跨会话后否则会丢失的知识
|
|
119
|
+
|
|
120
|
+
对话内容:
|
|
121
|
+
{text}
|
|
122
|
+
|
|
123
|
+
结构化摘要:"""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def compress_session_context(
|
|
127
|
+
text: str,
|
|
128
|
+
api_key: str,
|
|
129
|
+
model: str = "gpt-4o-mini",
|
|
130
|
+
base_url: str | None = None,
|
|
131
|
+
extra_body: dict | None = None,
|
|
132
|
+
provider: str = "openai",
|
|
133
|
+
) -> str:
|
|
134
|
+
"""
|
|
135
|
+
Structured summary for in-session compression events and session-end storage.
|
|
136
|
+
Preserves topics, decisions, progress, pending issues, key context.
|
|
137
|
+
Prompt language is auto-selected based on content language.
|
|
138
|
+
"""
|
|
139
|
+
if not text.strip():
|
|
140
|
+
return text
|
|
141
|
+
|
|
142
|
+
prompt = _SESSION_CONTEXT_PROMPT_ZH if _is_chinese(text) else _SESSION_CONTEXT_PROMPT
|
|
143
|
+
return _llm_complete(
|
|
144
|
+
[{"role": "user", "content": prompt.format(text=text)}],
|
|
145
|
+
800, api_key, model, base_url, extra_body, provider,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class Compressor:
|
|
150
|
+
"""
|
|
151
|
+
Compression interface.
|
|
152
|
+
compress_for_session_context(): structured summary (~800 tokens).
|
|
153
|
+
Used for both in-session history compression and cross-session recent_memory storage.
|
|
154
|
+
Prompt language is automatically selected based on content language.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
def __init__(
|
|
158
|
+
self,
|
|
159
|
+
api_key: str,
|
|
160
|
+
model: str = "gpt-4o-mini",
|
|
161
|
+
base_url: str | None = None,
|
|
162
|
+
extra_body: dict | None = None,
|
|
163
|
+
provider: str = "openai",
|
|
164
|
+
):
|
|
165
|
+
self.api_key = api_key
|
|
166
|
+
self.model = model
|
|
167
|
+
self.base_url = base_url
|
|
168
|
+
self.extra_body = extra_body
|
|
169
|
+
self.provider = provider
|
|
170
|
+
|
|
171
|
+
def compress_for_session_context(self, text: str) -> str:
|
|
172
|
+
"""
|
|
173
|
+
Structured compression for in-session history and session-end recent_memory storage.
|
|
174
|
+
Preserves decisions, rationale, and context — not code or config values.
|
|
175
|
+
"""
|
|
176
|
+
return compress_session_context(text, self.api_key, self.model, self.base_url, self.extra_body, self.provider)
|