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.
Files changed (64) hide show
  1. crabcode-0.1.0.dist-info/METADATA +23 -0
  2. crabcode-0.1.0.dist-info/RECORD +64 -0
  3. crabcode-0.1.0.dist-info/WHEEL +4 -0
  4. crabcode-0.1.0.dist-info/entry_points.txt +2 -0
  5. crabcode_cli/__init__.py +3 -0
  6. crabcode_cli/app.py +142 -0
  7. crabcode_cli/banner.py +42 -0
  8. crabcode_cli/pipe.py +58 -0
  9. crabcode_cli/render/__init__.py +0 -0
  10. crabcode_cli/render/messages.py +19 -0
  11. crabcode_cli/repl.py +1263 -0
  12. crabcode_core/__init__.py +3 -0
  13. crabcode_core/api/__init__.py +6 -0
  14. crabcode_core/api/anthropic_adapter.py +250 -0
  15. crabcode_core/api/base.py +66 -0
  16. crabcode_core/api/codex_adapter.py +390 -0
  17. crabcode_core/api/openai_adapter.py +221 -0
  18. crabcode_core/api/registry.py +40 -0
  19. crabcode_core/compact/__init__.py +7 -0
  20. crabcode_core/compact/compact.py +150 -0
  21. crabcode_core/config/__init__.py +3 -0
  22. crabcode_core/config/claudemd.py +53 -0
  23. crabcode_core/config/manager.py +152 -0
  24. crabcode_core/events.py +392 -0
  25. crabcode_core/mcp/__init__.py +4 -0
  26. crabcode_core/mcp/client.py +163 -0
  27. crabcode_core/mcp/config.py +52 -0
  28. crabcode_core/permissions/__init__.py +3 -0
  29. crabcode_core/permissions/manager.py +106 -0
  30. crabcode_core/prompts/__init__.py +0 -0
  31. crabcode_core/prompts/context.py +209 -0
  32. crabcode_core/prompts/profile.py +66 -0
  33. crabcode_core/prompts/system.py +405 -0
  34. crabcode_core/prompts/templates.py +60 -0
  35. crabcode_core/query/__init__.py +0 -0
  36. crabcode_core/query/loop.py +442 -0
  37. crabcode_core/session/__init__.py +3 -0
  38. crabcode_core/session/storage.py +153 -0
  39. crabcode_core/skills/__init__.py +5 -0
  40. crabcode_core/skills/loader.py +147 -0
  41. crabcode_core/tools/__init__.py +29 -0
  42. crabcode_core/tools/agent.py +160 -0
  43. crabcode_core/tools/bash.py +127 -0
  44. crabcode_core/tools/diff_utils.py +120 -0
  45. crabcode_core/tools/file_edit.py +145 -0
  46. crabcode_core/tools/file_read.py +123 -0
  47. crabcode_core/tools/file_write.py +102 -0
  48. crabcode_core/tools/glob.py +109 -0
  49. crabcode_core/tools/grep.py +130 -0
  50. crabcode_core/tools/lint.py +368 -0
  51. crabcode_core/tools/memory.py +236 -0
  52. crabcode_core/tools/skill.py +112 -0
  53. crabcode_core/types/__init__.py +73 -0
  54. crabcode_core/types/config.py +103 -0
  55. crabcode_core/types/event.py +105 -0
  56. crabcode_core/types/message.py +177 -0
  57. crabcode_core/types/tool.py +138 -0
  58. crabcode_search/__init__.py +11 -0
  59. crabcode_search/background.py +371 -0
  60. crabcode_search/chunker.py +517 -0
  61. crabcode_search/embedder.py +447 -0
  62. crabcode_search/indexer.py +212 -0
  63. crabcode_search/store.py +222 -0
  64. 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
+ )