mycode-coding-agent 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 (121) hide show
  1. mycode/__init__.py +0 -0
  2. mycode/adapters/__init__.py +21 -0
  3. mycode/adapters/jsonl.py +692 -0
  4. mycode/agent/__init__.py +25 -0
  5. mycode/agent/events.py +111 -0
  6. mycode/agent/outcome.py +103 -0
  7. mycode/agent/progress.py +373 -0
  8. mycode/agent/runner.py +1481 -0
  9. mycode/application/__init__.py +38 -0
  10. mycode/application/agent_session.py +367 -0
  11. mycode/application/events.py +59 -0
  12. mycode/application/runtime.py +211 -0
  13. mycode/application/sessions.py +180 -0
  14. mycode/cli.py +840 -0
  15. mycode/config.py +355 -0
  16. mycode/context/__init__.py +1 -0
  17. mycode/context/artifacts.py +672 -0
  18. mycode/context/budget.py +752 -0
  19. mycode/context/builder.py +112 -0
  20. mycode/context/compact.py +795 -0
  21. mycode/context/tool_result_format.py +199 -0
  22. mycode/context/tool_result_retention.py +261 -0
  23. mycode/conversation.py +78 -0
  24. mycode/error_handling.py +481 -0
  25. mycode/event_format.py +147 -0
  26. mycode/instructions.py +285 -0
  27. mycode/llm.py +771 -0
  28. mycode/mcp/__init__.py +41 -0
  29. mycode/mcp/client.py +44 -0
  30. mycode/mcp/config.py +207 -0
  31. mycode/mcp/errors.py +302 -0
  32. mycode/mcp/manager.py +339 -0
  33. mycode/mcp/models.py +20 -0
  34. mycode/mcp/result_adapter.py +58 -0
  35. mycode/mcp/tool_adapter.py +145 -0
  36. mycode/mcp/trust.py +313 -0
  37. mycode/memory.py +570 -0
  38. mycode/memory_context.py +245 -0
  39. mycode/messages.py +63 -0
  40. mycode/observability.py +28 -0
  41. mycode/permissions.py +262 -0
  42. mycode/persistence/__init__.py +1 -0
  43. mycode/persistence/filesystem.py +291 -0
  44. mycode/persistence/project_storage.py +208 -0
  45. mycode/persistence/session_lock.py +138 -0
  46. mycode/persistence/session_store.py +503 -0
  47. mycode/presentation/__init__.py +1 -0
  48. mycode/presentation/cli/__init__.py +14 -0
  49. mycode/presentation/cli/confirmer.py +116 -0
  50. mycode/presentation/cli/mcp_trust.py +61 -0
  51. mycode/presentation/cli/presenter.py +320 -0
  52. mycode/presentation/cli/session_menu.py +146 -0
  53. mycode/presentation/cli/subagent_observer.py +124 -0
  54. mycode/presentation/command_format.py +90 -0
  55. mycode/presentation/commands.py +95 -0
  56. mycode/presentation/tui/__init__.py +6 -0
  57. mycode/presentation/tui/app.py +1351 -0
  58. mycode/presentation/tui/interactions.py +253 -0
  59. mycode/presentation/tui/presenter.py +266 -0
  60. mycode/presentation/tui/screens.py +305 -0
  61. mycode/presentation/tui/widgets.py +214 -0
  62. mycode/project.py +22 -0
  63. mycode/prompts.py +181 -0
  64. mycode/reasoning.py +40 -0
  65. mycode/session.py +86 -0
  66. mycode/skills/__init__.py +27 -0
  67. mycode/skills/builtin/database-recovery/SKILL.md +138 -0
  68. mycode/skills/builtin/database-recovery/references/sqlite.md +235 -0
  69. mycode/skills/registry.py +295 -0
  70. mycode/skills/state.py +68 -0
  71. mycode/subagents/__init__.py +1 -0
  72. mycode/subagents/audit.py +212 -0
  73. mycode/subagents/concurrency.py +124 -0
  74. mycode/subagents/contracts.py +421 -0
  75. mycode/subagents/delegate.py +80 -0
  76. mycode/subagents/delegation.py +128 -0
  77. mycode/subagents/lifecycle.py +86 -0
  78. mycode/subagents/limits.py +7 -0
  79. mycode/subagents/observability.py +150 -0
  80. mycode/subagents/persistence.py +152 -0
  81. mycode/subagents/profiles.py +184 -0
  82. mycode/subagents/prompts.py +67 -0
  83. mycode/subagents/results.py +178 -0
  84. mycode/subagents/runtime.py +528 -0
  85. mycode/subagents/snapshots.py +211 -0
  86. mycode/subagents/tool_batch.py +260 -0
  87. mycode/tools/__init__.py +81 -0
  88. mycode/tools/base.py +222 -0
  89. mycode/tools/bounds.py +14 -0
  90. mycode/tools/command_executor.py +167 -0
  91. mycode/tools/command_output.py +166 -0
  92. mycode/tools/command_risk.py +596 -0
  93. mycode/tools/defaults.py +59 -0
  94. mycode/tools/edit_file.py +524 -0
  95. mycode/tools/file_mutation.py +30 -0
  96. mycode/tools/glob.py +247 -0
  97. mycode/tools/grep.py +324 -0
  98. mycode/tools/ignore.py +122 -0
  99. mycode/tools/inspect_changes.py +269 -0
  100. mycode/tools/load_skill.py +92 -0
  101. mycode/tools/memory.py +264 -0
  102. mycode/tools/path_permissions.py +78 -0
  103. mycode/tools/patterns.py +48 -0
  104. mycode/tools/permission_metadata.py +27 -0
  105. mycode/tools/process_tree.py +166 -0
  106. mycode/tools/read_file.py +242 -0
  107. mycode/tools/read_skill_resource.py +93 -0
  108. mycode/tools/registry.py +279 -0
  109. mycode/tools/run_command.py +237 -0
  110. mycode/tools/run_skill_script.py +206 -0
  111. mycode/tools/run_validation.py +107 -0
  112. mycode/tools/submit_result.py +93 -0
  113. mycode/tools/text.py +15 -0
  114. mycode/tools/validation_command.py +377 -0
  115. mycode/tools/workspace.py +33 -0
  116. mycode/tools/write_file.py +169 -0
  117. mycode_coding_agent-0.1.0.dist-info/METADATA +244 -0
  118. mycode_coding_agent-0.1.0.dist-info/RECORD +121 -0
  119. mycode_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  120. mycode_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
  121. mycode_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,269 @@
