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.
Files changed (42) hide show
  1. {memocode-0.2.2 → memocode-0.3.0}/PKG-INFO +1 -2
  2. {memocode-0.2.2 → memocode-0.3.0}/README.md +0 -1
  3. {memocode-0.2.2 → memocode-0.3.0}/control/brain.py +247 -40
  4. memocode-0.3.0/control/chatmem/compressor.py +176 -0
  5. {memocode-0.2.2 → memocode-0.3.0}/control/chatmem/context_manager.py +135 -11
  6. {memocode-0.2.2 → memocode-0.3.0}/control/chatmem/memory/consolidation.py +2 -6
  7. {memocode-0.2.2 → memocode-0.3.0}/control/chatmem/memory/core_memory.py +62 -24
  8. {memocode-0.2.2 → memocode-0.3.0}/control/chatmem/memory/recent_memory.py +49 -0
  9. {memocode-0.2.2 → memocode-0.3.0}/memocode.egg-info/PKG-INFO +1 -2
  10. {memocode-0.2.2 → memocode-0.3.0}/pyproject.toml +1 -1
  11. {memocode-0.2.2 → memocode-0.3.0}/run.py +86 -54
  12. {memocode-0.2.2 → memocode-0.3.0}/safety/safety.py +9 -3
  13. memocode-0.2.2/control/chatmem/compressor.py +0 -288
  14. {memocode-0.2.2 → memocode-0.3.0}/control/__init__.py +0 -0
  15. {memocode-0.2.2 → memocode-0.3.0}/control/audit.py +0 -0
  16. {memocode-0.2.2 → memocode-0.3.0}/control/chatmem/__init__.py +0 -0
  17. {memocode-0.2.2 → memocode-0.3.0}/control/chatmem/cli.py +0 -0
  18. {memocode-0.2.2 → memocode-0.3.0}/control/chatmem/config.py +0 -0
  19. {memocode-0.2.2 → memocode-0.3.0}/control/chatmem/mcp_server.py +0 -0
  20. {memocode-0.2.2 → memocode-0.3.0}/control/chatmem/memory/__init__.py +0 -0
  21. {memocode-0.2.2 → memocode-0.3.0}/control/chatmem/memory/forgetting.py +0 -0
  22. {memocode-0.2.2 → memocode-0.3.0}/control/chatmem/server.py +0 -0
  23. {memocode-0.2.2 → memocode-0.3.0}/control/chatmem/token_counter.py +0 -0
  24. {memocode-0.2.2 → memocode-0.3.0}/control/fmt.py +0 -0
  25. {memocode-0.2.2 → memocode-0.3.0}/control/llm.py +0 -0
  26. {memocode-0.2.2 → memocode-0.3.0}/control/planner.py +0 -0
  27. {memocode-0.2.2 → memocode-0.3.0}/control/project_manager.py +0 -0
  28. {memocode-0.2.2 → memocode-0.3.0}/memocode.egg-info/SOURCES.txt +0 -0
  29. {memocode-0.2.2 → memocode-0.3.0}/memocode.egg-info/dependency_links.txt +0 -0
  30. {memocode-0.2.2 → memocode-0.3.0}/memocode.egg-info/entry_points.txt +0 -0
  31. {memocode-0.2.2 → memocode-0.3.0}/memocode.egg-info/requires.txt +0 -0
  32. {memocode-0.2.2 → memocode-0.3.0}/memocode.egg-info/top_level.txt +0 -0
  33. {memocode-0.2.2 → memocode-0.3.0}/safety/__init__.py +0 -0
  34. {memocode-0.2.2 → memocode-0.3.0}/safety/backup.py +0 -0
  35. {memocode-0.2.2 → memocode-0.3.0}/safety/policy.py +0 -0
  36. {memocode-0.2.2 → memocode-0.3.0}/setup.cfg +0 -0
  37. {memocode-0.2.2 → memocode-0.3.0}/tools/__init__.py +0 -0
  38. {memocode-0.2.2 → memocode-0.3.0}/tools/file.py +0 -0
  39. {memocode-0.2.2 → memocode-0.3.0}/tools/loader.py +0 -0
  40. {memocode-0.2.2 → memocode-0.3.0}/tools/registry.py +0 -0
  41. {memocode-0.2.2 → memocode-0.3.0}/tools/shell.py +0 -0
  42. {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.2.2
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
 
@@ -2,7 +2,6 @@
2
2
 
3
3
  Personal AI coding agent CLI with memory, tool execution, and safety controls.
4
4
 
5
- **Author:** AssassinCHN
6
5
 
7
6
  ## Features
8
7
 
@@ -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
- def _resolve_task_paths(tasks: list, work_dir: str) -> list:
97
- """Legacy helper kept for any remaining callers."""
98
- result = []
99
- for t in tasks:
100
- path_args = {
101
- "file_read": ["path"], "file_write": ["path"],
102
- }.get(t.tool, [])
103
- if not path_args:
104
- result.append(t)
105
- continue
106
- args = dict(t.args)
107
- for arg_name in path_args:
108
- p = args.get(arg_name, "")
109
- if (isinstance(p, str) and p
110
- and not os.path.isabs(p)
111
- and not p.startswith("~")
112
- and "task_" not in p): # skip {{task_N_output}} placeholders
113
- args[arg_name] = os.path.join(work_dir, p)
114
- result.append(Task(
115
- id=t.id, tool=t.tool, args=args,
116
- description=t.description, depends_on=t.depends_on,
117
- reversible=t.reversible, backup_paths=t.backup_paths,
118
- ))
119
- return result
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
- "identity": {"label": "Who I am", "stability": 50.0},
126
- "values": {"label": "What I believe", "stability": 40.0},
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 and chatmem.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
- return agent_path, chatmem_path
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
- whitelist = self._agent_cfg.get("auto_whitelist", None)
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.memory.end_session()
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=cfg.llm.resolve_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)