claxcode 0.1.1__py3-none-any.whl
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.
- claxcode/__init__.py +3 -0
- claxcode/api/__init__.py +1 -0
- claxcode/api/chat.py +51 -0
- claxcode/api/session.py +66 -0
- claxcode/api/subagents.py +82 -0
- claxcode/api/tools.py +38 -0
- claxcode/cli.py +1214 -0
- claxcode/cli_completion.py +174 -0
- claxcode/config.py +54 -0
- claxcode/core/__init__.py +1 -0
- claxcode/core/agent.py +327 -0
- claxcode/core/checkpoint.py +100 -0
- claxcode/core/context.py +114 -0
- claxcode/core/models.py +446 -0
- claxcode/extensions/__init__.py +1 -0
- claxcode/extensions/hooks.py +131 -0
- claxcode/extensions/mcp.py +13 -0
- claxcode/extensions/plugins.py +10 -0
- claxcode/extensions/skills.py +205 -0
- claxcode/main.py +55 -0
- claxcode/memory/__init__.py +23 -0
- claxcode/memory/auto_memory.py +68 -0
- claxcode/memory/long_term.py +196 -0
- claxcode/memory/project_context.py +20 -0
- claxcode/memory/project_memory.py +194 -0
- claxcode/memory/session_store.py +206 -0
- claxcode/models/__init__.py +1 -0
- claxcode/models/session.py +25 -0
- claxcode/models/subagent.py +25 -0
- claxcode/models/tool.py +24 -0
- claxcode/security/__init__.py +1 -0
- claxcode/security/audit.py +54 -0
- claxcode/security/permissions.py +29 -0
- claxcode/skills/bug-fix/SKILL.md +33 -0
- claxcode/skills/code-review/SKILL.md +35 -0
- claxcode/skills/python-refactor/SKILL.md +22 -0
- claxcode/skills/task-workflow/SKILL.md +38 -0
- claxcode/subagents/__init__.py +1 -0
- claxcode/subagents/base.py +108 -0
- claxcode/subagents/executor.py +16 -0
- claxcode/subagents/explore.py +25 -0
- claxcode/subagents/planner.py +25 -0
- claxcode/tools/__init__.py +14 -0
- claxcode/tools/admin.py +170 -0
- claxcode/tools/base.py +19 -0
- claxcode/tools/browser.py +499 -0
- claxcode/tools/code_intelligence.py +68 -0
- claxcode/tools/custom_example.py +76 -0
- claxcode/tools/execution.py +114 -0
- claxcode/tools/file_operations.py +287 -0
- claxcode/tools/memory_tool.py +29 -0
- claxcode/tools/registry.py +35 -0
- claxcode/tools/search.py +157 -0
- claxcode/tools/ssh.py +534 -0
- claxcode/tools/web.py +83 -0
- claxcode-0.1.1.dist-info/METADATA +359 -0
- claxcode-0.1.1.dist-info/RECORD +60 -0
- claxcode-0.1.1.dist-info/WHEEL +4 -0
- claxcode-0.1.1.dist-info/entry_points.txt +3 -0
- claxcode-0.1.1.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""MCP (Model Context Protocol) connector (stub for Phase 5)."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MCPClient:
|
|
7
|
+
"""Client for connecting to MCP servers."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, server_url: str):
|
|
10
|
+
self.server_url = server_url
|
|
11
|
+
|
|
12
|
+
async def list_tools(self) -> list[dict[str, Any]]:
|
|
13
|
+
return []
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Skills system — load and inject skill packages into agent context.
|
|
2
|
+
|
|
3
|
+
Inspired by Claude Code's Skills. A skill is a directory containing a SKILL.md
|
|
4
|
+
file that describes capabilities, conventions, and commands. The SKILL.md content
|
|
5
|
+
is injected into the agent's system prompt.
|
|
6
|
+
|
|
7
|
+
Skill directory layout:
|
|
8
|
+
my-skill/
|
|
9
|
+
SKILL.md # Required: name, description, instructions
|
|
10
|
+
tools.py # Optional: skill-specific tools (auto-registered)
|
|
11
|
+
(optional files) # Templates, scripts, or reference docs
|
|
12
|
+
|
|
13
|
+
SKILL.md format:
|
|
14
|
+
# Skill Name
|
|
15
|
+
|
|
16
|
+
## Description
|
|
17
|
+
Brief explanation of what this skill does.
|
|
18
|
+
|
|
19
|
+
## Instructions
|
|
20
|
+
Detailed instructions for the agent on how to use this skill.
|
|
21
|
+
Can include examples, conventions, file patterns, etc.
|
|
22
|
+
|
|
23
|
+
## Commands (optional)
|
|
24
|
+
- `cmd_name`: Description of what this command does
|
|
25
|
+
|
|
26
|
+
Skill sources (later overrides earlier on name conflict):
|
|
27
|
+
builtin — shipped inside the package at claxcode/skills/
|
|
28
|
+
user — ~/.claxcode/skills/ and ~/.agent/skills/
|
|
29
|
+
project — <project>/.claxcode/skills/, <project>/.agent/skills/ and <project>/skills/
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
from claxcode.tools.registry import registry
|
|
36
|
+
|
|
37
|
+
BUILTIN_SKILLS_DIR = Path(__file__).resolve().parent.parent / "skills"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Skill:
|
|
42
|
+
"""A loaded skill package."""
|
|
43
|
+
|
|
44
|
+
name: str
|
|
45
|
+
description: str
|
|
46
|
+
content: str # Full SKILL.md text
|
|
47
|
+
commands: list[dict[str, str]] # Optional custom commands
|
|
48
|
+
path: Path # Directory containing the skill
|
|
49
|
+
source: str = "project" # builtin | user | project
|
|
50
|
+
registered_tools: list[str] = field(default_factory=list)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SkillManager:
|
|
54
|
+
"""Load and manage skill packages."""
|
|
55
|
+
|
|
56
|
+
def __init__(self) -> None:
|
|
57
|
+
self.skills: list[Skill] = []
|
|
58
|
+
self._skill_dirs: list[tuple[Path, str]] = []
|
|
59
|
+
|
|
60
|
+
def load_defaults(self, project_path: Path) -> None:
|
|
61
|
+
"""Load built-in, user-global, then project skills.
|
|
62
|
+
|
|
63
|
+
Later sources override earlier ones when skill names collide.
|
|
64
|
+
"""
|
|
65
|
+
self.load_skill_dir(BUILTIN_SKILLS_DIR, source="builtin")
|
|
66
|
+
self.load_skill_dir(Path.home() / ".claxcode" / "skills", source="user")
|
|
67
|
+
self.load_skill_dir(Path.home() / ".agent" / "skills", source="user")
|
|
68
|
+
self.load_skill_dir(project_path / ".claxcode" / "skills", source="project")
|
|
69
|
+
self.load_skill_dir(project_path / ".agent" / "skills", source="project")
|
|
70
|
+
self.load_skill_dir(project_path / "skills", source="project")
|
|
71
|
+
|
|
72
|
+
def load_skill_dir(self, path: Path, source: str = "project") -> None:
|
|
73
|
+
"""Scan a directory for skill packages and load them."""
|
|
74
|
+
if not path.exists():
|
|
75
|
+
return
|
|
76
|
+
if (path, source) not in self._skill_dirs:
|
|
77
|
+
self._skill_dirs.append((path, source))
|
|
78
|
+
for entry in sorted(path.iterdir()):
|
|
79
|
+
if entry.is_dir() and (entry / "SKILL.md").exists():
|
|
80
|
+
self._load_skill(entry, source)
|
|
81
|
+
|
|
82
|
+
def _load_skill(self, skill_dir: Path, source: str) -> None:
|
|
83
|
+
"""Parse and register a single skill, replacing any same-name skill."""
|
|
84
|
+
skill = self._parse_skill_md(skill_dir, source)
|
|
85
|
+
self._remove_skill(skill.name) # unregister tools of the overridden skill
|
|
86
|
+
self._load_skill_tools(skill_dir, skill)
|
|
87
|
+
self.skills.append(skill)
|
|
88
|
+
|
|
89
|
+
def _parse_skill_md(self, skill_dir: Path, source: str) -> Skill:
|
|
90
|
+
"""Parse SKILL.md into a Skill (no side effects)."""
|
|
91
|
+
raw = (skill_dir / "SKILL.md").read_text(encoding="utf-8")
|
|
92
|
+
|
|
93
|
+
name = skill_dir.name
|
|
94
|
+
description = ""
|
|
95
|
+
commands: list[dict[str, str]] = []
|
|
96
|
+
|
|
97
|
+
# Simple parsing: extract first H1 as name, first paragraph as description
|
|
98
|
+
lines = raw.splitlines()
|
|
99
|
+
for i, line in enumerate(lines):
|
|
100
|
+
if line.startswith("# ") and name == skill_dir.name:
|
|
101
|
+
name = line[2:].strip()
|
|
102
|
+
if line.lower().startswith("## description"):
|
|
103
|
+
# Collect following paragraph
|
|
104
|
+
for j in range(i + 1, len(lines)):
|
|
105
|
+
if lines[j].strip() and not lines[j].startswith("##"):
|
|
106
|
+
description = lines[j].strip()
|
|
107
|
+
break
|
|
108
|
+
if line.lower().startswith("## commands"):
|
|
109
|
+
# Parse command list
|
|
110
|
+
for j in range(i + 1, len(lines)):
|
|
111
|
+
cmd_line = lines[j].strip()
|
|
112
|
+
if cmd_line.startswith("##"):
|
|
113
|
+
break
|
|
114
|
+
if cmd_line.startswith("- "):
|
|
115
|
+
cmd_text = cmd_line[2:].strip()
|
|
116
|
+
if ":" in cmd_text:
|
|
117
|
+
cmd_name, cmd_desc = cmd_text.split(":", 1)
|
|
118
|
+
cmd_label = cmd_name.strip().strip("`")
|
|
119
|
+
commands.append({"name": cmd_label, "description": cmd_desc.strip()})
|
|
120
|
+
|
|
121
|
+
if not description:
|
|
122
|
+
# Fallback: first non-empty, non-header line
|
|
123
|
+
for line in lines:
|
|
124
|
+
stripped = line.strip()
|
|
125
|
+
if stripped and not stripped.startswith("#"):
|
|
126
|
+
description = stripped
|
|
127
|
+
break
|
|
128
|
+
|
|
129
|
+
return Skill(
|
|
130
|
+
name=name,
|
|
131
|
+
description=description,
|
|
132
|
+
content=raw,
|
|
133
|
+
commands=commands,
|
|
134
|
+
path=skill_dir,
|
|
135
|
+
source=source,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def _load_skill_tools(self, skill_dir: Path, skill: Skill) -> None:
|
|
139
|
+
"""Dynamically load tools.py from a skill directory."""
|
|
140
|
+
tools_file = skill_dir / "tools.py"
|
|
141
|
+
if not tools_file.exists():
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
before = set(registry._tools.keys())
|
|
145
|
+
code = tools_file.read_text(encoding="utf-8")
|
|
146
|
+
namespace = {
|
|
147
|
+
"__name__": f"claxcode_skill_{skill.name}",
|
|
148
|
+
"__file__": str(tools_file),
|
|
149
|
+
}
|
|
150
|
+
exec(compile(code, str(tools_file), "exec"), namespace) # noqa: S102
|
|
151
|
+
after = set(registry._tools.keys())
|
|
152
|
+
skill.registered_tools = sorted(after - before)
|
|
153
|
+
|
|
154
|
+
def _remove_skill(self, name: str) -> bool:
|
|
155
|
+
"""Remove a skill by name and unregister its tools."""
|
|
156
|
+
for i, skill in enumerate(self.skills):
|
|
157
|
+
if skill.name == name:
|
|
158
|
+
for tool_name in skill.registered_tools:
|
|
159
|
+
registry.unregister(tool_name)
|
|
160
|
+
self.skills.pop(i)
|
|
161
|
+
return True
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
def unload_skill(self, name: str) -> bool:
|
|
165
|
+
"""Unload a skill and unregister its tools."""
|
|
166
|
+
return self._remove_skill(name)
|
|
167
|
+
|
|
168
|
+
def reload_all(self) -> tuple[int, int]:
|
|
169
|
+
"""Unload all skills and reload from all known directories.
|
|
170
|
+
|
|
171
|
+
Returns (skills_loaded, tools_registered).
|
|
172
|
+
"""
|
|
173
|
+
for skill in self.skills:
|
|
174
|
+
for tool_name in skill.registered_tools:
|
|
175
|
+
registry.unregister(tool_name)
|
|
176
|
+
self.skills.clear()
|
|
177
|
+
|
|
178
|
+
for path, source in self._skill_dirs:
|
|
179
|
+
self.load_skill_dir(path, source)
|
|
180
|
+
return len(self.skills), sum(len(s.registered_tools) for s in self.skills)
|
|
181
|
+
|
|
182
|
+
def to_prompt_fragment(self) -> str:
|
|
183
|
+
"""Convert all loaded skills into a system-prompt fragment."""
|
|
184
|
+
if not self.skills:
|
|
185
|
+
return ""
|
|
186
|
+
lines = ["# Skills"]
|
|
187
|
+
for skill in self.skills:
|
|
188
|
+
lines.append(f"\n## {skill.name}")
|
|
189
|
+
lines.append(skill.content)
|
|
190
|
+
return "\n".join(lines)
|
|
191
|
+
|
|
192
|
+
def get_skill_commands(self) -> list[dict[str, str]]:
|
|
193
|
+
"""Return all custom commands defined across skills (with source skill)."""
|
|
194
|
+
cmds = []
|
|
195
|
+
for skill in self.skills:
|
|
196
|
+
for cmd in skill.commands:
|
|
197
|
+
cmds.append({**cmd, "skill": skill.name})
|
|
198
|
+
return cmds
|
|
199
|
+
|
|
200
|
+
def find_command(self, name: str) -> dict[str, str] | None:
|
|
201
|
+
"""Find a skill command by name (without the leading slash)."""
|
|
202
|
+
for cmd in self.get_skill_commands():
|
|
203
|
+
if cmd["name"] == name:
|
|
204
|
+
return cmd
|
|
205
|
+
return None
|
claxcode/main.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""FastAPI application entry point."""
|
|
2
|
+
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
from fastapi.responses import PlainTextResponse
|
|
7
|
+
|
|
8
|
+
# Ensure all built-in tools are registered before API starts
|
|
9
|
+
import claxcode.tools # noqa: F401
|
|
10
|
+
from claxcode.api import chat, session, subagents, tools
|
|
11
|
+
from claxcode.config import settings
|
|
12
|
+
from claxcode.memory.session_store import init_db
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@asynccontextmanager
|
|
16
|
+
async def lifespan(app: FastAPI):
|
|
17
|
+
"""Application lifespan events."""
|
|
18
|
+
# Startup
|
|
19
|
+
await init_db()
|
|
20
|
+
yield
|
|
21
|
+
# Shutdown
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
app = FastAPI(
|
|
25
|
+
title=settings.app_name,
|
|
26
|
+
description="Agentic coding assistant API",
|
|
27
|
+
version="0.1.0",
|
|
28
|
+
lifespan=lifespan,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
app.include_router(session.router, prefix="/sessions", tags=["sessions"])
|
|
32
|
+
app.include_router(chat.router, prefix="/sessions", tags=["chat"])
|
|
33
|
+
app.include_router(tools.router, prefix="/tools", tags=["tools"])
|
|
34
|
+
app.include_router(subagents.router, prefix="/subagents", tags=["subagents"])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.get("/health", response_class=PlainTextResponse)
|
|
38
|
+
async def health():
|
|
39
|
+
return "ok"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def run() -> None:
|
|
43
|
+
"""CLI entry point to start the server."""
|
|
44
|
+
import uvicorn
|
|
45
|
+
|
|
46
|
+
uvicorn.run(
|
|
47
|
+
"claxcode.main:app",
|
|
48
|
+
host=settings.host,
|
|
49
|
+
port=settings.port,
|
|
50
|
+
reload=settings.debug,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
run()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Memory system for ClaxCode."""
|
|
2
|
+
|
|
3
|
+
from claxcode.memory.long_term import LongTermMemory, long_term_memory
|
|
4
|
+
from claxcode.memory.project_memory import ProjectMemory, project_memory
|
|
5
|
+
from claxcode.memory.session_store import (
|
|
6
|
+
LongTermMemoryORM,
|
|
7
|
+
ProjectMemoryORM,
|
|
8
|
+
SessionORM,
|
|
9
|
+
get_db_session,
|
|
10
|
+
init_db,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"long_term_memory",
|
|
15
|
+
"LongTermMemory",
|
|
16
|
+
"project_memory",
|
|
17
|
+
"ProjectMemory",
|
|
18
|
+
"init_db",
|
|
19
|
+
"get_db_session",
|
|
20
|
+
"SessionORM",
|
|
21
|
+
"LongTermMemoryORM",
|
|
22
|
+
"ProjectMemoryORM",
|
|
23
|
+
]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Auto-memory for learned facts and preferences (durable JSON store)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
DEFAULT_STORE_PATH = Path.home() / ".claxcode" / "auto_memory.json"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AutoMemory:
|
|
10
|
+
"""Stores stable global user preferences and facts in a JSON file."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, store_path: Path | None = None, limit: int = 50):
|
|
13
|
+
self._store_path = store_path or DEFAULT_STORE_PATH
|
|
14
|
+
self._limit = limit
|
|
15
|
+
self._memories: dict[str, str] = self._load()
|
|
16
|
+
|
|
17
|
+
def remember(self, key: str, value: str) -> str:
|
|
18
|
+
"""Record or update a fact. Returns a human-readable status."""
|
|
19
|
+
if key not in self._memories and len(self._memories) >= self._limit:
|
|
20
|
+
return f"记忆已满({self._limit} 条上限),请先清理其他条目"
|
|
21
|
+
self._memories[key] = value
|
|
22
|
+
self._save()
|
|
23
|
+
return f"已记住: {key}"
|
|
24
|
+
|
|
25
|
+
def recall(self, key: str) -> str | None:
|
|
26
|
+
return self._memories.get(key)
|
|
27
|
+
|
|
28
|
+
def forget(self, key: str) -> bool:
|
|
29
|
+
"""Delete a fact. Returns True if it existed."""
|
|
30
|
+
if key not in self._memories:
|
|
31
|
+
return False
|
|
32
|
+
del self._memories[key]
|
|
33
|
+
self._save()
|
|
34
|
+
return True
|
|
35
|
+
|
|
36
|
+
def items(self) -> dict[str, str]:
|
|
37
|
+
return dict(self._memories)
|
|
38
|
+
|
|
39
|
+
def to_prompt_fragment(self) -> str:
|
|
40
|
+
if not self._memories:
|
|
41
|
+
return ""
|
|
42
|
+
lines = ["# 全局用户偏好与事实(自动记忆)"]
|
|
43
|
+
for k, v in self._memories.items():
|
|
44
|
+
lines.append(f"- {k}: {v}")
|
|
45
|
+
return "\n".join(lines)
|
|
46
|
+
|
|
47
|
+
def _load(self) -> dict[str, str]:
|
|
48
|
+
if not self._store_path.exists():
|
|
49
|
+
return {}
|
|
50
|
+
try:
|
|
51
|
+
data = json.loads(self._store_path.read_text(encoding="utf-8"))
|
|
52
|
+
except (json.JSONDecodeError, OSError):
|
|
53
|
+
return {}
|
|
54
|
+
return {str(k): str(v) for k, v in data.items()} if isinstance(data, dict) else {}
|
|
55
|
+
|
|
56
|
+
def _save(self) -> None:
|
|
57
|
+
try:
|
|
58
|
+
self._store_path.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
self._store_path.write_text(
|
|
60
|
+
json.dumps(self._memories, ensure_ascii=False, indent=2),
|
|
61
|
+
encoding="utf-8",
|
|
62
|
+
)
|
|
63
|
+
except OSError:
|
|
64
|
+
pass # 记忆写入失败不应阻塞主流程
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# Global singleton shared by the agent prompt, the remember_fact tool and the CLI.
|
|
68
|
+
auto_memory = AutoMemory()
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Searchable long-term memories with project and global scopes."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
from sqlalchemy import desc, or_, select
|
|
11
|
+
|
|
12
|
+
from claxcode.memory.session_store import LongTermMemoryORM, get_db_session
|
|
13
|
+
|
|
14
|
+
_STOP_TERMS = {
|
|
15
|
+
"我们", "你们", "他们", "这个", "那个", "可以", "怎么", "什么", "一下",
|
|
16
|
+
"帮我", "谢谢", "现在", "还是", "就是", "因为", "所以", "如果", "已经", "继续",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _normalize_project_path(project_path: str) -> str:
|
|
21
|
+
return str(Path(project_path).resolve()) if project_path else ""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _search_terms(keywords: list[str] | None) -> list[str]:
|
|
25
|
+
"""Build searchable words and Chinese bigrams without external tokenizers."""
|
|
26
|
+
if not keywords:
|
|
27
|
+
return []
|
|
28
|
+
terms: set[str] = set()
|
|
29
|
+
for token in re.findall(r"[a-z0-9_]+|[\u4e00-\u9fff]+", " ".join(keywords).lower()):
|
|
30
|
+
if re.fullmatch(r"[a-z0-9_]+", token):
|
|
31
|
+
if len(token) >= 2:
|
|
32
|
+
terms.add(token)
|
|
33
|
+
continue
|
|
34
|
+
if len(token) <= 6 and token not in _STOP_TERMS:
|
|
35
|
+
terms.add(token)
|
|
36
|
+
terms.update(
|
|
37
|
+
token[i:i + 2]
|
|
38
|
+
for i in range(len(token) - 1)
|
|
39
|
+
if token[i:i + 2] not in _STOP_TERMS
|
|
40
|
+
)
|
|
41
|
+
return sorted(terms, key=lambda term: (-len(term), term))[:60]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _relevance(memory: LongTermMemoryORM, terms: list[str]) -> int:
|
|
45
|
+
text = f"{memory.keywords}\n{memory.compressed_content}".lower()
|
|
46
|
+
return sum(len(term) if len(term) > 2 else 1 for term in terms if term in text)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _to_dict(memory: LongTermMemoryORM, truncate: bool = False) -> dict[str, Any]:
|
|
50
|
+
content = memory.compressed_content
|
|
51
|
+
if truncate and len(content) > 200:
|
|
52
|
+
content = content[:200] + "..."
|
|
53
|
+
return {
|
|
54
|
+
"id": memory.id,
|
|
55
|
+
"session_id": memory.session_id,
|
|
56
|
+
"timestamp": memory.timestamp.isoformat(),
|
|
57
|
+
"keywords": json.loads(memory.keywords) if memory.keywords else [],
|
|
58
|
+
"compressed_content": content,
|
|
59
|
+
"project_path": memory.project_path,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class LongTermMemory:
|
|
64
|
+
"""Manage searchable project memories and cross-project global memories."""
|
|
65
|
+
|
|
66
|
+
async def save(
|
|
67
|
+
self,
|
|
68
|
+
session_id: str,
|
|
69
|
+
compressed_content: str,
|
|
70
|
+
keywords: list[str],
|
|
71
|
+
project_path: str = "",
|
|
72
|
+
) -> str:
|
|
73
|
+
"""Save a memory; an empty project_path means global scope."""
|
|
74
|
+
content = compressed_content.strip()
|
|
75
|
+
if not content:
|
|
76
|
+
raise ValueError("记忆内容不能为空")
|
|
77
|
+
memory_id = str(uuid4())
|
|
78
|
+
normalized_keywords = list(dict.fromkeys(k.strip() for k in keywords if k.strip()))
|
|
79
|
+
async for db in get_db_session():
|
|
80
|
+
memory = LongTermMemoryORM(
|
|
81
|
+
id=memory_id,
|
|
82
|
+
session_id=session_id,
|
|
83
|
+
timestamp=datetime.now(UTC),
|
|
84
|
+
keywords=json.dumps(normalized_keywords, ensure_ascii=False),
|
|
85
|
+
compressed_content=content,
|
|
86
|
+
project_path=_normalize_project_path(project_path),
|
|
87
|
+
)
|
|
88
|
+
db.add(memory)
|
|
89
|
+
await db.commit()
|
|
90
|
+
return memory_id
|
|
91
|
+
return memory_id
|
|
92
|
+
|
|
93
|
+
async def search(
|
|
94
|
+
self,
|
|
95
|
+
keywords: list[str] | None = None,
|
|
96
|
+
project_path: str | None = None,
|
|
97
|
+
limit: int = 10,
|
|
98
|
+
include_global: bool = False,
|
|
99
|
+
) -> list[dict[str, Any]]:
|
|
100
|
+
"""Search by relevance within a project, optionally including global memories."""
|
|
101
|
+
terms = _search_terms(keywords)
|
|
102
|
+
normalized_path = _normalize_project_path(project_path or "")
|
|
103
|
+
async for db in get_db_session():
|
|
104
|
+
query = select(LongTermMemoryORM).order_by(desc(LongTermMemoryORM.timestamp))
|
|
105
|
+
if normalized_path:
|
|
106
|
+
if include_global:
|
|
107
|
+
query = query.where(
|
|
108
|
+
or_(
|
|
109
|
+
LongTermMemoryORM.project_path == normalized_path,
|
|
110
|
+
LongTermMemoryORM.project_path == "",
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
else:
|
|
114
|
+
query = query.where(LongTermMemoryORM.project_path == normalized_path)
|
|
115
|
+
|
|
116
|
+
result = await db.execute(query.limit(200 if terms else limit))
|
|
117
|
+
memories = list(result.scalars().all())
|
|
118
|
+
if terms:
|
|
119
|
+
memories = [m for m in memories if _relevance(m, terms) > 0]
|
|
120
|
+
memories.sort(key=lambda m: _relevance(m, terms), reverse=True)
|
|
121
|
+
return [_to_dict(m) for m in memories[:limit]]
|
|
122
|
+
return []
|
|
123
|
+
|
|
124
|
+
async def list_all(
|
|
125
|
+
self,
|
|
126
|
+
limit: int = 50,
|
|
127
|
+
project_path: str | None = None,
|
|
128
|
+
include_global: bool = False,
|
|
129
|
+
) -> list[dict[str, Any]]:
|
|
130
|
+
"""List recent memories, optionally restricted to project and global scopes."""
|
|
131
|
+
normalized_path = _normalize_project_path(project_path or "")
|
|
132
|
+
async for db in get_db_session():
|
|
133
|
+
query = select(LongTermMemoryORM).order_by(desc(LongTermMemoryORM.timestamp))
|
|
134
|
+
if normalized_path:
|
|
135
|
+
if include_global:
|
|
136
|
+
query = query.where(
|
|
137
|
+
or_(
|
|
138
|
+
LongTermMemoryORM.project_path == normalized_path,
|
|
139
|
+
LongTermMemoryORM.project_path == "",
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
else:
|
|
143
|
+
query = query.where(LongTermMemoryORM.project_path == normalized_path)
|
|
144
|
+
result = await db.execute(query.limit(limit))
|
|
145
|
+
return [_to_dict(m, truncate=True) for m in result.scalars().all()]
|
|
146
|
+
return []
|
|
147
|
+
|
|
148
|
+
async def delete(self, memory_id: str) -> bool:
|
|
149
|
+
"""Delete a memory by ID."""
|
|
150
|
+
async for db in get_db_session():
|
|
151
|
+
result = await db.execute(
|
|
152
|
+
select(LongTermMemoryORM).where(LongTermMemoryORM.id == memory_id)
|
|
153
|
+
)
|
|
154
|
+
memory = result.scalar_one_or_none()
|
|
155
|
+
if memory:
|
|
156
|
+
await db.delete(memory)
|
|
157
|
+
await db.commit()
|
|
158
|
+
return True
|
|
159
|
+
return False
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
async def export_to_file(self, filepath: str, project_path: str | None = None) -> int:
|
|
163
|
+
"""Export memories to JSON."""
|
|
164
|
+
memories = await self.search(project_path=project_path, limit=1000)
|
|
165
|
+
export_data = {
|
|
166
|
+
"export_time": datetime.now(UTC).isoformat(),
|
|
167
|
+
"memories": memories,
|
|
168
|
+
}
|
|
169
|
+
Path(filepath).write_text(
|
|
170
|
+
json.dumps(export_data, ensure_ascii=False, indent=2),
|
|
171
|
+
encoding="utf-8",
|
|
172
|
+
)
|
|
173
|
+
return len(memories)
|
|
174
|
+
|
|
175
|
+
async def import_from_file(self, filepath: str) -> int:
|
|
176
|
+
"""Import memories from JSON without reusing their original IDs."""
|
|
177
|
+
data = json.loads(Path(filepath).read_text(encoding="utf-8"))
|
|
178
|
+
memories = data.get("memories", [])
|
|
179
|
+
count = 0
|
|
180
|
+
async for db in get_db_session():
|
|
181
|
+
for item in memories:
|
|
182
|
+
memory = LongTermMemoryORM(
|
|
183
|
+
id=str(uuid4()),
|
|
184
|
+
session_id=item.get("session_id", ""),
|
|
185
|
+
timestamp=datetime.fromisoformat(item["timestamp"]),
|
|
186
|
+
keywords=json.dumps(item.get("keywords", []), ensure_ascii=False),
|
|
187
|
+
compressed_content=item.get("compressed_content", ""),
|
|
188
|
+
project_path=_normalize_project_path(item.get("project_path", "")),
|
|
189
|
+
)
|
|
190
|
+
db.add(memory)
|
|
191
|
+
count += 1
|
|
192
|
+
await db.commit()
|
|
193
|
+
return count
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
long_term_memory = LongTermMemory()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Project-level context loading."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from claxcode.config import settings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def load_project_context(project_path: Path) -> str:
|
|
9
|
+
"""Load CLAUDE.md or .claxcode/context.md if present."""
|
|
10
|
+
candidates = [
|
|
11
|
+
project_path / settings.project_context_file,
|
|
12
|
+
project_path / ".claxcode" / "context.md",
|
|
13
|
+
]
|
|
14
|
+
for candidate in candidates:
|
|
15
|
+
if candidate.exists():
|
|
16
|
+
try:
|
|
17
|
+
return candidate.read_text(encoding="utf-8")
|
|
18
|
+
except Exception:
|
|
19
|
+
continue
|
|
20
|
+
return ""
|