1
+ from pathlib import Path
2
+ import re
3
+ import shutil
4
+ from typing import Literal, Self
5
+
6
+ from pydantic import Field, field_validator, model_validator
7
+
8
+ from mycode.permissions import PermissionChecker, PermissionDecision, PermissionRequest
9
+ from mycode.tools.base import PydanticTool, ToolArgs, ToolResult
10
+ from mycode.tools.command_executor import CommandExecutionArgs, execute_command
11
+ from mycode.tools.ignore import is_sensitive_path
12
+ from mycode.tools.path_permissions import PathPermissionPolicy
13
+ from mycode.tools.permission_metadata import with_permission_metadata
14
+ from mycode.tools.workspace import Workspace, WorkspacePathError
15
+
16
+
17
+ DEFAULT_INSPECT_TIMEOUT_SECONDS = 30.0
18
+ DEFAULT_INSPECT_MAX_OUTPUT_CHARS = 20000
19
+ MAX_INSPECT_OUTPUT_CHARS = 100000
20
+ MAX_INSPECT_PATHS = 20
21
+ SAFE_REF_PATTERN = re.compile(
22
+ r"(?:HEAD|[A-Za-z0-9][A-Za-z0-9._/-]*)(?:[~^][0-9]+)?"
23
+ )
24
+
25
+ InspectChangesAction = Literal["status", "diff"]
26
+
27
+
28
+ class InspectChangesArgs(ToolArgs):
29
+ action: InspectChangesAction
30
+ paths: list[str] = Field(default_factory=list, max_length=MAX_INSPECT_PATHS)
31
+ staged: bool = False
32
+ base_ref: str | None = Field(default=None, max_length=200)
33
+ max_output_chars: int = Field(
34
+ default=DEFAULT_INSPECT_MAX_OUTPUT_CHARS,
35
+ ge=0,
36
+ le=MAX_INSPECT_OUTPUT_CHARS,
37
+ )
38
+
39
+ @field_validator("paths")
40
+ @classmethod
41
+ def validate_plain_paths(cls, values: list[str]) -> list[str]:
42
+ normalized: list[str] = []
43
+ for value in values:
44
+ path = value.strip()
45
+ if path == "":
46
+ raise ValueError("inspect_changes paths must not be blank.")
47
+ if len(path) > 500:
48
+ raise ValueError("inspect_changes paths must not exceed 500 characters.")
49
+ if path.startswith(":") or any(marker in path for marker in ("*", "?", "[", "]", "\x00")):
50
+ raise ValueError("inspect_changes paths must be literal paths, not pathspecs.")
51
+ normalized.append(path)
52
+ return normalized
53
+
54
+ @field_validator("base_ref")
55
+ @classmethod
56
+ def validate_base_ref(cls, value: str | None) -> str | None:
57
+ if value is None:
58
+ return None
59
+ normalized = value.strip()
60
+ if (
61
+ normalized == ""
62
+ or normalized.startswith("-")
63
+ or SAFE_REF_PATTERN.fullmatch(normalized) is None
64
+ ):
65
+ raise ValueError("base_ref must be a plain Git revision, not an option.")
66
+ return normalized
67
+
68
+ @model_validator(mode="after")
69
+ def action_arguments_must_match(self) -> Self:
70
+ if self.action == "status" and (
71
+ self.paths or self.staged or self.base_ref is not None
72
+ ):
73
+ raise ValueError(
74
+ "status does not accept paths, staged, or base_ref arguments."
75
+ )
76
+ if self.action == "diff" and not self.paths:
77
+ raise ValueError("diff requires at least one explicit path.")
78
+ return self
79
+
80
+
81
+ class InspectChangesTool(PydanticTool[InspectChangesArgs]):
82
+ name = "inspect_changes"
83
+ description = (
84
+ "Inspect Git status or a bounded diff for explicit non-sensitive workspace paths."
85
+ )
86
+ args_model = InspectChangesArgs
87
+ capability = "read"
88
+ risk = "low"
89
+ concurrency_safe = True
90
+
91
+ def __init__(self, workspace: Workspace) -> None:
92
+ self.workspace = workspace
93
+
94
+ def build_permission_request(self, args: InspectChangesArgs) -> PermissionRequest:
95
+ target = "repository status" if args.action == "status" else ", ".join(args.paths)
96
+ return PermissionRequest(
97
+ tool_name=self.name,
98
+ capability=self.capability,
99
+ action=f"{self.name}.{args.action}",
100
+ target=target,
101
+ arguments=args.model_dump(),
102
+ description="Inspect repository changes without arbitrary command execution.",
103
+ )
104
+
105
+ def check_permission(
106
+ self,
107
+ args: InspectChangesArgs,
108
+ permission_checker: PermissionChecker,
109
+ ) -> tuple[PermissionRequest, PermissionDecision]:
110
+ request = self.build_permission_request(args)
111
+ resolved_paths: list[str] = []
112
+ for path in args.paths:
113
+ try:
114
+ resolved = self.workspace.resolve_path(path)
115
+ except WorkspacePathError:
116
+ return request, PermissionDecision.deny(
117
+ reason="outside_workspace",
118
+ message=f"inspect_changes path is outside workspace: {path}",
119
+ metadata={
120
+ "path": path,
121
+ "workspace_root": self.workspace.root.as_posix(),
122
+ "inspect_action": args.action,
123
+ },
124
+ )
125
+ if is_sensitive_path(resolved, self.workspace.root):
126
+ return request, PermissionDecision.deny(
127
+ reason="sensitive_path",
128
+ message=f"inspect_changes refuses sensitive path content: {path}",
129
+ metadata={
130
+ "path": path,
131
+ "resolved_path": resolved.as_posix(),
132
+ "workspace_root": self.workspace.root.as_posix(),
133
+ "inspect_action": args.action,
134
+ },
135
+ )
136
+
137
+ path_decision = PathPermissionPolicy(self.workspace).check_path(request, path)
138
+ if path_decision.status != "allow":
139
+ return request, with_permission_metadata(
140
+ path_decision,
141
+ {
142
+ "inspect_action": args.action,
143
+ "resolved_paths": [*resolved_paths, resolved.as_posix()],
144
+ "staged": args.staged,
145
+ "base_ref": args.base_ref,
146
+ },
147
+ )
148
+ resolved_paths.append(resolved.as_posix())
149
+
150
+ decision = permission_checker.check(request, self.get_permission_profile())
151
+ if decision.status != "allow":
152
+ return request, decision
153
+ return request, PermissionDecision.allow(
154
+ message=decision.message,
155
+ metadata={
156
+ **decision.metadata,
157
+ "workspace_root": self.workspace.root.as_posix(),
158
+ "resolved_cwd": self.workspace.root.as_posix(),
159
+ "inspect_action": args.action,
160
+ "resolved_paths": resolved_paths,
161
+ "staged": args.staged,
162
+ "base_ref": args.base_ref,
163
+ },
164
+ )
165
+
166
+ def run_authorized(
167
+ self,
168
+ args: InspectChangesArgs,
169
+ decision: PermissionDecision,
170
+ ) -> ToolResult:
171
+ try:
172
+ return self._run_authorized(args, decision)
173
+ except Exception as error:
174
+ return ToolResult.failure(
175
+ error=f"Tool execution failed: {error}",
176
+ metadata={
177
+ **decision.metadata,
178
+ "exception_type": type(error).__name__,
179
+ },
180
+ )
181
+
182
+ def _run_authorized(
183
+ self,
184
+ args: InspectChangesArgs,
185
+ decision: PermissionDecision,
186
+ ) -> ToolResult:
187
+ git_executable = shutil.which("git")
188
+ if git_executable is None:
189
+ return ToolResult.failure(
190
+ error="Git executable was not found.",
191
+ metadata={**decision.metadata, "reason": "git_not_found"},
192
+ )
193
+ resolved_git = Path(git_executable).resolve(strict=False)
194
+ if resolved_git.is_relative_to(self.workspace.root):
195
+ return ToolResult.failure(
196
+ error="Refusing to execute a workspace-local Git executable.",
197
+ metadata={
198
+ **decision.metadata,
199
+ "reason": "workspace_local_executable",
200
+ "git_executable": resolved_git.as_posix(),
201
+ },
202
+ )
203
+
204
+ command = self._build_command(args, resolved_git)
205
+ safe_git_flags = [
206
+ "--no-optional-locks",
207
+ "-c core.fsmonitor=false",
208
+ "--no-pager",
209
+ "--ignore-submodules=all",
210
+ ]
211
+ if args.action == "diff":
212
+ safe_git_flags.extend(["--no-ext-diff", "--no-textconv"])
213
+ return execute_command(
214
+ args=CommandExecutionArgs(
215
+ command=command,
216
+ timeout_seconds=DEFAULT_INSPECT_TIMEOUT_SECONDS,
217
+ max_output_chars=args.max_output_chars,
218
+ ),
219
+ cwd=self.workspace.root,
220
+ permission_metadata={
221
+ **decision.metadata,
222
+ "command": command,
223
+ "safe_git_flags": safe_git_flags,
224
+ },
225
+ permission_status=decision.status,
226
+ )
227
+
228
+ def _run(self, args: InspectChangesArgs) -> ToolResult:
229
+ return ToolResult.failure(
230
+ error="inspect_changes must be run through ToolRegistry.run_tool().",
231
+ metadata={"action": args.action, "reason": "permission_required"},
232
+ )
233
+
234
+ def _build_command(self, args: InspectChangesArgs, git_executable: Path) -> list[str]:
235
+ command = [
236
+ git_executable.as_posix(),
237
+ "--no-optional-locks",
238
+ "-c",
239
+ "core.fsmonitor=false",
240
+ "--no-pager",
241
+ ]
242
+ if args.action == "status":
243
+ return [
244
+ *command,
245
+ "status",
246
+ "--short",
247
+ "--branch",
248
+ "--untracked-files=all",
249
+ "--ignore-submodules=all",
250
+ ]
251
+
252
+ command.extend(
253
+ [
254
+ "diff",
255
+ "--no-ext-diff",
256
+ "--no-textconv",
257
+ "--ignore-submodules=all",
258
+ ]
259
+ )
260
+ if args.staged:
261
+ command.append("--cached")
262
+ if args.base_ref is not None:
263
+ command.append(args.base_ref)
264
+ resolved_paths = [
265
+ self.workspace.resolve_path(path).relative_to(self.workspace.root).as_posix()
266
+ for path in args.paths
267
+ ]
268
+ command.extend(["--", *resolved_paths])
269
+ return command
@@ -0,0 +1,92 @@
1
+ from pydantic import Field
2
+
3
+ from mycode.skills import (
4
+ MAX_ACTIVE_SKILLS,
5
+ ActiveSkillLimitError,
6
+ ActiveSkillState,
7
+ SkillNotFoundError,
8
+ SkillRegistry,
9
+ )
10
+ from mycode.tools.base import PydanticTool, ToolArgs, ToolResult
11
+
12
+
13
+ class LoadSkillArgs(ToolArgs):
14
+ name: str = Field(min_length=1)
15
+
16
+
17
+ class LoadSkillTool(PydanticTool[LoadSkillArgs]):
18
+ name = "load_skill"
19
+ description = (
20
+ "加载一个与当前任务相关的 Skill。加载后的完整 Skill 指导会从下一轮模型调用"
21
+ "开始可见;如果后续操作依赖该 Skill,请先加载 Skill,再根据加载后的指导继续执行。"
22
+ )
23
+ args_model = LoadSkillArgs
24
+ capability = "control"
25
+ risk = "low"
26
+
27
+ def __init__(self, registry: SkillRegistry, state: ActiveSkillState) -> None:
28
+ self.registry = registry
29
+ self.state = state
30
+
31
+ def _run(self, args: LoadSkillArgs) -> ToolResult:
32
+ try:
33
+ skill = self.registry.require(args.name)
34
+ except SkillNotFoundError:
35
+ return ToolResult.failure(
36
+ error=f"Skill not found: {args.name}",
37
+ metadata={"skill_name": args.name, "already_active": False},
38
+ )
39
+ if self.state.is_active(skill.name):
40
+ return ToolResult.success(
41
+ content=f"Skill '{skill.name}' 已经激活。",
42
+ metadata={
43
+ "skill_name": skill.name,
44
+ "skill_source": skill.source,
45
+ "already_active": True,
46
+ },
47
+ )
48
+ if len(self.state.get_active()) >= MAX_ACTIVE_SKILLS:
49
+ return ToolResult.failure(
50
+ error=f"最多只能同时激活 {MAX_ACTIVE_SKILLS} 个 Skill。",
51
+ metadata={
52
+ "skill_name": skill.name,
53
+ "skill_source": skill.source,
54
+ "already_active": False,
55
+ "active_skill_count": len(self.state.get_active()),
56
+ "max_active_skills": MAX_ACTIVE_SKILLS,
57
+ },
58
+ )
59
+ try:
60
+ instructions = self.registry.load_instructions(skill.name)
61
+ self.state.activate(skill, instructions)
62
+ except ActiveSkillLimitError as error:
63
+ return ToolResult.failure(
64
+ error=str(error),
65
+ metadata={
66
+ "skill_name": skill.name,
67
+ "skill_source": skill.source,
68
+ "already_active": False,
69
+ "active_skill_count": len(self.state.get_active()),
70
+ "max_active_skills": MAX_ACTIVE_SKILLS,
71
+ },
72
+ )
73
+ except ValueError as error:
74
+ return ToolResult.failure(
75
+ error=f"无法加载 Skill '{skill.name}':{error}",
76
+ metadata={
77
+ "skill_name": skill.name,
78
+ "skill_source": skill.source,
79
+ "already_active": False,
80
+ },
81
+ )
82
+ metadata = {
83
+ "skill_name": skill.name,
84
+ "skill_source": skill.source,
85
+ "already_active": False,
86
+ }
87
+ return ToolResult.success(
88
+ content=(
89
+ f"Skill '{skill.name}' 已为当前任务激活。完整指导将从下一轮模型调用开始可见。"
90
+ ),
91
+ metadata=metadata,
92
+ )
mycode/tools/memory.py ADDED
@@ -0,0 +1,264 @@
1
+ from pydantic import field_validator
2
+
3
+ from mycode.memory import (
4
+ MemoryKind,
5
+ MemoryScope,
6
+ MemoryStore,
7
+ SensitiveMemoryError,
8
+ validate_memory_content,
9
+ validate_memory_key,
10
+ )
11
+ from mycode.permissions import PermissionChecker, PermissionDecision, PermissionRequest
12
+ from mycode.tools.base import PydanticTool, ToolArgs, ToolResult
13
+ from mycode.tools.permission_metadata import with_permission_metadata
14
+
15
+
16
+ class ListMemoriesArgs(ToolArgs):
17
+ scope: MemoryScope | None = None
18
+
19
+
20
+ class SaveMemoryArgs(ToolArgs):
21
+ scope: MemoryScope
22
+ kind: MemoryKind
23
+ key: str
24
+ content: str
25
+
26
+ @field_validator("key")
27
+ @classmethod
28
+ def validate_key(cls, value: str) -> str:
29
+ return validate_memory_key(value)
30
+
31
+ @field_validator("content")
32
+ @classmethod
33
+ def validate_content(cls, value: str) -> str:
34
+ try:
35
+ return validate_memory_content(value, max_chars=2000)
36
+ except SensitiveMemoryError as error:
37
+ raise ValueError(str(error)) from error
38
+
39
+
40
+ class DeleteMemoryArgs(ToolArgs):
41
+ scope: MemoryScope
42
+ key: str
43
+
44
+ @field_validator("key")
45
+ @classmethod
46
+ def validate_key(cls, value: str) -> str:
47
+ return validate_memory_key(value)
48
+
49
+
50
+ class ListMemoriesTool(PydanticTool[ListMemoriesArgs]):
51
+ name = "list_memories"
52
+ description = "List safe long-term memories for the current user or project."
53
+ args_model = ListMemoriesArgs
54
+ capability = "read"
55
+ risk = "low"
56
+ concurrency_safe = True
57
+
58
+ def __init__(self, store: MemoryStore) -> None:
59
+ self.store = store
60
+
61
+ def _run(self, args: ListMemoriesArgs) -> ToolResult:
62
+ entries = self.store.list_entries(args.scope)
63
+ issues = self.store.list_issues(args.scope)
64
+ lines: list[str] = []
65
+ for entry in entries:
66
+ lines.extend(
67
+ [
68
+ f"[{entry.scope}/{entry.kind}] {entry.key}",
69
+ entry.content,
70
+ "",
71
+ ]
72
+ )
73
+ if issues:
74
+ lines.append("WITHHELD_OR_INVALID")
75
+ lines.extend(issue.display for issue in issues)
76
+ content = "\n".join(lines).rstrip()
77
+ if content == "":
78
+ content = "No long-term memories found."
79
+ return ToolResult.success(
80
+ content=content,
81
+ metadata={
82
+ "scope": args.scope or "all",
83
+ "memory_count": len(entries),
84
+ "issue_count": len(issues),
85
+ },
86
+ )
87
+
88
+
89
+ class SaveMemoryTool(PydanticTool[SaveMemoryArgs]):
90
+ name = "save_memory"
91
+ description = (
92
+ "Create or correct one user-level or project-level long-term memory. "
93
+ "Only use when the user explicitly asks to remember the information."
94
+ )
95
+ args_model = SaveMemoryArgs
96
+ capability = "write"
97
+ risk = "medium"
98
+
99
+ def __init__(self, store: MemoryStore) -> None:
100
+ self.store = store
101
+
102
+ def build_permission_request(self, args: SaveMemoryArgs) -> PermissionRequest:
103
+ return PermissionRequest(
104
+ tool_name=self.name,
105
+ capability=self.capability,
106
+ action=f"save {args.scope} memory",
107
+ target=str(self.store.path_for_scope(args.scope)),
108
+ arguments={
109
+ "scope": args.scope,
110
+ "kind": args.kind,
111
+ "key": args.key,
112
+ "content_chars": len(args.content),
113
+ },
114
+ description="Save one reviewed long-term memory.",
115
+ )
116
+
117
+ def check_permission(
118
+ self,
119
+ args: SaveMemoryArgs,
120
+ permission_checker: PermissionChecker,
121
+ ) -> tuple[PermissionRequest, PermissionDecision]:
122
+ request = self.build_permission_request(args)
123
+ decision = permission_checker.check(request, self.get_permission_profile())
124
+ return request, with_permission_metadata(
125
+ decision,
126
+ {
127
+ "memory_scope": args.scope,
128
+ "memory_kind": args.kind,
129
+ "memory_key": args.key,
130
+ "memory_content": args.content,
131
+ "memory_path": str(self.store.path_for_scope(args.scope)),
132
+ },
133
+ )
134
+
135
+ def run_authorized(
136
+ self,
137
+ args: SaveMemoryArgs,
138
+ decision: PermissionDecision,
139
+ ) -> ToolResult:
140
+ try:
141
+ result = self.store.save(
142
+ scope=args.scope,
143
+ kind=args.kind,
144
+ key=args.key,
145
+ content=args.content,
146
+ )
147
+ except Exception as error:
148
+ return ToolResult.failure(
149
+ error=f"Memory write failed: {error}",
150
+ metadata={
151
+ **_safe_permission_metadata(decision),
152
+ "memory_scope": args.scope,
153
+ "memory_kind": args.kind,
154
+ "memory_key": args.key,
155
+ "exception_type": type(error).__name__,
156
+ },
157
+ )
158
+ return ToolResult.success(
159
+ content=(
160
+ f"Memory {result.action}: "
161
+ f"[{args.scope}/{args.kind}] {args.key}"
162
+ ),
163
+ metadata={
164
+ **_safe_permission_metadata(decision),
165
+ "memory_scope": args.scope,
166
+ "memory_kind": args.kind,
167
+ "memory_key": args.key,
168
+ "memory_action": result.action,
169
+ "memory_path": str(result.path),
170
+ },
171
+ )
172
+
173
+ def _run(self, args: SaveMemoryArgs) -> ToolResult:
174
+ return ToolResult.failure(
175
+ error="save_memory must be run through ToolRegistry.run_tool().",
176
+ metadata={"memory_key": args.key, "reason": "permission_required"},
177
+ )
178
+
179
+
180
+ class DeleteMemoryTool(PydanticTool[DeleteMemoryArgs]):
181
+ name = "delete_memory"
182
+ description = "Delete one user-level or project-level long-term memory by key."
183
+ args_model = DeleteMemoryArgs
184
+ capability = "write"
185
+ risk = "medium"
186
+
187
+ def __init__(self, store: MemoryStore) -> None:
188
+ self.store = store
189
+
190
+ def build_permission_request(self, args: DeleteMemoryArgs) -> PermissionRequest:
191
+ return PermissionRequest(
192
+ tool_name=self.name,
193
+ capability=self.capability,
194
+ action=f"delete {args.scope} memory",
195
+ target=str(self.store.path_for_scope(args.scope)),
196
+ arguments={"scope": args.scope, "key": args.key},
197
+ description="Delete one long-term memory.",
198
+ )
199
+
200
+ def check_permission(
201
+ self,
202
+ args: DeleteMemoryArgs,
203
+ permission_checker: PermissionChecker,
204
+ ) -> tuple[PermissionRequest, PermissionDecision]:
205
+ request = self.build_permission_request(args)
206
+ decision = permission_checker.check(request, self.get_permission_profile())
207
+ return request, with_permission_metadata(
208
+ decision,
209
+ {
210
+ "memory_scope": args.scope,
211
+ "memory_key": args.key,
212
+ "memory_path": str(self.store.path_for_scope(args.scope)),
213
+ },
214
+ )
215
+
216
+ def run_authorized(
217
+ self,
218
+ args: DeleteMemoryArgs,
219
+ decision: PermissionDecision,
220
+ ) -> ToolResult:
221
+ try:
222
+ deleted = self.store.delete(scope=args.scope, key=args.key)
223
+ except Exception as error:
224
+ return ToolResult.failure(
225
+ error=f"Memory delete failed: {error}",
226
+ metadata={
227
+ **_safe_permission_metadata(decision),
228
+ "memory_scope": args.scope,
229
+ "memory_key": args.key,
230
+ "exception_type": type(error).__name__,
231
+ },
232
+ )
233
+ if not deleted:
234
+ return ToolResult.failure(
235
+ error=f"Memory not found: [{args.scope}] {args.key}",
236
+ metadata={
237
+ **_safe_permission_metadata(decision),
238
+ "memory_scope": args.scope,
239
+ "memory_key": args.key,
240
+ },
241
+ )
242
+ return ToolResult.success(
243
+ content=f"Memory deleted: [{args.scope}] {args.key}",
244
+ metadata={
245
+ **_safe_permission_metadata(decision),
246
+ "memory_scope": args.scope,
247
+ "memory_key": args.key,
248
+ "memory_action": "deleted",
249
+ },
250
+ )
251
+
252
+ def _run(self, args: DeleteMemoryArgs) -> ToolResult:
253
+ return ToolResult.failure(
254
+ error="delete_memory must be run through ToolRegistry.run_tool().",
255
+ metadata={"memory_key": args.key, "reason": "permission_required"},
256
+ )
257
+
258
+
259
+ def _safe_permission_metadata(decision: PermissionDecision) -> dict[str, object]:
260
+ return {
261
+ key: value
262
+ for key, value in decision.metadata.items()
263
+ if key != "memory_content"
264
+ }
@@ -0,0 +1,78 @@
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+
4
+ from mycode.permissions import PermissionDecision, PermissionRequest
5
+ from mycode.tools.ignore import is_ignored_path, is_sensitive_path
6
+ from mycode.tools.workspace import Workspace, WorkspacePathError
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class PathPermissionPolicy:
11
+ workspace: Workspace
12
+
13
+ def check_path(
14
+ self,
15
+ request: PermissionRequest,
16
+ path: str | Path,
17
+ ) -> PermissionDecision:
18
+ resolved_path = _resolve_candidate_path(self.workspace, path)
19
+ metadata = _build_metadata(
20
+ request=request,
21
+ path=path,
22
+ resolved_path=resolved_path,
23
+ workspace_root=self.workspace.root,
24
+ )
25
+
26
+ try:
27
+ self.workspace.resolve_path(path)
28
+ except WorkspacePathError:
29
+ return PermissionDecision.ask(
30
+ reason="outside_workspace",
31
+ message=f"Path outside workspace requires confirmation: {path}",
32
+ metadata={**metadata, "path_scope": "outside_workspace"},
33
+ )
34
+
35
+ if is_sensitive_path(resolved_path, self.workspace.root):
36
+ return PermissionDecision.ask(
37
+ reason="sensitive_path",
38
+ message=f"Sensitive path requires confirmation: {path}",
39
+ metadata={**metadata, "path_scope": "sensitive_path"},
40
+ )
41
+
42
+ if is_ignored_path(resolved_path, self.workspace.root):
43
+ return PermissionDecision.ask(
44
+ reason="ignored_path",
45
+ message=f"Ignored path requires confirmation: {path}",
46
+ metadata={**metadata, "path_scope": "ignored_path"},
47
+ )
48
+
49
+ return PermissionDecision.allow(
50
+ message=f"Path inside workspace allowed: {path}",
51
+ metadata={**metadata, "path_scope": "inside_workspace"},
52
+ )
53
+
54
+
55
+ def _resolve_candidate_path(workspace: Workspace, path: str | Path) -> Path:
56
+ requested_path = Path(path)
57
+ if requested_path.is_absolute():
58
+ return requested_path.resolve(strict=False)
59
+
60
+ return (workspace.root / requested_path).resolve(strict=False)
61
+
62
+
63
+ def _build_metadata(
64
+ *,
65
+ request: PermissionRequest,
66
+ path: str | Path,
67
+ resolved_path: Path,
68
+ workspace_root: Path,
69
+ ) -> dict[str, object]:
70
+ return {
71
+ "tool_name": request.tool_name,
72
+ "capability": request.capability,
73
+ "action": request.action,
74
+ "target": request.target,
75
+ "path": str(path),
76
+ "resolved_path": resolved_path.as_posix(),
77
+ "workspace_root": workspace_root.as_posix(),
78
+ }