crabcode 0.1.6__py3-none-win_amd64.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.
- crabcode-0.1.6.dist-info/METADATA +63 -0
- crabcode-0.1.6.dist-info/RECORD +205 -0
- crabcode-0.1.6.dist-info/WHEEL +4 -0
- crabcode-0.1.6.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +7 -0
- crabcode_cli/__main__.py +4 -0
- crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
- crabcode_cli/_native/runtime.json +6 -0
- crabcode_cli/app.py +776 -0
- crabcode_cli/banner.py +39 -0
- crabcode_cli/computer_use.py +286 -0
- crabcode_cli/model_manager.py +649 -0
- crabcode_cli/model_picker.py +168 -0
- crabcode_cli/pipe.py +148 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +4841 -0
- crabcode_cli/session_picker.py +239 -0
- crabcode_core/__init__.py +7 -0
- crabcode_core/_version.py +3 -0
- crabcode_core/_windows_process.py +68 -0
- crabcode_core/agent_manager.py +1731 -0
- crabcode_core/api/__init__.py +20 -0
- crabcode_core/api/anthropic_adapter.py +648 -0
- crabcode_core/api/azure_adapter.py +69 -0
- crabcode_core/api/base.py +233 -0
- crabcode_core/api/codex_adapter.py +1327 -0
- crabcode_core/api/error_logging.py +132 -0
- crabcode_core/api/gemini_adapter.py +26 -0
- crabcode_core/api/model_info.py +257 -0
- crabcode_core/api/network.py +62 -0
- crabcode_core/api/ollama_adapter.py +27 -0
- crabcode_core/api/openai_adapter.py +323 -0
- crabcode_core/api/registry.py +58 -0
- crabcode_core/command_line.py +18 -0
- crabcode_core/compact/__init__.py +17 -0
- crabcode_core/compact/compact.py +514 -0
- crabcode_core/compact/context_tokens.py +165 -0
- crabcode_core/computer_use_mode.py +38 -0
- crabcode_core/computer_use_observation.py +144 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +61 -0
- crabcode_core/config/manager.py +237 -0
- crabcode_core/config/model_catalog.py +256 -0
- crabcode_core/events.py +4896 -0
- crabcode_core/file_lock.py +81 -0
- crabcode_core/filesystem.py +22 -0
- crabcode_core/goal.py +149 -0
- crabcode_core/hooks/__init__.py +5 -0
- crabcode_core/hooks/manager.py +290 -0
- crabcode_core/image_io.py +111 -0
- crabcode_core/io_worker.py +211 -0
- crabcode_core/logging_utils.py +168 -0
- crabcode_core/lsp/__init__.py +6 -0
- crabcode_core/lsp/client.py +786 -0
- crabcode_core/lsp/diagnostics.py +150 -0
- crabcode_core/lsp/manager.py +583 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +169 -0
- crabcode_core/mcp/config.py +57 -0
- crabcode_core/path_validation.py +34 -0
- crabcode_core/paths.py +24 -0
- crabcode_core/peer/__init__.py +10 -0
- crabcode_core/peer/runtime.py +518 -0
- crabcode_core/permissions/__init__.py +7 -0
- crabcode_core/permissions/ai_reviewer.py +184 -0
- crabcode_core/permissions/manager.py +187 -0
- crabcode_core/plan/__init__.py +5 -0
- crabcode_core/plan/executor.py +270 -0
- crabcode_core/plan/types.py +169 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/blocks.py +10 -0
- crabcode_core/prompts/context.py +229 -0
- crabcode_core/prompts/library.py +92 -0
- crabcode_core/prompts/profile.py +79 -0
- crabcode_core/prompts/system.py +356 -0
- crabcode_core/prompts/templates.py +104 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/computer_use_history.py +83 -0
- crabcode_core/query/loop.py +2446 -0
- crabcode_core/query/retry.py +116 -0
- crabcode_core/schedule/__init__.py +13 -0
- crabcode_core/schedule/manager.py +631 -0
- crabcode_core/schedule/models.py +204 -0
- crabcode_core/schedule/store.py +558 -0
- crabcode_core/schedule/timing.py +287 -0
- crabcode_core/session/__init__.py +12 -0
- crabcode_core/session/export.py +141 -0
- crabcode_core/session/meta_db.py +684 -0
- crabcode_core/session/storage.py +2032 -0
- crabcode_core/session/title_gen.py +52 -0
- crabcode_core/skills/__init__.py +6 -0
- crabcode_core/skills/context.py +23 -0
- crabcode_core/skills/loader.py +214 -0
- crabcode_core/skills/matcher.py +203 -0
- crabcode_core/snapshot/__init__.py +21 -0
- crabcode_core/snapshot/snapshot.py +436 -0
- crabcode_core/snapshot/tracker.py +359 -0
- crabcode_core/subprocess_utils.py +260 -0
- crabcode_core/team/__init__.py +27 -0
- crabcode_core/team/bridge.py +66 -0
- crabcode_core/team/inbox.py +237 -0
- crabcode_core/team/manager.py +1660 -0
- crabcode_core/team/message_bus.py +821 -0
- crabcode_core/team/models.py +191 -0
- crabcode_core/team/recovery.py +192 -0
- crabcode_core/text_io.py +52 -0
- crabcode_core/tools/__init__.py +112 -0
- crabcode_core/tools/_input_helpers.py +39 -0
- crabcode_core/tools/_input_schema.py +130 -0
- crabcode_core/tools/agent.py +413 -0
- crabcode_core/tools/apply_patch.py +587 -0
- crabcode_core/tools/ask_user.py +122 -0
- crabcode_core/tools/bash.py +154 -0
- crabcode_core/tools/browser.py +678 -0
- crabcode_core/tools/checklist.py +241 -0
- crabcode_core/tools/checkpoint.py +78 -0
- crabcode_core/tools/computer_use.py +599 -0
- crabcode_core/tools/diff_utils.py +123 -0
- crabcode_core/tools/file_edit.py +240 -0
- crabcode_core/tools/file_read.py +187 -0
- crabcode_core/tools/file_write.py +154 -0
- crabcode_core/tools/glob.py +123 -0
- crabcode_core/tools/goal.py +134 -0
- crabcode_core/tools/grep.py +247 -0
- crabcode_core/tools/image.py +173 -0
- crabcode_core/tools/image_generate.py +115 -0
- crabcode_core/tools/lint.py +388 -0
- crabcode_core/tools/loading.py +187 -0
- crabcode_core/tools/memory.py +456 -0
- crabcode_core/tools/monitor.py +677 -0
- crabcode_core/tools/peer.py +103 -0
- crabcode_core/tools/revert.py +90 -0
- crabcode_core/tools/schedule.py +683 -0
- crabcode_core/tools/skill.py +108 -0
- crabcode_core/tools/switch_mode.py +213 -0
- crabcode_core/tools/team.py +329 -0
- crabcode_core/tools/tool_search.py +64 -0
- crabcode_core/tools/web_search.py +444 -0
- crabcode_core/turn_details.py +64 -0
- crabcode_core/types/__init__.py +95 -0
- crabcode_core/types/config.py +434 -0
- crabcode_core/types/event.py +334 -0
- crabcode_core/types/message.py +263 -0
- crabcode_core/types/tool.py +208 -0
- crabcode_core/usage.py +289 -0
- crabcode_core/utf8_sanitize.py +29 -0
- crabcode_debugger/__init__.py +5 -0
- crabcode_debugger/adapters.py +332 -0
- crabcode_debugger/dap.py +304 -0
- crabcode_debugger/memory.py +1806 -0
- crabcode_debugger/process.py +707 -0
- crabcode_debugger/runtime.py +58 -0
- crabcode_debugger/sessions.py +387 -0
- crabcode_debugger/tool.py +731 -0
- crabcode_gateway/__init__.py +18 -0
- crabcode_gateway/acp/__init__.py +13 -0
- crabcode_gateway/acp/agent.py +1581 -0
- crabcode_gateway/acp/session.py +114 -0
- crabcode_gateway/acp/transport.py +39 -0
- crabcode_gateway/acp/types.py +90 -0
- crabcode_gateway/adapter.py +30 -0
- crabcode_gateway/auth.py +139 -0
- crabcode_gateway/computer_use.py +364 -0
- crabcode_gateway/document_engine.py +637 -0
- crabcode_gateway/document_worker.py +301 -0
- crabcode_gateway/event_bus.py +655 -0
- crabcode_gateway/grpc/__init__.py +0 -0
- crabcode_gateway/grpc/crabcode_pb2.py +70 -0
- crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
- crabcode_gateway/grpc/proto/crabcode.proto +160 -0
- crabcode_gateway/grpc/server.py +765 -0
- crabcode_gateway/middleware.py +344 -0
- crabcode_gateway/protocol.py +17 -0
- crabcode_gateway/routes/__init__.py +0 -0
- crabcode_gateway/routes/agent.py +392 -0
- crabcode_gateway/routes/auth.py +150 -0
- crabcode_gateway/routes/computer_use.py +101 -0
- crabcode_gateway/routes/config.py +1698 -0
- crabcode_gateway/routes/document.py +1810 -0
- crabcode_gateway/routes/event.py +3801 -0
- crabcode_gateway/routes/git_review.py +232 -0
- crabcode_gateway/routes/health.py +14 -0
- crabcode_gateway/routes/peer.py +78 -0
- crabcode_gateway/routes/permission.py +121 -0
- crabcode_gateway/routes/schedule.py +300 -0
- crabcode_gateway/routes/session.py +1496 -0
- crabcode_gateway/routes/snapshot.py +211 -0
- crabcode_gateway/routes/tasks.py +318 -0
- crabcode_gateway/routes/team.py +392 -0
- crabcode_gateway/routes/usage.py +49 -0
- crabcode_gateway/routes/workspace.py +299 -0
- crabcode_gateway/safe_file.py +63 -0
- crabcode_gateway/schemas.py +1820 -0
- crabcode_gateway/server.py +514 -0
- crabcode_gateway/session_registry.py +36 -0
- crabcode_gateway/task_registry.py +708 -0
- crabcode_gateway/turn_details.py +60 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +368 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +497 -0
- crabcode_search/indexer.py +215 -0
- crabcode_search/store.py +222 -0
- crabcode_search/tool.py +268 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Provider-independent tool discovery. State belongs to a conversation, not a Tool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Callable
|
|
9
|
+
|
|
10
|
+
from crabcode_core.types.config import ToolLoadingSettings
|
|
11
|
+
from crabcode_core.types.tool import Tool, ToolContext
|
|
12
|
+
|
|
13
|
+
CORE_TOOLS = (
|
|
14
|
+
"Bash", "Read", "apply_patch", "Edit", "Write", "Grep", "Glob", "Lint",
|
|
15
|
+
"AskUser", "Checklist", "SwitchMode", "Skill", "ToolSearch",
|
|
16
|
+
)
|
|
17
|
+
GROUPS = {
|
|
18
|
+
"web": ("WebSearch", "Browser"),
|
|
19
|
+
"images": ("Image", "ImageGenerate"),
|
|
20
|
+
"computer": ("ComputerUse",),
|
|
21
|
+
"agents": ("Agent", "AgentStatus", "AgentWait", "AgentCancel", "AgentSendInput"),
|
|
22
|
+
"sessions": ("ListAgents", "SendMessage"),
|
|
23
|
+
"teams": ("TeamCreate", "TeamSpawn", "TeamMessage", "TeamBroadcast", "TeamStatus",
|
|
24
|
+
"TeamTaskAdd", "TeamTaskClaim", "TeamTaskComplete", "TeamShutdown"),
|
|
25
|
+
"background": ("Monitor", "TaskList", "TaskStop"),
|
|
26
|
+
"schedule": ("ScheduleCreate", "ScheduleList", "ScheduleCancel", "ScheduleStatus",
|
|
27
|
+
"SchedulePause", "ScheduleResume", "ScheduleRun"),
|
|
28
|
+
"memory": ("Memory",),
|
|
29
|
+
"checkpoints": ("Checkpoint", "Revert"),
|
|
30
|
+
"goals": ("create_goal", "get_goal", "update_goal"),
|
|
31
|
+
}
|
|
32
|
+
ALIASES = {
|
|
33
|
+
"web": "search browse internet 网页 浏览 搜索 联网",
|
|
34
|
+
"images": "image picture generate 图片 生成 看图",
|
|
35
|
+
"computer": "computer desktop gui screen mouse keyboard 电脑 桌面 图形界面 鼠标 键盘 操作",
|
|
36
|
+
"agents": "delegate subagent 子代理 委派",
|
|
37
|
+
"sessions": "peer message 会话 消息",
|
|
38
|
+
"teams": "team coordinate 团队 协作",
|
|
39
|
+
"background": "monitor task stop 监控 后台 停止",
|
|
40
|
+
"schedule": "cron scheduled 定时 调度",
|
|
41
|
+
"memory": "remember memories 记忆 记住",
|
|
42
|
+
"checkpoints": "checkpoint revert undo 快照 回滚 撤销",
|
|
43
|
+
"goals": "goal objective 目标",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ToolLoadingState:
|
|
49
|
+
# Append-only order preserves existing schema ordering as tools are discovered.
|
|
50
|
+
names: list[str] = field(default_factory=list)
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def restore(cls, value: object) -> ToolLoadingState:
|
|
54
|
+
if not isinstance(value, list):
|
|
55
|
+
return cls()
|
|
56
|
+
return cls(list(dict.fromkeys(n for n in value if isinstance(n, str))))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ToolCatalog:
|
|
60
|
+
def __init__(
|
|
61
|
+
self, tools: list[Tool], context: ToolContext, settings: ToolLoadingSettings,
|
|
62
|
+
state: ToolLoadingState, *, plan_mode: bool = False,
|
|
63
|
+
on_change: Callable[[list[str]], None] | None = None,
|
|
64
|
+
pinned: tuple[str, ...] = (),
|
|
65
|
+
) -> None:
|
|
66
|
+
# Restrict to this agent's registry and the current model/mode. Discovery
|
|
67
|
+
# never grants permissions or reaches into the parent agent's registry.
|
|
68
|
+
self.tools: dict[str, Tool] = {}
|
|
69
|
+
for tool in tools:
|
|
70
|
+
if tool.is_available(context) and (not plan_mode or tool.is_read_only):
|
|
71
|
+
self.tools.setdefault(tool.name, tool)
|
|
72
|
+
self.settings = settings
|
|
73
|
+
self.mode = settings.mode if "ToolSearch" in self.tools else "eager"
|
|
74
|
+
self.state = state
|
|
75
|
+
self.on_change = on_change
|
|
76
|
+
initial = tuple(self.tools) if self.mode == "eager" else (
|
|
77
|
+
*CORE_TOOLS, *settings.pinned_tools, *pinned,
|
|
78
|
+
)
|
|
79
|
+
self.load(initial)
|
|
80
|
+
self.exposed_names = frozenset(tool.name for tool in self.loaded)
|
|
81
|
+
|
|
82
|
+
def load(self, names) -> list[str]:
|
|
83
|
+
selected = [n for n in dict.fromkeys(names) if n in self.tools]
|
|
84
|
+
added = [n for n in selected if n not in self.state.names]
|
|
85
|
+
if added:
|
|
86
|
+
self.state.names.extend(added)
|
|
87
|
+
if self.on_change:
|
|
88
|
+
self.on_change(list(self.state.names))
|
|
89
|
+
return selected
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def loaded(self) -> list[Tool]:
|
|
93
|
+
return [self.tools[n] for n in self.state.names if n in self.tools]
|
|
94
|
+
|
|
95
|
+
def group(self, name: str) -> str:
|
|
96
|
+
if name in CORE_TOOLS:
|
|
97
|
+
return "core"
|
|
98
|
+
for group, names in GROUPS.items():
|
|
99
|
+
if name in names:
|
|
100
|
+
return group
|
|
101
|
+
server = getattr(self.tools[name], "_server_name", None)
|
|
102
|
+
return f"mcp:{server}" if server else "extensions"
|
|
103
|
+
|
|
104
|
+
def _resolve_name(self, name: str) -> str | None:
|
|
105
|
+
if name in self.tools:
|
|
106
|
+
return name
|
|
107
|
+
# Accept an accidentally qualified name only within this agent's
|
|
108
|
+
# available registry. Never strip arbitrary prefixes or override an
|
|
109
|
+
# exact registered name (MCP/extension names may contain dots).
|
|
110
|
+
matches = [n for n in self.tools if name == f"{self.group(n)}.{n}"]
|
|
111
|
+
return matches[0] if len(matches) == 1 else None
|
|
112
|
+
|
|
113
|
+
def directory(self) -> str:
|
|
114
|
+
if self.mode == "eager":
|
|
115
|
+
return ""
|
|
116
|
+
groups: dict[str, list[str]] = {}
|
|
117
|
+
for name in self.tools:
|
|
118
|
+
if name not in self.exposed_names:
|
|
119
|
+
groups.setdefault(self.group(name), []).append(name)
|
|
120
|
+
if not groups:
|
|
121
|
+
return ""
|
|
122
|
+
# Large MCP registries stay compact; ToolSearch list pagination is the
|
|
123
|
+
# lossless fallback, so truncation here cannot hide a tool permanently.
|
|
124
|
+
lines = [
|
|
125
|
+
"# Tool discovery",
|
|
126
|
+
"The names below are NOT callable yet and do not include their schemas. When a task needs one, "
|
|
127
|
+
"first make a real ToolSearch call using its exact name, group or a query. Never guess its "
|
|
128
|
+
"arguments or print a textual/pseudo tool call. A loaded tool becomes callable in the NEXT "
|
|
129
|
+
"response only. Use names exactly as listed; group is a category, "
|
|
130
|
+
"not a namespace or name prefix. Tools whose schemas are already supplied "
|
|
131
|
+
"are callable directly and need no further search. "
|
|
132
|
+
"Use list=true to browse all tools.",
|
|
133
|
+
]
|
|
134
|
+
for group, names in sorted(groups.items())[:20]:
|
|
135
|
+
summary = json.dumps(names[:12], ensure_ascii=False)
|
|
136
|
+
line = f"names={summary} (group={json.dumps(group, ensure_ascii=False)})"
|
|
137
|
+
if len(names) > 12:
|
|
138
|
+
line += f" (+{len(names) - 12} more)"
|
|
139
|
+
if sum(map(len, lines)) + len(line) > 1800:
|
|
140
|
+
lines.append("More tools available through ToolSearch list=true.")
|
|
141
|
+
break
|
|
142
|
+
lines.append(line)
|
|
143
|
+
if len(groups) > 20:
|
|
144
|
+
lines.append("More groups available through ToolSearch list=true.")
|
|
145
|
+
return "\n".join(lines)
|
|
146
|
+
|
|
147
|
+
def instructions(self) -> str:
|
|
148
|
+
servers = {}
|
|
149
|
+
for tool in self.loaded:
|
|
150
|
+
instructions = getattr(tool, "server_instructions", "")
|
|
151
|
+
if instructions:
|
|
152
|
+
servers[getattr(tool, "_server_name", tool.name)] = instructions
|
|
153
|
+
return "\n\n".join(f"MCP {name}:\n{value}" for name, value in sorted(servers.items()))
|
|
154
|
+
|
|
155
|
+
def search(self, *, names: list[str], group: str, query: str,
|
|
156
|
+
list_only: bool, offset: int, limit: int) -> str:
|
|
157
|
+
candidates = list(self.tools)
|
|
158
|
+
if names:
|
|
159
|
+
resolved = [(name, self._resolve_name(name)) for name in names]
|
|
160
|
+
missing = [name for name, canonical in resolved if canonical is None]
|
|
161
|
+
if missing:
|
|
162
|
+
return json.dumps({"error": "Unknown or unavailable tools", "names": missing,
|
|
163
|
+
"hint": "Use list=true to browse available tools."})
|
|
164
|
+
candidates = list(dict.fromkeys(
|
|
165
|
+
canonical for _, canonical in resolved if canonical is not None
|
|
166
|
+
))
|
|
167
|
+
if group:
|
|
168
|
+
candidates = [n for n in candidates if self.group(n) == group]
|
|
169
|
+
if query:
|
|
170
|
+
terms = re.findall(r"[\w]+", query.casefold())
|
|
171
|
+
def score(name: str) -> int:
|
|
172
|
+
tool = self.tools[name]
|
|
173
|
+
text = f"{name} {tool.description} {getattr(tool, '_cached_prompt', '')} "
|
|
174
|
+
text += f"{self.group(name)} {ALIASES.get(self.group(name), '')}"
|
|
175
|
+
return sum(term in text.casefold() for term in terms)
|
|
176
|
+
candidates = sorted((n for n in candidates if score(n)), key=lambda n: -score(n))
|
|
177
|
+
page = candidates[offset:offset + limit]
|
|
178
|
+
loaded = [] if list_only else self.load(page)
|
|
179
|
+
return json.dumps({
|
|
180
|
+
"tools": [{"name": n, "group": self.group(n),
|
|
181
|
+
"summary": (self.tools[n].description or getattr(self.tools[n], '_cached_prompt', ''))[:160],
|
|
182
|
+
"loaded": n in self.state.names} for n in page],
|
|
183
|
+
"loaded": loaded, "total": len(candidates),
|
|
184
|
+
"next_offset": offset + limit if offset + limit < len(candidates) else None,
|
|
185
|
+
"hint": "Loaded schemas are available in the next response. Permissions still apply."
|
|
186
|
+
if loaded else "Use names or group to load; list=true with offset to browse.",
|
|
187
|
+
}, ensure_ascii=False)
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
"""MemoryTool — persistent memory across conversations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import uuid
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from crabcode_core.logging_utils import get_logger
|
|
13
|
+
from crabcode_core.paths import get_config_home
|
|
14
|
+
from crabcode_core.types.tool import Tool, ToolContext, ToolResult
|
|
15
|
+
|
|
16
|
+
logger = get_logger(__name__)
|
|
17
|
+
|
|
18
|
+
PROJECT_MEMORY_DIR_NAME = ".crabcode"
|
|
19
|
+
MEMORY_FILENAME = "memories.json"
|
|
20
|
+
MEMORY_DIRECTORY_LIMIT = 100
|
|
21
|
+
MEMORY_SEARCH_DEFAULT_LIMIT = 10
|
|
22
|
+
MEMORY_SEARCH_MAX_LIMIT = 50
|
|
23
|
+
MEMORY_SEARCH_EXCERPT_LENGTH = 180
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _memory_path(scope: str, cwd: str) -> Path:
|
|
27
|
+
if scope == "global":
|
|
28
|
+
return get_config_home() / MEMORY_FILENAME
|
|
29
|
+
return Path(cwd) / PROJECT_MEMORY_DIR_NAME / MEMORY_FILENAME
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _load_memories(path: Path) -> list[dict[str, Any]]:
|
|
33
|
+
if not path.exists():
|
|
34
|
+
return []
|
|
35
|
+
try:
|
|
36
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
37
|
+
if isinstance(data, list):
|
|
38
|
+
return [entry for entry in data if isinstance(entry, dict)]
|
|
39
|
+
except (json.JSONDecodeError, OSError):
|
|
40
|
+
logger.warning("Failed to load memories from %s", path, exc_info=True)
|
|
41
|
+
return []
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _save_memories(path: Path, memories: list[dict[str, Any]]) -> None:
|
|
45
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
path.write_text(
|
|
47
|
+
json.dumps(memories, indent=2, ensure_ascii=False) + "\n",
|
|
48
|
+
encoding="utf-8",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_all_memories(cwd: str) -> list[dict[str, Any]]:
|
|
53
|
+
"""Load memories from both global and project scopes for context injection."""
|
|
54
|
+
results: list[dict[str, Any]] = []
|
|
55
|
+
|
|
56
|
+
global_path = _memory_path("global", cwd)
|
|
57
|
+
for m in _load_memories(global_path):
|
|
58
|
+
m["_scope"] = "global"
|
|
59
|
+
results.append(m)
|
|
60
|
+
|
|
61
|
+
project_path = _memory_path("project", cwd)
|
|
62
|
+
if project_path != global_path:
|
|
63
|
+
for m in _load_memories(project_path):
|
|
64
|
+
m["_scope"] = "project"
|
|
65
|
+
results.append(m)
|
|
66
|
+
|
|
67
|
+
return results
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _memory_sort_key(memory: dict[str, Any]) -> str:
|
|
71
|
+
"""Sort memories by their most recent timestamp, newest first."""
|
|
72
|
+
updated_at = memory.get("updated_at")
|
|
73
|
+
if isinstance(updated_at, str):
|
|
74
|
+
return updated_at
|
|
75
|
+
created_at = memory.get("created_at")
|
|
76
|
+
return created_at if isinstance(created_at, str) else ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _memory_summary(memory: dict[str, Any]) -> str:
|
|
80
|
+
"""Return a compact, defensive summary for a stored memory."""
|
|
81
|
+
title = memory.get("title")
|
|
82
|
+
if isinstance(title, str) and title.strip():
|
|
83
|
+
return " ".join(title.split())
|
|
84
|
+
return "Untitled memory"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def format_memory_directory(
|
|
88
|
+
memories: list[dict[str, Any]],
|
|
89
|
+
*,
|
|
90
|
+
limit: int = MEMORY_DIRECTORY_LIMIT,
|
|
91
|
+
) -> str:
|
|
92
|
+
"""Format a bounded title-only directory suitable for prompt injection."""
|
|
93
|
+
ordered = sorted(memories, key=_memory_sort_key, reverse=True)
|
|
94
|
+
visible = ordered[:limit]
|
|
95
|
+
lines: list[str] = []
|
|
96
|
+
for memory in visible:
|
|
97
|
+
memory_id = memory.get("id")
|
|
98
|
+
if not isinstance(memory_id, str) or not memory_id:
|
|
99
|
+
continue
|
|
100
|
+
scope = memory.get("_scope", "?")
|
|
101
|
+
lines.append(f"- [{scope}] {memory_id}: {_memory_summary(memory)}")
|
|
102
|
+
|
|
103
|
+
if not lines:
|
|
104
|
+
return ""
|
|
105
|
+
|
|
106
|
+
heading = f"Persistent memory directory ({len(lines)} of {len(memories)} shown):"
|
|
107
|
+
return "\n".join([heading, *lines])
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _scoped_memories(cwd: str, scope: str | None) -> list[dict[str, Any]]:
|
|
111
|
+
memories = load_all_memories(cwd)
|
|
112
|
+
if scope is None:
|
|
113
|
+
return memories
|
|
114
|
+
return [memory for memory in memories if memory.get("_scope") == scope]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _search_score(memory: dict[str, Any], query: str) -> int:
|
|
118
|
+
title = str(memory.get("title", "")).casefold()
|
|
119
|
+
content = str(memory.get("content", "")).casefold()
|
|
120
|
+
normalized_query = query.casefold().strip()
|
|
121
|
+
if not normalized_query:
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
score = 0
|
|
125
|
+
if normalized_query in title:
|
|
126
|
+
score += 8
|
|
127
|
+
if normalized_query in content:
|
|
128
|
+
score += 4
|
|
129
|
+
|
|
130
|
+
terms = [term for term in re.split(r"\s+", normalized_query) if term]
|
|
131
|
+
for term in terms:
|
|
132
|
+
if term in title:
|
|
133
|
+
score += 2
|
|
134
|
+
if term in content:
|
|
135
|
+
score += 1
|
|
136
|
+
return score
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _memory_excerpt(content: Any, query: str) -> str:
|
|
140
|
+
text = " ".join(str(content or "").split())
|
|
141
|
+
if len(text) <= MEMORY_SEARCH_EXCERPT_LENGTH:
|
|
142
|
+
return text
|
|
143
|
+
|
|
144
|
+
folded = text.casefold()
|
|
145
|
+
candidates = [query.casefold().strip(), *query.casefold().split()]
|
|
146
|
+
match_at = next(
|
|
147
|
+
(folded.find(candidate) for candidate in candidates if candidate and candidate in folded),
|
|
148
|
+
0,
|
|
149
|
+
)
|
|
150
|
+
half = MEMORY_SEARCH_EXCERPT_LENGTH // 2
|
|
151
|
+
start = max(0, match_at - half)
|
|
152
|
+
end = min(len(text), start + MEMORY_SEARCH_EXCERPT_LENGTH)
|
|
153
|
+
if end - start < MEMORY_SEARCH_EXCERPT_LENGTH:
|
|
154
|
+
start = max(0, end - MEMORY_SEARCH_EXCERPT_LENGTH)
|
|
155
|
+
prefix = "…" if start else ""
|
|
156
|
+
suffix = "…" if end < len(text) else ""
|
|
157
|
+
return f"{prefix}{text[start:end]}{suffix}"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class MemoryTool(Tool):
|
|
161
|
+
name = "Memory"
|
|
162
|
+
description = "Search, read, create, update, or delete persistent memories."
|
|
163
|
+
is_read_only = False
|
|
164
|
+
is_concurrency_safe = False
|
|
165
|
+
input_schema = {
|
|
166
|
+
"type": "object",
|
|
167
|
+
"properties": {
|
|
168
|
+
"action": {
|
|
169
|
+
"type": "string",
|
|
170
|
+
"enum": ["create", "update", "delete", "list", "search", "read"],
|
|
171
|
+
"description": "The action to perform.",
|
|
172
|
+
},
|
|
173
|
+
"title": {
|
|
174
|
+
"type": "string",
|
|
175
|
+
"description": (
|
|
176
|
+
"Short title capturing the essence of the memory. "
|
|
177
|
+
"Required for 'create' and 'update'."
|
|
178
|
+
),
|
|
179
|
+
},
|
|
180
|
+
"content": {
|
|
181
|
+
"type": "string",
|
|
182
|
+
"description": (
|
|
183
|
+
"The memory content (no more than a paragraph). "
|
|
184
|
+
"Required for 'create' and 'update'."
|
|
185
|
+
),
|
|
186
|
+
},
|
|
187
|
+
"memory_id": {
|
|
188
|
+
"type": "string",
|
|
189
|
+
"description": (
|
|
190
|
+
"ID of an existing memory. "
|
|
191
|
+
"Required for 'update', 'delete', and 'read'."
|
|
192
|
+
),
|
|
193
|
+
},
|
|
194
|
+
"query": {
|
|
195
|
+
"type": "string",
|
|
196
|
+
"description": "Search terms. Required for 'search'.",
|
|
197
|
+
},
|
|
198
|
+
"limit": {
|
|
199
|
+
"type": "integer",
|
|
200
|
+
"minimum": 1,
|
|
201
|
+
"maximum": MEMORY_SEARCH_MAX_LIMIT,
|
|
202
|
+
"description": (
|
|
203
|
+
"Maximum search results (default 10, maximum 50). "
|
|
204
|
+
"Only used by 'search'."
|
|
205
|
+
),
|
|
206
|
+
},
|
|
207
|
+
"scope": {
|
|
208
|
+
"type": "string",
|
|
209
|
+
"enum": ["project", "global"],
|
|
210
|
+
"description": (
|
|
211
|
+
"Memory scope. For writes, 'project' is the default. "
|
|
212
|
+
"For list/search/read, omitting scope checks both project "
|
|
213
|
+
"and global memories."
|
|
214
|
+
),
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
"required": ["action"],
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async def get_prompt(self, **kwargs: Any) -> str:
|
|
221
|
+
return (
|
|
222
|
+
"Search, read, create, update, or delete persistent memories that survive "
|
|
223
|
+
"across conversations. Only a compact directory of memory summaries is "
|
|
224
|
+
"automatically loaded into context; use search and read to retrieve details.\n\n"
|
|
225
|
+
"Actions:\n"
|
|
226
|
+
"- create: Store a new memory (requires title + content)\n"
|
|
227
|
+
"- update: Modify an existing memory (requires memory_id + title and/or content)\n"
|
|
228
|
+
"- delete: Remove a memory (requires memory_id)\n"
|
|
229
|
+
"- list: Show the title-only memory directory\n"
|
|
230
|
+
"- search: Find memories by title or content (requires query; returns snippets)\n"
|
|
231
|
+
"- read: Retrieve one memory's full content (requires memory_id)\n\n"
|
|
232
|
+
"Scope:\n"
|
|
233
|
+
"- 'project' (default): Stored in .crabcode/memories.json in the project root\n"
|
|
234
|
+
f"- 'global': Stored in {get_config_home() / MEMORY_FILENAME}, available across all projects\n"
|
|
235
|
+
"- list/search/read check both scopes when scope is omitted\n\n"
|
|
236
|
+
"Guidelines:\n"
|
|
237
|
+
"- Only create memories when the user explicitly asks to remember something.\n"
|
|
238
|
+
"- If the user contradicts an existing memory, DELETE the old one rather than updating.\n"
|
|
239
|
+
"- Keep memories concise — no more than a paragraph each.\n"
|
|
240
|
+
"- Use 'project' scope for project-specific conventions and preferences.\n"
|
|
241
|
+
"- Use 'global' scope for general user preferences that apply everywhere.\n"
|
|
242
|
+
"- Search first when the directory summary is insufficient, then read the relevant memory."
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
async def validate_input(self, tool_input: dict[str, Any]) -> str | None:
|
|
246
|
+
action = tool_input.get("action")
|
|
247
|
+
if action not in ("create", "update", "delete", "list", "search", "read"):
|
|
248
|
+
return "action must be one of: create, update, delete, list, search, read"
|
|
249
|
+
|
|
250
|
+
scope = tool_input.get("scope")
|
|
251
|
+
if scope is not None and scope not in ("project", "global"):
|
|
252
|
+
return "scope must be one of: project, global"
|
|
253
|
+
|
|
254
|
+
if action == "create":
|
|
255
|
+
if not tool_input.get("title"):
|
|
256
|
+
return "title is required for 'create'"
|
|
257
|
+
if not tool_input.get("content"):
|
|
258
|
+
return "content is required for 'create'"
|
|
259
|
+
|
|
260
|
+
if action == "update":
|
|
261
|
+
if not tool_input.get("memory_id"):
|
|
262
|
+
return "memory_id is required for 'update'"
|
|
263
|
+
if not tool_input.get("title") and not tool_input.get("content"):
|
|
264
|
+
return "title or content is required for 'update'"
|
|
265
|
+
|
|
266
|
+
if action in ("delete", "read"):
|
|
267
|
+
if not tool_input.get("memory_id"):
|
|
268
|
+
return f"memory_id is required for '{action}'"
|
|
269
|
+
|
|
270
|
+
if action == "search":
|
|
271
|
+
query = tool_input.get("query")
|
|
272
|
+
if not isinstance(query, str) or not query.strip():
|
|
273
|
+
return "query is required for 'search'"
|
|
274
|
+
limit = tool_input.get("limit", MEMORY_SEARCH_DEFAULT_LIMIT)
|
|
275
|
+
if (
|
|
276
|
+
not isinstance(limit, int)
|
|
277
|
+
or isinstance(limit, bool)
|
|
278
|
+
or limit < 1
|
|
279
|
+
or limit > MEMORY_SEARCH_MAX_LIMIT
|
|
280
|
+
):
|
|
281
|
+
return f"limit must be an integer between 1 and {MEMORY_SEARCH_MAX_LIMIT}"
|
|
282
|
+
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
async def call(
|
|
286
|
+
self,
|
|
287
|
+
tool_input: dict[str, Any],
|
|
288
|
+
context: ToolContext,
|
|
289
|
+
) -> ToolResult:
|
|
290
|
+
action = tool_input["action"]
|
|
291
|
+
requested_scope = tool_input.get("scope")
|
|
292
|
+
scope = requested_scope or "project"
|
|
293
|
+
path = _memory_path(scope, context.cwd)
|
|
294
|
+
memories = _load_memories(path)
|
|
295
|
+
|
|
296
|
+
if action == "create":
|
|
297
|
+
entry = {
|
|
298
|
+
"id": uuid.uuid4().hex[:12],
|
|
299
|
+
"title": tool_input["title"],
|
|
300
|
+
"content": tool_input["content"],
|
|
301
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
302
|
+
}
|
|
303
|
+
memories.append(entry)
|
|
304
|
+
_save_memories(path, memories)
|
|
305
|
+
return ToolResult(
|
|
306
|
+
data=entry,
|
|
307
|
+
result_for_model=(
|
|
308
|
+
f"Memory created (id: {entry['id']}, scope: {scope}):\n"
|
|
309
|
+
f" {entry['title']}"
|
|
310
|
+
),
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
if action == "update":
|
|
314
|
+
memory_id = tool_input["memory_id"]
|
|
315
|
+
target = next((m for m in memories if m.get("id") == memory_id), None)
|
|
316
|
+
if not target:
|
|
317
|
+
return ToolResult(
|
|
318
|
+
result_for_model=f"Error: memory '{memory_id}' not found in {scope} scope.",
|
|
319
|
+
is_error=True,
|
|
320
|
+
)
|
|
321
|
+
if tool_input.get("title"):
|
|
322
|
+
target["title"] = tool_input["title"]
|
|
323
|
+
if tool_input.get("content"):
|
|
324
|
+
target["content"] = tool_input["content"]
|
|
325
|
+
target["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
326
|
+
_save_memories(path, memories)
|
|
327
|
+
return ToolResult(
|
|
328
|
+
data=target,
|
|
329
|
+
result_for_model=(
|
|
330
|
+
f"Memory updated (id: {memory_id}, scope: {scope}):\n"
|
|
331
|
+
f" {target['title']}"
|
|
332
|
+
),
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
if action == "delete":
|
|
336
|
+
memory_id = tool_input["memory_id"]
|
|
337
|
+
before = len(memories)
|
|
338
|
+
memories = [m for m in memories if m.get("id") != memory_id]
|
|
339
|
+
if len(memories) == before:
|
|
340
|
+
return ToolResult(
|
|
341
|
+
result_for_model=f"Error: memory '{memory_id}' not found in {scope} scope.",
|
|
342
|
+
is_error=True,
|
|
343
|
+
)
|
|
344
|
+
_save_memories(path, memories)
|
|
345
|
+
return ToolResult(
|
|
346
|
+
result_for_model=f"Memory deleted (id: {memory_id}, scope: {scope}).",
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
if action == "list":
|
|
350
|
+
all_memories = _scoped_memories(context.cwd, requested_scope)
|
|
351
|
+
if not all_memories:
|
|
352
|
+
return ToolResult(
|
|
353
|
+
result_for_model="No memories stored.",
|
|
354
|
+
)
|
|
355
|
+
directory = format_memory_directory(all_memories)
|
|
356
|
+
return ToolResult(
|
|
357
|
+
data={"count": len(all_memories)},
|
|
358
|
+
result_for_model=(
|
|
359
|
+
f"{directory}\n"
|
|
360
|
+
"Use action='read' with a memory_id to retrieve full content."
|
|
361
|
+
),
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
if action == "search":
|
|
365
|
+
query = tool_input["query"].strip()
|
|
366
|
+
limit = tool_input.get("limit", MEMORY_SEARCH_DEFAULT_LIMIT)
|
|
367
|
+
candidates = _scoped_memories(context.cwd, requested_scope)
|
|
368
|
+
ranked = [
|
|
369
|
+
(score, memory)
|
|
370
|
+
for memory in candidates
|
|
371
|
+
if (score := _search_score(memory, query)) > 0
|
|
372
|
+
]
|
|
373
|
+
ranked.sort(
|
|
374
|
+
key=lambda item: (item[0], _memory_sort_key(item[1])),
|
|
375
|
+
reverse=True,
|
|
376
|
+
)
|
|
377
|
+
search_matches = ranked[:limit]
|
|
378
|
+
if not search_matches:
|
|
379
|
+
return ToolResult(
|
|
380
|
+
data={"count": 0, "matches": []},
|
|
381
|
+
result_for_model=f"No memories matched {query!r}.",
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
result_data: list[dict[str, Any]] = []
|
|
385
|
+
lines: list[str] = []
|
|
386
|
+
for _, memory in search_matches:
|
|
387
|
+
memory_id = memory.get("id", "?")
|
|
388
|
+
memory_scope = memory.get("_scope", "?")
|
|
389
|
+
excerpt = _memory_excerpt(memory.get("content"), query)
|
|
390
|
+
lines.append(
|
|
391
|
+
f"- [{memory_scope}] {memory_id}: {_memory_summary(memory)}"
|
|
392
|
+
+ (f"\n Match: {excerpt}" if excerpt else "")
|
|
393
|
+
)
|
|
394
|
+
result_data.append(
|
|
395
|
+
{
|
|
396
|
+
"id": memory_id,
|
|
397
|
+
"scope": memory_scope,
|
|
398
|
+
"title": _memory_summary(memory),
|
|
399
|
+
"excerpt": excerpt,
|
|
400
|
+
}
|
|
401
|
+
)
|
|
402
|
+
return ToolResult(
|
|
403
|
+
data={"count": len(result_data), "matches": result_data},
|
|
404
|
+
result_for_model=(
|
|
405
|
+
f"{len(result_data)} memory match(es) for {query!r}:\n"
|
|
406
|
+
+ "\n".join(lines)
|
|
407
|
+
+ "\nUse action='read' with a memory_id for the full content."
|
|
408
|
+
),
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
if action == "read":
|
|
412
|
+
memory_id = tool_input["memory_id"]
|
|
413
|
+
candidates = _scoped_memories(context.cwd, requested_scope)
|
|
414
|
+
read_matches = [
|
|
415
|
+
memory for memory in candidates if memory.get("id") == memory_id
|
|
416
|
+
]
|
|
417
|
+
if not read_matches:
|
|
418
|
+
scope_text = requested_scope or "project/global"
|
|
419
|
+
return ToolResult(
|
|
420
|
+
result_for_model=(
|
|
421
|
+
f"Error: memory '{memory_id}' not found in {scope_text} scope."
|
|
422
|
+
),
|
|
423
|
+
is_error=True,
|
|
424
|
+
)
|
|
425
|
+
if len(read_matches) > 1 and requested_scope is None:
|
|
426
|
+
return ToolResult(
|
|
427
|
+
result_for_model=(
|
|
428
|
+
f"Error: memory id '{memory_id}' exists in multiple scopes; "
|
|
429
|
+
"specify scope='project' or scope='global'."
|
|
430
|
+
),
|
|
431
|
+
is_error=True,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
target_memory = read_matches[0]
|
|
435
|
+
memory_scope = target_memory.get("_scope", "?")
|
|
436
|
+
content = str(target_memory.get("content", ""))
|
|
437
|
+
return ToolResult(
|
|
438
|
+
data={
|
|
439
|
+
"id": memory_id,
|
|
440
|
+
"scope": memory_scope,
|
|
441
|
+
"title": _memory_summary(target_memory),
|
|
442
|
+
"content": content,
|
|
443
|
+
"created_at": target_memory.get("created_at"),
|
|
444
|
+
"updated_at": target_memory.get("updated_at"),
|
|
445
|
+
},
|
|
446
|
+
result_for_model=(
|
|
447
|
+
f"Memory [{memory_scope}] {memory_id}: "
|
|
448
|
+
f"{_memory_summary(target_memory)}\n"
|
|
449
|
+
f"{content}"
|
|
450
|
+
),
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
return ToolResult(
|
|
454
|
+
result_for_model=f"Unknown action: {action}",
|
|
455
|
+
is_error=True,
|
|
456
|
+
)
|