crabcode 0.1.0__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.
- crabcode-0.1.0.dist-info/METADATA +23 -0
- crabcode-0.1.0.dist-info/RECORD +64 -0
- crabcode-0.1.0.dist-info/WHEEL +4 -0
- crabcode-0.1.0.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +3 -0
- crabcode_cli/app.py +142 -0
- crabcode_cli/banner.py +42 -0
- crabcode_cli/pipe.py +58 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +1263 -0
- crabcode_core/__init__.py +3 -0
- crabcode_core/api/__init__.py +6 -0
- crabcode_core/api/anthropic_adapter.py +250 -0
- crabcode_core/api/base.py +66 -0
- crabcode_core/api/codex_adapter.py +390 -0
- crabcode_core/api/openai_adapter.py +221 -0
- crabcode_core/api/registry.py +40 -0
- crabcode_core/compact/__init__.py +7 -0
- crabcode_core/compact/compact.py +150 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +53 -0
- crabcode_core/config/manager.py +152 -0
- crabcode_core/events.py +392 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +163 -0
- crabcode_core/mcp/config.py +52 -0
- crabcode_core/permissions/__init__.py +3 -0
- crabcode_core/permissions/manager.py +106 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/context.py +209 -0
- crabcode_core/prompts/profile.py +66 -0
- crabcode_core/prompts/system.py +405 -0
- crabcode_core/prompts/templates.py +60 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/loop.py +442 -0
- crabcode_core/session/__init__.py +3 -0
- crabcode_core/session/storage.py +153 -0
- crabcode_core/skills/__init__.py +5 -0
- crabcode_core/skills/loader.py +147 -0
- crabcode_core/tools/__init__.py +29 -0
- crabcode_core/tools/agent.py +160 -0
- crabcode_core/tools/bash.py +127 -0
- crabcode_core/tools/diff_utils.py +120 -0
- crabcode_core/tools/file_edit.py +145 -0
- crabcode_core/tools/file_read.py +123 -0
- crabcode_core/tools/file_write.py +102 -0
- crabcode_core/tools/glob.py +109 -0
- crabcode_core/tools/grep.py +130 -0
- crabcode_core/tools/lint.py +368 -0
- crabcode_core/tools/memory.py +236 -0
- crabcode_core/tools/skill.py +112 -0
- crabcode_core/types/__init__.py +73 -0
- crabcode_core/types/config.py +103 -0
- crabcode_core/types/event.py +105 -0
- crabcode_core/types/message.py +177 -0
- crabcode_core/types/tool.py +138 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +371 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +447 -0
- crabcode_search/indexer.py +212 -0
- crabcode_search/store.py +222 -0
- crabcode_search/tool.py +232 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""LintTool — read linter errors and diagnostics for specified files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from crabcode_core.types.tool import Tool, ToolContext, ToolResult
|
|
11
|
+
|
|
12
|
+
_LINTER_CONFIGS: list[dict[str, Any]] = [
|
|
13
|
+
# ── Python ──
|
|
14
|
+
{
|
|
15
|
+
"name": "ruff",
|
|
16
|
+
"extensions": {".py", ".pyi"},
|
|
17
|
+
"cmd": ["ruff", "check", "--output-format=concise"],
|
|
18
|
+
"detect_files": {"ruff.toml", ".ruff.toml"},
|
|
19
|
+
"detect_sections": {"ruff"},
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"name": "pylint",
|
|
23
|
+
"extensions": {".py"},
|
|
24
|
+
"cmd": ["pylint", "--output-format=text", "--score=no"],
|
|
25
|
+
"detect_files": {".pylintrc", "pylintrc"},
|
|
26
|
+
"detect_sections": {"pylint"},
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"name": "mypy",
|
|
30
|
+
"extensions": {".py", ".pyi"},
|
|
31
|
+
"cmd": ["mypy", "--no-error-summary", "--no-pretty", "--show-column-numbers"],
|
|
32
|
+
"detect_files": {"mypy.ini", ".mypy.ini"},
|
|
33
|
+
"detect_sections": {"mypy"},
|
|
34
|
+
},
|
|
35
|
+
# ── JavaScript / TypeScript ──
|
|
36
|
+
{
|
|
37
|
+
"name": "eslint",
|
|
38
|
+
"extensions": {".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"},
|
|
39
|
+
"cmd": ["eslint", "--format=unix", "--no-color"],
|
|
40
|
+
"detect_files": {
|
|
41
|
+
".eslintrc", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json",
|
|
42
|
+
".eslintrc.yml", ".eslintrc.yaml", "eslint.config.js",
|
|
43
|
+
"eslint.config.mjs", "eslint.config.cjs", "eslint.config.ts",
|
|
44
|
+
},
|
|
45
|
+
"detect_sections": set(),
|
|
46
|
+
},
|
|
47
|
+
# ── Go ──
|
|
48
|
+
{
|
|
49
|
+
"name": "golangci-lint",
|
|
50
|
+
"extensions": {".go"},
|
|
51
|
+
"cmd": ["golangci-lint", "run", "--out-format=line-number"],
|
|
52
|
+
"detect_files": {".golangci.yml", ".golangci.yaml", ".golangci.toml"},
|
|
53
|
+
"detect_sections": set(),
|
|
54
|
+
},
|
|
55
|
+
# ── Rust ──
|
|
56
|
+
{
|
|
57
|
+
"name": "clippy",
|
|
58
|
+
"extensions": {".rs"},
|
|
59
|
+
"cmd": ["cargo", "clippy", "--quiet", "--message-format=short"],
|
|
60
|
+
"detect_files": {"Cargo.toml"},
|
|
61
|
+
"detect_sections": set(),
|
|
62
|
+
"project_level": True,
|
|
63
|
+
"use_stderr": True,
|
|
64
|
+
},
|
|
65
|
+
# ── C / C++ ──
|
|
66
|
+
{
|
|
67
|
+
"name": "clang-tidy",
|
|
68
|
+
"extensions": {".c", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".hxx"},
|
|
69
|
+
"cmd": ["clang-tidy"],
|
|
70
|
+
"detect_files": {".clang-tidy", "compile_commands.json"},
|
|
71
|
+
"detect_sections": set(),
|
|
72
|
+
"suffix_args": ["--"],
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"name": "cppcheck",
|
|
76
|
+
"extensions": {".c", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".hxx"},
|
|
77
|
+
"cmd": ["cppcheck", "--enable=warning,style,performance", "--template=gcc", "--quiet"],
|
|
78
|
+
"detect_files": set(),
|
|
79
|
+
"detect_sections": set(),
|
|
80
|
+
},
|
|
81
|
+
# ── Java ──
|
|
82
|
+
{
|
|
83
|
+
"name": "checkstyle",
|
|
84
|
+
"extensions": {".java"},
|
|
85
|
+
"cmd": ["checkstyle"],
|
|
86
|
+
"detect_files": {"checkstyle.xml", ".checkstyle", "checkstyle-config.xml"},
|
|
87
|
+
"detect_sections": set(),
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"name": "pmd",
|
|
91
|
+
"extensions": {".java"},
|
|
92
|
+
"cmd": ["pmd", "check", "-f", "text", "--no-progress", "-R",
|
|
93
|
+
"rulesets/java/quickstart.xml"],
|
|
94
|
+
"detect_files": {"ruleset.xml", "pmd-ruleset.xml"},
|
|
95
|
+
"detect_sections": set(),
|
|
96
|
+
"target_flag": "-d",
|
|
97
|
+
},
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _has_pyproject_section(cwd: str, section_names: set[str]) -> bool:
|
|
102
|
+
"""Check if pyproject.toml has a [tool.<name>] section."""
|
|
103
|
+
if not section_names:
|
|
104
|
+
return False
|
|
105
|
+
pyproject = Path(cwd) / "pyproject.toml"
|
|
106
|
+
if not pyproject.exists():
|
|
107
|
+
return False
|
|
108
|
+
try:
|
|
109
|
+
text = pyproject.read_text(errors="replace")
|
|
110
|
+
return any(f"[tool.{s}]" in text for s in section_names)
|
|
111
|
+
except Exception:
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _has_config_file(cwd: str, filenames: set[str]) -> bool:
|
|
116
|
+
"""Check if any of the given config files exist in cwd."""
|
|
117
|
+
for name in filenames:
|
|
118
|
+
if (Path(cwd) / name).exists():
|
|
119
|
+
return True
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _detect_linters(
|
|
124
|
+
paths: list[Path], cwd: str
|
|
125
|
+
) -> list[dict[str, Any]]:
|
|
126
|
+
"""Pick applicable linters based on file extensions and project config.
|
|
127
|
+
|
|
128
|
+
When no paths are given, scans for project config files to decide which
|
|
129
|
+
linters apply instead of guessing by extension.
|
|
130
|
+
"""
|
|
131
|
+
extensions = {p.suffix for p in paths if p.suffix}
|
|
132
|
+
no_paths = not paths
|
|
133
|
+
|
|
134
|
+
matched: list[dict[str, Any]] = []
|
|
135
|
+
seen_names: set[str] = set()
|
|
136
|
+
for cfg in _LINTER_CONFIGS:
|
|
137
|
+
if cfg["name"] in seen_names:
|
|
138
|
+
continue
|
|
139
|
+
if not shutil.which(cfg["cmd"][0]):
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
has_config = (
|
|
143
|
+
_has_config_file(cwd, cfg["detect_files"])
|
|
144
|
+
or _has_pyproject_section(cwd, cfg["detect_sections"])
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
if no_paths:
|
|
148
|
+
if not has_config:
|
|
149
|
+
continue
|
|
150
|
+
else:
|
|
151
|
+
if not extensions & cfg["extensions"]:
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
matched.append({**cfg, "has_config": has_config})
|
|
155
|
+
seen_names.add(cfg["name"])
|
|
156
|
+
|
|
157
|
+
if not matched:
|
|
158
|
+
return []
|
|
159
|
+
|
|
160
|
+
configured = [m for m in matched if m["has_config"]]
|
|
161
|
+
if configured:
|
|
162
|
+
return configured
|
|
163
|
+
return matched[:1]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def _run_linter(
|
|
167
|
+
cfg: dict[str, Any],
|
|
168
|
+
targets: list[str],
|
|
169
|
+
cwd: str,
|
|
170
|
+
timeout: int = 60,
|
|
171
|
+
) -> tuple[str, str, int]:
|
|
172
|
+
"""Run a linter command and return (output, stderr, exit_code).
|
|
173
|
+
|
|
174
|
+
Respects per-linter config flags:
|
|
175
|
+
project_level – don't append file targets
|
|
176
|
+
use_stderr – read diagnostics from stderr instead of stdout
|
|
177
|
+
suffix_args – extra args appended after targets (e.g. ["--"] for clang-tidy)
|
|
178
|
+
target_flag – flag before each target (e.g. "-d" for pmd)
|
|
179
|
+
"""
|
|
180
|
+
cmd = cfg["cmd"]
|
|
181
|
+
is_project_level = cfg.get("project_level", False)
|
|
182
|
+
target_flag = cfg.get("target_flag")
|
|
183
|
+
suffix_args = cfg.get("suffix_args", [])
|
|
184
|
+
|
|
185
|
+
full_cmd = list(cmd)
|
|
186
|
+
if not is_project_level:
|
|
187
|
+
if target_flag:
|
|
188
|
+
full_cmd.extend([target_flag, ",".join(targets)])
|
|
189
|
+
else:
|
|
190
|
+
full_cmd.extend(targets)
|
|
191
|
+
full_cmd.extend(suffix_args)
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
proc = await asyncio.create_subprocess_exec(
|
|
195
|
+
*full_cmd,
|
|
196
|
+
stdout=asyncio.subprocess.PIPE,
|
|
197
|
+
stderr=asyncio.subprocess.PIPE,
|
|
198
|
+
cwd=cwd,
|
|
199
|
+
)
|
|
200
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
201
|
+
proc.communicate(), timeout=timeout
|
|
202
|
+
)
|
|
203
|
+
stdout = stdout_bytes.decode("utf-8", errors="replace")
|
|
204
|
+
stderr = stderr_bytes.decode("utf-8", errors="replace")
|
|
205
|
+
|
|
206
|
+
if cfg.get("use_stderr"):
|
|
207
|
+
return stderr, stdout, proc.returncode or 0
|
|
208
|
+
return stdout, stderr, proc.returncode or 0
|
|
209
|
+
except asyncio.TimeoutError:
|
|
210
|
+
proc.kill()
|
|
211
|
+
await proc.wait()
|
|
212
|
+
return "", f"Linter timed out after {timeout}s", -1
|
|
213
|
+
except FileNotFoundError:
|
|
214
|
+
return "", f"Linter command not found: {cmd[0]}", -1
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class LintTool(Tool):
|
|
218
|
+
name = "Lint"
|
|
219
|
+
description = "Read linter errors and diagnostics for specified files."
|
|
220
|
+
is_read_only = True
|
|
221
|
+
is_concurrency_safe = True
|
|
222
|
+
input_schema = {
|
|
223
|
+
"type": "object",
|
|
224
|
+
"properties": {
|
|
225
|
+
"paths": {
|
|
226
|
+
"type": "array",
|
|
227
|
+
"items": {"type": "string"},
|
|
228
|
+
"description": (
|
|
229
|
+
"File or directory paths to check. "
|
|
230
|
+
"If omitted, checks the current working directory."
|
|
231
|
+
),
|
|
232
|
+
},
|
|
233
|
+
"linter": {
|
|
234
|
+
"type": "string",
|
|
235
|
+
"description": (
|
|
236
|
+
"Force a specific linter (e.g., 'ruff', 'eslint', 'mypy'). "
|
|
237
|
+
"If omitted, auto-detects based on file types and project config."
|
|
238
|
+
),
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async def get_prompt(self, **kwargs: Any) -> str:
|
|
244
|
+
return (
|
|
245
|
+
"Read linter errors and diagnostics for specified files or directories. "
|
|
246
|
+
"Auto-detects the appropriate linter based on file types and project "
|
|
247
|
+
"configuration.\n\n"
|
|
248
|
+
"Supported linters:\n"
|
|
249
|
+
"- Python: ruff, pylint, mypy\n"
|
|
250
|
+
"- JavaScript/TypeScript: eslint\n"
|
|
251
|
+
"- Go: golangci-lint\n"
|
|
252
|
+
"- Rust: clippy (via cargo)\n"
|
|
253
|
+
"- C/C++: clang-tidy, cppcheck\n"
|
|
254
|
+
"- Java: checkstyle, pmd\n\n"
|
|
255
|
+
"Use this tool after making edits to verify you haven't introduced "
|
|
256
|
+
"errors. You can provide specific file paths or omit them to check "
|
|
257
|
+
"the whole project.\n\n"
|
|
258
|
+
"Guidelines:\n"
|
|
259
|
+
"- Only use on files you've edited or are about to edit.\n"
|
|
260
|
+
"- Results may include pre-existing errors — focus on errors in "
|
|
261
|
+
"code you changed.\n"
|
|
262
|
+
"- If no linter is installed or configured, the tool will report "
|
|
263
|
+
"that clearly.\n"
|
|
264
|
+
"- For Rust projects, clippy runs at the project level (ignores "
|
|
265
|
+
"individual file paths)."
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
async def call(
|
|
269
|
+
self,
|
|
270
|
+
tool_input: dict[str, Any],
|
|
271
|
+
context: ToolContext,
|
|
272
|
+
) -> ToolResult:
|
|
273
|
+
raw_paths = tool_input.get("paths") or []
|
|
274
|
+
forced_linter = tool_input.get("linter")
|
|
275
|
+
|
|
276
|
+
cwd = context.cwd
|
|
277
|
+
|
|
278
|
+
resolved: list[Path] = []
|
|
279
|
+
for p in raw_paths:
|
|
280
|
+
path = Path(p)
|
|
281
|
+
if not path.is_absolute():
|
|
282
|
+
path = Path(cwd) / path
|
|
283
|
+
resolved.append(path)
|
|
284
|
+
|
|
285
|
+
missing = [str(p) for p in resolved if not p.exists()]
|
|
286
|
+
if missing:
|
|
287
|
+
return ToolResult(
|
|
288
|
+
result_for_model=f"Error: paths not found: {', '.join(missing)}",
|
|
289
|
+
is_error=True,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
if forced_linter:
|
|
293
|
+
cfg = next(
|
|
294
|
+
(c for c in _LINTER_CONFIGS if c["name"] == forced_linter), None
|
|
295
|
+
)
|
|
296
|
+
if cfg is None:
|
|
297
|
+
known = ", ".join(c["name"] for c in _LINTER_CONFIGS)
|
|
298
|
+
return ToolResult(
|
|
299
|
+
result_for_model=(
|
|
300
|
+
f"Unknown linter '{forced_linter}'. "
|
|
301
|
+
f"Supported: {known}"
|
|
302
|
+
),
|
|
303
|
+
is_error=True,
|
|
304
|
+
)
|
|
305
|
+
if not shutil.which(cfg["cmd"][0]):
|
|
306
|
+
return ToolResult(
|
|
307
|
+
result_for_model=(
|
|
308
|
+
f"Linter '{forced_linter}' is not installed. "
|
|
309
|
+
f"Install it and try again."
|
|
310
|
+
),
|
|
311
|
+
is_error=True,
|
|
312
|
+
)
|
|
313
|
+
linters = [cfg]
|
|
314
|
+
else:
|
|
315
|
+
linters = _detect_linters(resolved, cwd)
|
|
316
|
+
if not linters:
|
|
317
|
+
return ToolResult(
|
|
318
|
+
result_for_model=(
|
|
319
|
+
"No suitable linter found. Either no linter is installed "
|
|
320
|
+
"for the given file types, or no matching files were provided.\n"
|
|
321
|
+
"Supported: ruff, pylint, mypy, eslint, golangci-lint, clippy, "
|
|
322
|
+
"clang-tidy, cppcheck, checkstyle, pmd."
|
|
323
|
+
),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
targets = [str(p) for p in resolved] if resolved else ["."]
|
|
327
|
+
|
|
328
|
+
all_output: list[str] = []
|
|
329
|
+
total_issues = 0
|
|
330
|
+
|
|
331
|
+
for linter_cfg in linters:
|
|
332
|
+
linter_name = linter_cfg["name"]
|
|
333
|
+
stdout, stderr, exit_code = await _run_linter(
|
|
334
|
+
linter_cfg, targets, cwd
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
if exit_code == -1:
|
|
338
|
+
all_output.append(f"[{linter_name}] Error: {stderr}")
|
|
339
|
+
continue
|
|
340
|
+
|
|
341
|
+
output = stdout.strip()
|
|
342
|
+
if not output and exit_code == 0:
|
|
343
|
+
all_output.append(f"[{linter_name}] No issues found.")
|
|
344
|
+
continue
|
|
345
|
+
|
|
346
|
+
lines = output.split("\n") if output else []
|
|
347
|
+
issue_count = len([
|
|
348
|
+
l for l in lines
|
|
349
|
+
if l.strip() and not l.startswith((" ", "\t"))
|
|
350
|
+
])
|
|
351
|
+
total_issues += issue_count
|
|
352
|
+
|
|
353
|
+
max_chars = 50_000
|
|
354
|
+
if len(output) > max_chars:
|
|
355
|
+
output = output[:max_chars] + "\n... (truncated)"
|
|
356
|
+
|
|
357
|
+
header = f"[{linter_name}] {issue_count} issue(s):"
|
|
358
|
+
all_output.append(f"{header}\n{output}")
|
|
359
|
+
|
|
360
|
+
if stderr.strip() and exit_code != 0:
|
|
361
|
+
err_preview = stderr.strip()[:2000]
|
|
362
|
+
all_output.append(f"[{linter_name}] stderr:\n{err_preview}")
|
|
363
|
+
|
|
364
|
+
result_text = "\n\n".join(all_output)
|
|
365
|
+
return ToolResult(
|
|
366
|
+
data={"total_issues": total_issues},
|
|
367
|
+
result_for_model=result_text,
|
|
368
|
+
)
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""MemoryTool — persistent memory across conversations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import uuid
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from crabcode_core.types.tool import Tool, ToolContext, ToolResult
|
|
12
|
+
|
|
13
|
+
GLOBAL_MEMORY_DIR = Path.home() / ".crabcode"
|
|
14
|
+
PROJECT_MEMORY_DIR_NAME = ".crabcode"
|
|
15
|
+
MEMORY_FILENAME = "memories.json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _memory_path(scope: str, cwd: str) -> Path:
|
|
19
|
+
if scope == "global":
|
|
20
|
+
return GLOBAL_MEMORY_DIR / MEMORY_FILENAME
|
|
21
|
+
return Path(cwd) / PROJECT_MEMORY_DIR_NAME / MEMORY_FILENAME
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _load_memories(path: Path) -> list[dict[str, Any]]:
|
|
25
|
+
if not path.exists():
|
|
26
|
+
return []
|
|
27
|
+
try:
|
|
28
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
29
|
+
if isinstance(data, list):
|
|
30
|
+
return data
|
|
31
|
+
except (json.JSONDecodeError, OSError):
|
|
32
|
+
pass
|
|
33
|
+
return []
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _save_memories(path: Path, memories: list[dict[str, Any]]) -> None:
|
|
37
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
path.write_text(
|
|
39
|
+
json.dumps(memories, indent=2, ensure_ascii=False) + "\n",
|
|
40
|
+
encoding="utf-8",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_all_memories(cwd: str) -> list[dict[str, Any]]:
|
|
45
|
+
"""Load memories from both global and project scopes for context injection."""
|
|
46
|
+
results: list[dict[str, Any]] = []
|
|
47
|
+
|
|
48
|
+
global_path = _memory_path("global", cwd)
|
|
49
|
+
for m in _load_memories(global_path):
|
|
50
|
+
m["_scope"] = "global"
|
|
51
|
+
results.append(m)
|
|
52
|
+
|
|
53
|
+
project_path = _memory_path("project", cwd)
|
|
54
|
+
if project_path != global_path:
|
|
55
|
+
for m in _load_memories(project_path):
|
|
56
|
+
m["_scope"] = "project"
|
|
57
|
+
results.append(m)
|
|
58
|
+
|
|
59
|
+
return results
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class MemoryTool(Tool):
|
|
63
|
+
name = "Memory"
|
|
64
|
+
description = "Create, update, or delete persistent memories for future reference."
|
|
65
|
+
is_read_only = False
|
|
66
|
+
is_concurrency_safe = False
|
|
67
|
+
input_schema = {
|
|
68
|
+
"type": "object",
|
|
69
|
+
"properties": {
|
|
70
|
+
"action": {
|
|
71
|
+
"type": "string",
|
|
72
|
+
"enum": ["create", "update", "delete", "list"],
|
|
73
|
+
"description": "The action to perform.",
|
|
74
|
+
},
|
|
75
|
+
"title": {
|
|
76
|
+
"type": "string",
|
|
77
|
+
"description": (
|
|
78
|
+
"Short title capturing the essence of the memory. "
|
|
79
|
+
"Required for 'create' and 'update'."
|
|
80
|
+
),
|
|
81
|
+
},
|
|
82
|
+
"content": {
|
|
83
|
+
"type": "string",
|
|
84
|
+
"description": (
|
|
85
|
+
"The memory content (no more than a paragraph). "
|
|
86
|
+
"Required for 'create' and 'update'."
|
|
87
|
+
),
|
|
88
|
+
},
|
|
89
|
+
"memory_id": {
|
|
90
|
+
"type": "string",
|
|
91
|
+
"description": (
|
|
92
|
+
"ID of an existing memory. "
|
|
93
|
+
"Required for 'update' and 'delete'."
|
|
94
|
+
),
|
|
95
|
+
},
|
|
96
|
+
"scope": {
|
|
97
|
+
"type": "string",
|
|
98
|
+
"enum": ["project", "global"],
|
|
99
|
+
"description": (
|
|
100
|
+
"Where to store the memory. "
|
|
101
|
+
"'project' (default) stores in the current project, "
|
|
102
|
+
"'global' stores in ~/.crabcode/ for all projects."
|
|
103
|
+
),
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
"required": ["action"],
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async def get_prompt(self, **kwargs: Any) -> str:
|
|
110
|
+
return (
|
|
111
|
+
"Create, update, or delete persistent memories that survive across "
|
|
112
|
+
"conversations. Memories are automatically loaded into context at "
|
|
113
|
+
"the start of each session.\n\n"
|
|
114
|
+
"Actions:\n"
|
|
115
|
+
"- create: Store a new memory (requires title + content)\n"
|
|
116
|
+
"- update: Modify an existing memory (requires memory_id + title + content)\n"
|
|
117
|
+
"- delete: Remove a memory (requires memory_id)\n"
|
|
118
|
+
"- list: Show all stored memories\n\n"
|
|
119
|
+
"Scope:\n"
|
|
120
|
+
"- 'project' (default): Stored in .crabcode/memories.json in the project root\n"
|
|
121
|
+
"- 'global': Stored in ~/.crabcode/memories.json, available across all projects\n\n"
|
|
122
|
+
"Guidelines:\n"
|
|
123
|
+
"- Only create memories when the user explicitly asks to remember something.\n"
|
|
124
|
+
"- If the user contradicts an existing memory, DELETE the old one rather than updating.\n"
|
|
125
|
+
"- Keep memories concise — no more than a paragraph each.\n"
|
|
126
|
+
"- Use 'project' scope for project-specific conventions and preferences.\n"
|
|
127
|
+
"- Use 'global' scope for general user preferences that apply everywhere."
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
async def validate_input(self, tool_input: dict[str, Any]) -> str | None:
|
|
131
|
+
action = tool_input.get("action")
|
|
132
|
+
if action not in ("create", "update", "delete", "list"):
|
|
133
|
+
return "action must be one of: create, update, delete, list"
|
|
134
|
+
|
|
135
|
+
if action == "create":
|
|
136
|
+
if not tool_input.get("title"):
|
|
137
|
+
return "title is required for 'create'"
|
|
138
|
+
if not tool_input.get("content"):
|
|
139
|
+
return "content is required for 'create'"
|
|
140
|
+
|
|
141
|
+
if action == "update":
|
|
142
|
+
if not tool_input.get("memory_id"):
|
|
143
|
+
return "memory_id is required for 'update'"
|
|
144
|
+
if not tool_input.get("title") and not tool_input.get("content"):
|
|
145
|
+
return "title or content is required for 'update'"
|
|
146
|
+
|
|
147
|
+
if action == "delete":
|
|
148
|
+
if not tool_input.get("memory_id"):
|
|
149
|
+
return "memory_id is required for 'delete'"
|
|
150
|
+
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
async def call(
|
|
154
|
+
self,
|
|
155
|
+
tool_input: dict[str, Any],
|
|
156
|
+
context: ToolContext,
|
|
157
|
+
) -> ToolResult:
|
|
158
|
+
action = tool_input["action"]
|
|
159
|
+
scope = tool_input.get("scope", "project")
|
|
160
|
+
path = _memory_path(scope, context.cwd)
|
|
161
|
+
memories = _load_memories(path)
|
|
162
|
+
|
|
163
|
+
if action == "create":
|
|
164
|
+
entry = {
|
|
165
|
+
"id": uuid.uuid4().hex[:12],
|
|
166
|
+
"title": tool_input["title"],
|
|
167
|
+
"content": tool_input["content"],
|
|
168
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
169
|
+
}
|
|
170
|
+
memories.append(entry)
|
|
171
|
+
_save_memories(path, memories)
|
|
172
|
+
return ToolResult(
|
|
173
|
+
data=entry,
|
|
174
|
+
result_for_model=(
|
|
175
|
+
f"Memory created (id: {entry['id']}, scope: {scope}):\n"
|
|
176
|
+
f" {entry['title']}"
|
|
177
|
+
),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
if action == "update":
|
|
181
|
+
memory_id = tool_input["memory_id"]
|
|
182
|
+
target = next((m for m in memories if m["id"] == memory_id), None)
|
|
183
|
+
if not target:
|
|
184
|
+
return ToolResult(
|
|
185
|
+
result_for_model=f"Error: memory '{memory_id}' not found in {scope} scope.",
|
|
186
|
+
is_error=True,
|
|
187
|
+
)
|
|
188
|
+
if tool_input.get("title"):
|
|
189
|
+
target["title"] = tool_input["title"]
|
|
190
|
+
if tool_input.get("content"):
|
|
191
|
+
target["content"] = tool_input["content"]
|
|
192
|
+
target["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
193
|
+
_save_memories(path, memories)
|
|
194
|
+
return ToolResult(
|
|
195
|
+
data=target,
|
|
196
|
+
result_for_model=(
|
|
197
|
+
f"Memory updated (id: {memory_id}, scope: {scope}):\n"
|
|
198
|
+
f" {target['title']}"
|
|
199
|
+
),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if action == "delete":
|
|
203
|
+
memory_id = tool_input["memory_id"]
|
|
204
|
+
before = len(memories)
|
|
205
|
+
memories = [m for m in memories if m["id"] != memory_id]
|
|
206
|
+
if len(memories) == before:
|
|
207
|
+
return ToolResult(
|
|
208
|
+
result_for_model=f"Error: memory '{memory_id}' not found in {scope} scope.",
|
|
209
|
+
is_error=True,
|
|
210
|
+
)
|
|
211
|
+
_save_memories(path, memories)
|
|
212
|
+
return ToolResult(
|
|
213
|
+
result_for_model=f"Memory deleted (id: {memory_id}, scope: {scope}).",
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
if action == "list":
|
|
217
|
+
all_memories = load_all_memories(context.cwd)
|
|
218
|
+
if not all_memories:
|
|
219
|
+
return ToolResult(
|
|
220
|
+
result_for_model="No memories stored.",
|
|
221
|
+
)
|
|
222
|
+
lines = []
|
|
223
|
+
for m in all_memories:
|
|
224
|
+
s = m.pop("_scope", "?")
|
|
225
|
+
lines.append(
|
|
226
|
+
f"- [{s}] {m['id']}: {m['title']}\n {m['content']}"
|
|
227
|
+
)
|
|
228
|
+
return ToolResult(
|
|
229
|
+
data={"count": len(all_memories)},
|
|
230
|
+
result_for_model=f"{len(all_memories)} memory(ies):\n" + "\n".join(lines),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
return ToolResult(
|
|
234
|
+
result_for_model=f"Unknown action: {action}",
|
|
235
|
+
is_error=True,
|
|
236
|
+
)
|