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.
- mycode/__init__.py +0 -0
- mycode/adapters/__init__.py +21 -0
- mycode/adapters/jsonl.py +692 -0
- mycode/agent/__init__.py +25 -0
- mycode/agent/events.py +111 -0
- mycode/agent/outcome.py +103 -0
- mycode/agent/progress.py +373 -0
- mycode/agent/runner.py +1481 -0
- mycode/application/__init__.py +38 -0
- mycode/application/agent_session.py +367 -0
- mycode/application/events.py +59 -0
- mycode/application/runtime.py +211 -0
- mycode/application/sessions.py +180 -0
- mycode/cli.py +840 -0
- mycode/config.py +355 -0
- mycode/context/__init__.py +1 -0
- mycode/context/artifacts.py +672 -0
- mycode/context/budget.py +752 -0
- mycode/context/builder.py +112 -0
- mycode/context/compact.py +795 -0
- mycode/context/tool_result_format.py +199 -0
- mycode/context/tool_result_retention.py +261 -0
- mycode/conversation.py +78 -0
- mycode/error_handling.py +481 -0
- mycode/event_format.py +147 -0
- mycode/instructions.py +285 -0
- mycode/llm.py +771 -0
- mycode/mcp/__init__.py +41 -0
- mycode/mcp/client.py +44 -0
- mycode/mcp/config.py +207 -0
- mycode/mcp/errors.py +302 -0
- mycode/mcp/manager.py +339 -0
- mycode/mcp/models.py +20 -0
- mycode/mcp/result_adapter.py +58 -0
- mycode/mcp/tool_adapter.py +145 -0
- mycode/mcp/trust.py +313 -0
- mycode/memory.py +570 -0
- mycode/memory_context.py +245 -0
- mycode/messages.py +63 -0
- mycode/observability.py +28 -0
- mycode/permissions.py +262 -0
- mycode/persistence/__init__.py +1 -0
- mycode/persistence/filesystem.py +291 -0
- mycode/persistence/project_storage.py +208 -0
- mycode/persistence/session_lock.py +138 -0
- mycode/persistence/session_store.py +503 -0
- mycode/presentation/__init__.py +1 -0
- mycode/presentation/cli/__init__.py +14 -0
- mycode/presentation/cli/confirmer.py +116 -0
- mycode/presentation/cli/mcp_trust.py +61 -0
- mycode/presentation/cli/presenter.py +320 -0
- mycode/presentation/cli/session_menu.py +146 -0
- mycode/presentation/cli/subagent_observer.py +124 -0
- mycode/presentation/command_format.py +90 -0
- mycode/presentation/commands.py +95 -0
- mycode/presentation/tui/__init__.py +6 -0
- mycode/presentation/tui/app.py +1351 -0
- mycode/presentation/tui/interactions.py +253 -0
- mycode/presentation/tui/presenter.py +266 -0
- mycode/presentation/tui/screens.py +305 -0
- mycode/presentation/tui/widgets.py +214 -0
- mycode/project.py +22 -0
- mycode/prompts.py +181 -0
- mycode/reasoning.py +40 -0
- mycode/session.py +86 -0
- mycode/skills/__init__.py +27 -0
- mycode/skills/builtin/database-recovery/SKILL.md +138 -0
- mycode/skills/builtin/database-recovery/references/sqlite.md +235 -0
- mycode/skills/registry.py +295 -0
- mycode/skills/state.py +68 -0
- mycode/subagents/__init__.py +1 -0
- mycode/subagents/audit.py +212 -0
- mycode/subagents/concurrency.py +124 -0
- mycode/subagents/contracts.py +421 -0
- mycode/subagents/delegate.py +80 -0
- mycode/subagents/delegation.py +128 -0
- mycode/subagents/lifecycle.py +86 -0
- mycode/subagents/limits.py +7 -0
- mycode/subagents/observability.py +150 -0
- mycode/subagents/persistence.py +152 -0
- mycode/subagents/profiles.py +184 -0
- mycode/subagents/prompts.py +67 -0
- mycode/subagents/results.py +178 -0
- mycode/subagents/runtime.py +528 -0
- mycode/subagents/snapshots.py +211 -0
- mycode/subagents/tool_batch.py +260 -0
- mycode/tools/__init__.py +81 -0
- mycode/tools/base.py +222 -0
- mycode/tools/bounds.py +14 -0
- mycode/tools/command_executor.py +167 -0
- mycode/tools/command_output.py +166 -0
- mycode/tools/command_risk.py +596 -0
- mycode/tools/defaults.py +59 -0
- mycode/tools/edit_file.py +524 -0
- mycode/tools/file_mutation.py +30 -0
- mycode/tools/glob.py +247 -0
- mycode/tools/grep.py +324 -0
- mycode/tools/ignore.py +122 -0
- mycode/tools/inspect_changes.py +269 -0
- mycode/tools/load_skill.py +92 -0
- mycode/tools/memory.py +264 -0
- mycode/tools/path_permissions.py +78 -0
- mycode/tools/patterns.py +48 -0
- mycode/tools/permission_metadata.py +27 -0
- mycode/tools/process_tree.py +166 -0
- mycode/tools/read_file.py +242 -0
- mycode/tools/read_skill_resource.py +93 -0
- mycode/tools/registry.py +279 -0
- mycode/tools/run_command.py +237 -0
- mycode/tools/run_skill_script.py +206 -0
- mycode/tools/run_validation.py +107 -0
- mycode/tools/submit_result.py +93 -0
- mycode/tools/text.py +15 -0
- mycode/tools/validation_command.py +377 -0
- mycode/tools/workspace.py +33 -0
- mycode/tools/write_file.py +169 -0
- mycode_coding_agent-0.1.0.dist-info/METADATA +244 -0
- mycode_coding_agent-0.1.0.dist-info/RECORD +121 -0
- mycode_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- mycode_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
- mycode_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
mycode/memory.py
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
from dataclasses import dataclass, field, replace
|
|
2
|
+
import hashlib
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import re
|
|
6
|
+
import tempfile
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from mycode.project import ProjectIdentity
|
|
10
|
+
from mycode.persistence.project_storage import project_directory_name
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
MemoryScope = Literal["user", "project"]
|
|
14
|
+
MemoryKind = Literal["preference", "fact", "experience"]
|
|
15
|
+
MemoryIssueCode = Literal[
|
|
16
|
+
"file_too_large",
|
|
17
|
+
"invalid_utf8",
|
|
18
|
+
"read_error",
|
|
19
|
+
"malformed_marker",
|
|
20
|
+
"missing_end_marker",
|
|
21
|
+
"invalid_heading",
|
|
22
|
+
"empty_content",
|
|
23
|
+
"duplicate_key",
|
|
24
|
+
"sensitive_content",
|
|
25
|
+
]
|
|
26
|
+
MemoryWriteAction = Literal["created", "updated"]
|
|
27
|
+
|
|
28
|
+
MEMORY_KINDS = frozenset({"preference", "fact", "experience"})
|
|
29
|
+
MEMORY_KEY_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]{0,79}$")
|
|
30
|
+
ENTRY_START_PATTERN = re.compile(
|
|
31
|
+
r"^<!-- mycode-memory:entry "
|
|
32
|
+
r"kind=(preference|fact|experience) "
|
|
33
|
+
r"key=([a-z0-9][a-z0-9._-]{0,79}) -->[ \t]*\r?\n",
|
|
34
|
+
re.MULTILINE,
|
|
35
|
+
)
|
|
36
|
+
ENTRY_MARKER_PREFIX_PATTERN = re.compile(
|
|
37
|
+
r"^<!-- mycode-memory:entry\b",
|
|
38
|
+
re.MULTILINE,
|
|
39
|
+
)
|
|
40
|
+
ENTRY_END_PATTERN = re.compile(
|
|
41
|
+
r"^<!-- mycode-memory:end -->[ \t]*(?:\r?\n|$)",
|
|
42
|
+
re.MULTILINE,
|
|
43
|
+
)
|
|
44
|
+
SENSITIVE_ASSIGNMENT_PATTERN = re.compile(
|
|
45
|
+
r"(?i)\b(?:[a-z0-9]+[_-])*"
|
|
46
|
+
r"(?:api[_-]?key|access[_-]?token|auth[_-]?token|token|secret|password)"
|
|
47
|
+
r"\s*[:=]\s*[^\s]{6,}"
|
|
48
|
+
)
|
|
49
|
+
SENSITIVE_TOKEN_PATTERN = re.compile(r"\bsk-[A-Za-z0-9_-]{16,}\b")
|
|
50
|
+
|
|
51
|
+
DEFAULT_MAX_MEMORY_FILE_BYTES = 128 * 1024
|
|
52
|
+
DEFAULT_MAX_MEMORY_ENTRY_CHARS = 2000
|
|
53
|
+
DEFAULT_MAX_MEMORY_ENTRIES = 100
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class MemoryError(RuntimeError):
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class MemoryFormatError(MemoryError):
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class SensitiveMemoryError(MemoryError):
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class MemoryLimits:
|
|
70
|
+
max_file_bytes: int = DEFAULT_MAX_MEMORY_FILE_BYTES
|
|
71
|
+
max_entry_chars: int = DEFAULT_MAX_MEMORY_ENTRY_CHARS
|
|
72
|
+
max_entries: int = DEFAULT_MAX_MEMORY_ENTRIES
|
|
73
|
+
|
|
74
|
+
def __post_init__(self) -> None:
|
|
75
|
+
if self.max_file_bytes < 1:
|
|
76
|
+
raise ValueError("max_file_bytes must be at least 1.")
|
|
77
|
+
if self.max_entry_chars < 1:
|
|
78
|
+
raise ValueError("max_entry_chars must be at least 1.")
|
|
79
|
+
if self.max_entries < 1:
|
|
80
|
+
raise ValueError("max_entries must be at least 1.")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class MemoryEntry:
|
|
85
|
+
scope: MemoryScope
|
|
86
|
+
kind: MemoryKind
|
|
87
|
+
key: str
|
|
88
|
+
content: str
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class MemoryIssue:
|
|
93
|
+
path: Path
|
|
94
|
+
code: MemoryIssueCode
|
|
95
|
+
message: str
|
|
96
|
+
key: str | None = None
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def blocking(self) -> bool:
|
|
100
|
+
return self.code != "sensitive_content"
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def display(self) -> str:
|
|
104
|
+
key_text = "" if self.key is None else f" [{self.key}]"
|
|
105
|
+
return f"{self.code}{key_text}: {self.path}: {self.message}"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class _MemoryBlock:
|
|
110
|
+
entry: MemoryEntry
|
|
111
|
+
start: int
|
|
112
|
+
end: int
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class MemoryDocument:
|
|
117
|
+
path: Path
|
|
118
|
+
scope: MemoryScope
|
|
119
|
+
entries: tuple[MemoryEntry, ...] = ()
|
|
120
|
+
issues: tuple[MemoryIssue, ...] = ()
|
|
121
|
+
raw_text: str = ""
|
|
122
|
+
content_bytes: int = 0
|
|
123
|
+
sha256: str = ""
|
|
124
|
+
_blocks: tuple[_MemoryBlock, ...] = field(default=(), repr=False)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class MemoryWriteResult:
|
|
129
|
+
action: MemoryWriteAction
|
|
130
|
+
entry: MemoryEntry
|
|
131
|
+
path: Path
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class MemoryStore:
|
|
135
|
+
def __init__(
|
|
136
|
+
self,
|
|
137
|
+
project: ProjectIdentity,
|
|
138
|
+
*,
|
|
139
|
+
base_directory: str | Path | None = None,
|
|
140
|
+
limits: MemoryLimits | None = None,
|
|
141
|
+
) -> None:
|
|
142
|
+
self.project = project
|
|
143
|
+
self.base_directory = Path(
|
|
144
|
+
Path.home() / ".mycode"
|
|
145
|
+
if base_directory is None
|
|
146
|
+
else base_directory
|
|
147
|
+
).resolve(strict=False)
|
|
148
|
+
self.limits = MemoryLimits() if limits is None else limits
|
|
149
|
+
|
|
150
|
+
def path_for_scope(self, scope: MemoryScope) -> Path:
|
|
151
|
+
_validate_scope(scope)
|
|
152
|
+
if scope == "user":
|
|
153
|
+
return self.base_directory / "MEMORY.md"
|
|
154
|
+
return (
|
|
155
|
+
self.base_directory
|
|
156
|
+
/ "projects"
|
|
157
|
+
/ project_directory_name(self.project)
|
|
158
|
+
/ "MEMORY.md"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def read_document(self, scope: MemoryScope) -> MemoryDocument:
|
|
162
|
+
path = self.path_for_scope(scope)
|
|
163
|
+
if not path.exists():
|
|
164
|
+
return MemoryDocument(path=path, scope=scope)
|
|
165
|
+
if not path.is_file():
|
|
166
|
+
return MemoryDocument(
|
|
167
|
+
path=path,
|
|
168
|
+
scope=scope,
|
|
169
|
+
issues=(
|
|
170
|
+
MemoryIssue(
|
|
171
|
+
path=path,
|
|
172
|
+
code="read_error",
|
|
173
|
+
message="memory path is not a regular file",
|
|
174
|
+
),
|
|
175
|
+
),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
raw = path.read_bytes()
|
|
180
|
+
except OSError as error:
|
|
181
|
+
return MemoryDocument(
|
|
182
|
+
path=path,
|
|
183
|
+
scope=scope,
|
|
184
|
+
issues=(
|
|
185
|
+
MemoryIssue(
|
|
186
|
+
path=path,
|
|
187
|
+
code="read_error",
|
|
188
|
+
message=str(error),
|
|
189
|
+
),
|
|
190
|
+
),
|
|
191
|
+
)
|
|
192
|
+
if len(raw) > self.limits.max_file_bytes:
|
|
193
|
+
return MemoryDocument(
|
|
194
|
+
path=path,
|
|
195
|
+
scope=scope,
|
|
196
|
+
content_bytes=len(raw),
|
|
197
|
+
sha256=hashlib.sha256(raw).hexdigest(),
|
|
198
|
+
issues=(
|
|
199
|
+
MemoryIssue(
|
|
200
|
+
path=path,
|
|
201
|
+
code="file_too_large",
|
|
202
|
+
message=(
|
|
203
|
+
f"file is {len(raw)} bytes; limit is "
|
|
204
|
+
f"{self.limits.max_file_bytes} bytes"
|
|
205
|
+
),
|
|
206
|
+
),
|
|
207
|
+
),
|
|
208
|
+
)
|
|
209
|
+
try:
|
|
210
|
+
raw_text = raw.decode("utf-8-sig")
|
|
211
|
+
except UnicodeDecodeError:
|
|
212
|
+
return MemoryDocument(
|
|
213
|
+
path=path,
|
|
214
|
+
scope=scope,
|
|
215
|
+
content_bytes=len(raw),
|
|
216
|
+
sha256=hashlib.sha256(raw).hexdigest(),
|
|
217
|
+
issues=(
|
|
218
|
+
MemoryIssue(
|
|
219
|
+
path=path,
|
|
220
|
+
code="invalid_utf8",
|
|
221
|
+
message="memory file must be valid UTF-8",
|
|
222
|
+
),
|
|
223
|
+
),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
return replace(
|
|
227
|
+
_parse_memory_document(path, scope, raw_text),
|
|
228
|
+
content_bytes=len(raw),
|
|
229
|
+
sha256=hashlib.sha256(raw).hexdigest(),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def list_entries(
|
|
233
|
+
self,
|
|
234
|
+
scope: MemoryScope | None = None,
|
|
235
|
+
) -> tuple[MemoryEntry, ...]:
|
|
236
|
+
scopes: tuple[MemoryScope, ...] = (
|
|
237
|
+
("user", "project") if scope is None else (scope,)
|
|
238
|
+
)
|
|
239
|
+
entries: list[MemoryEntry] = []
|
|
240
|
+
for selected_scope in scopes:
|
|
241
|
+
entries.extend(self.read_document(selected_scope).entries)
|
|
242
|
+
return tuple(entries)
|
|
243
|
+
|
|
244
|
+
def list_issues(
|
|
245
|
+
self,
|
|
246
|
+
scope: MemoryScope | None = None,
|
|
247
|
+
) -> tuple[MemoryIssue, ...]:
|
|
248
|
+
scopes: tuple[MemoryScope, ...] = (
|
|
249
|
+
("user", "project") if scope is None else (scope,)
|
|
250
|
+
)
|
|
251
|
+
issues: list[MemoryIssue] = []
|
|
252
|
+
for selected_scope in scopes:
|
|
253
|
+
issues.extend(self.read_document(selected_scope).issues)
|
|
254
|
+
return tuple(issues)
|
|
255
|
+
|
|
256
|
+
def save(
|
|
257
|
+
self,
|
|
258
|
+
*,
|
|
259
|
+
scope: MemoryScope,
|
|
260
|
+
kind: MemoryKind,
|
|
261
|
+
key: str,
|
|
262
|
+
content: str,
|
|
263
|
+
) -> MemoryWriteResult:
|
|
264
|
+
normalized_scope = _validate_scope(scope)
|
|
265
|
+
normalized_kind = _validate_kind(kind)
|
|
266
|
+
normalized_key = validate_memory_key(key)
|
|
267
|
+
normalized_content = validate_memory_content(
|
|
268
|
+
content,
|
|
269
|
+
max_chars=self.limits.max_entry_chars,
|
|
270
|
+
)
|
|
271
|
+
document = self.read_document(normalized_scope)
|
|
272
|
+
_raise_for_blocking_issues(document)
|
|
273
|
+
|
|
274
|
+
existing = next(
|
|
275
|
+
(block for block in document._blocks if block.entry.key == normalized_key),
|
|
276
|
+
None,
|
|
277
|
+
)
|
|
278
|
+
if existing is None and len(document._blocks) >= self.limits.max_entries:
|
|
279
|
+
raise MemoryError(
|
|
280
|
+
f"Memory file already contains {self.limits.max_entries} entries."
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
entry = MemoryEntry(
|
|
284
|
+
scope=normalized_scope,
|
|
285
|
+
kind=normalized_kind,
|
|
286
|
+
key=normalized_key,
|
|
287
|
+
content=normalized_content,
|
|
288
|
+
)
|
|
289
|
+
newline = "\r\n" if "\r\n" in document.raw_text else "\n"
|
|
290
|
+
rendered_block = _render_memory_block(entry, newline=newline)
|
|
291
|
+
if existing is None:
|
|
292
|
+
base = document.raw_text
|
|
293
|
+
if base.strip() == "":
|
|
294
|
+
base = _memory_file_header(normalized_scope, newline=newline)
|
|
295
|
+
updated = base.rstrip("\r\n") + newline * 2 + rendered_block + newline
|
|
296
|
+
action: MemoryWriteAction = "created"
|
|
297
|
+
else:
|
|
298
|
+
updated = (
|
|
299
|
+
document.raw_text[: existing.start]
|
|
300
|
+
+ rendered_block
|
|
301
|
+
+ newline
|
|
302
|
+
+ document.raw_text[existing.end :]
|
|
303
|
+
)
|
|
304
|
+
action = "updated"
|
|
305
|
+
|
|
306
|
+
encoded = updated.encode("utf-8")
|
|
307
|
+
if len(encoded) > self.limits.max_file_bytes:
|
|
308
|
+
raise MemoryError(
|
|
309
|
+
"Memory update would exceed file limit: "
|
|
310
|
+
f"{len(encoded)}/{self.limits.max_file_bytes} bytes."
|
|
311
|
+
)
|
|
312
|
+
path = self.path_for_scope(normalized_scope)
|
|
313
|
+
_atomic_write(path, encoded)
|
|
314
|
+
|
|
315
|
+
verified = self.read_document(normalized_scope)
|
|
316
|
+
verified_entry = next(
|
|
317
|
+
(item for item in verified.entries if item.key == normalized_key),
|
|
318
|
+
None,
|
|
319
|
+
)
|
|
320
|
+
if verified_entry != entry:
|
|
321
|
+
raise MemoryFormatError(
|
|
322
|
+
f"Memory write verification failed for key: {normalized_key}"
|
|
323
|
+
)
|
|
324
|
+
return MemoryWriteResult(action=action, entry=entry, path=path)
|
|
325
|
+
|
|
326
|
+
def delete(self, *, scope: MemoryScope, key: str) -> bool:
|
|
327
|
+
normalized_scope = _validate_scope(scope)
|
|
328
|
+
normalized_key = validate_memory_key(key)
|
|
329
|
+
document = self.read_document(normalized_scope)
|
|
330
|
+
_raise_for_blocking_issues(document)
|
|
331
|
+
block = next(
|
|
332
|
+
(item for item in document._blocks if item.entry.key == normalized_key),
|
|
333
|
+
None,
|
|
334
|
+
)
|
|
335
|
+
if block is None:
|
|
336
|
+
return False
|
|
337
|
+
|
|
338
|
+
updated = document.raw_text[: block.start] + document.raw_text[block.end :]
|
|
339
|
+
_atomic_write(self.path_for_scope(normalized_scope), updated.encode("utf-8"))
|
|
340
|
+
verified = self.read_document(normalized_scope)
|
|
341
|
+
if any(item.entry.key == normalized_key for item in verified._blocks):
|
|
342
|
+
raise MemoryFormatError(
|
|
343
|
+
f"Memory delete verification failed for key: {normalized_key}"
|
|
344
|
+
)
|
|
345
|
+
return True
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def validate_memory_key(key: str) -> str:
|
|
349
|
+
normalized = key.strip().casefold()
|
|
350
|
+
if not MEMORY_KEY_PATTERN.fullmatch(normalized):
|
|
351
|
+
raise ValueError(
|
|
352
|
+
"Memory key must use 1-80 lowercase letters, digits, '.', '_' or '-', "
|
|
353
|
+
"and must start with a letter or digit."
|
|
354
|
+
)
|
|
355
|
+
return normalized
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def validate_memory_content(content: str, *, max_chars: int) -> str:
|
|
359
|
+
normalized = content.strip()
|
|
360
|
+
if normalized == "":
|
|
361
|
+
raise ValueError("Memory content must not be empty.")
|
|
362
|
+
if len(normalized) > max_chars:
|
|
363
|
+
raise ValueError(
|
|
364
|
+
f"Memory content must not exceed {max_chars} characters."
|
|
365
|
+
)
|
|
366
|
+
if contains_sensitive_memory_content(normalized):
|
|
367
|
+
raise SensitiveMemoryError(
|
|
368
|
+
"Memory content appears to contain a secret, token, password or private key."
|
|
369
|
+
)
|
|
370
|
+
return normalized
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def contains_sensitive_memory_content(content: str) -> bool:
|
|
374
|
+
return bool(
|
|
375
|
+
"-----BEGIN PRIVATE KEY-----" in content
|
|
376
|
+
or "-----BEGIN RSA PRIVATE KEY-----" in content
|
|
377
|
+
or SENSITIVE_ASSIGNMENT_PATTERN.search(content)
|
|
378
|
+
or SENSITIVE_TOKEN_PATTERN.search(content)
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _parse_memory_document(
|
|
383
|
+
path: Path,
|
|
384
|
+
scope: MemoryScope,
|
|
385
|
+
raw_text: str,
|
|
386
|
+
) -> MemoryDocument:
|
|
387
|
+
starts = list(ENTRY_START_PATTERN.finditer(raw_text))
|
|
388
|
+
issues: list[MemoryIssue] = []
|
|
389
|
+
blocks: list[_MemoryBlock] = []
|
|
390
|
+
safe_entries: list[MemoryEntry] = []
|
|
391
|
+
seen_keys: set[str] = set()
|
|
392
|
+
|
|
393
|
+
if len(starts) != len(ENTRY_MARKER_PREFIX_PATTERN.findall(raw_text)):
|
|
394
|
+
issues.append(
|
|
395
|
+
MemoryIssue(
|
|
396
|
+
path=path,
|
|
397
|
+
code="malformed_marker",
|
|
398
|
+
message="one or more memory entry markers are malformed",
|
|
399
|
+
)
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
consumed_until = 0
|
|
403
|
+
for index, start_match in enumerate(starts):
|
|
404
|
+
if start_match.start() < consumed_until:
|
|
405
|
+
continue
|
|
406
|
+
next_start = starts[index + 1].start() if index + 1 < len(starts) else None
|
|
407
|
+
end_match = ENTRY_END_PATTERN.search(raw_text, start_match.end())
|
|
408
|
+
kind = start_match.group(1)
|
|
409
|
+
key = start_match.group(2)
|
|
410
|
+
if end_match is None or (
|
|
411
|
+
next_start is not None and next_start < end_match.start()
|
|
412
|
+
):
|
|
413
|
+
issues.append(
|
|
414
|
+
MemoryIssue(
|
|
415
|
+
path=path,
|
|
416
|
+
code="missing_end_marker",
|
|
417
|
+
key=key,
|
|
418
|
+
message="memory entry is missing its end marker",
|
|
419
|
+
)
|
|
420
|
+
)
|
|
421
|
+
continue
|
|
422
|
+
|
|
423
|
+
consumed_until = end_match.end()
|
|
424
|
+
payload = raw_text[start_match.end() : end_match.start()]
|
|
425
|
+
lines = payload.splitlines()
|
|
426
|
+
expected_heading = f"### {key}"
|
|
427
|
+
heading_valid = bool(lines and lines[0].strip() == expected_heading)
|
|
428
|
+
content = "\n".join(lines[1:]).strip() if lines else ""
|
|
429
|
+
entry = MemoryEntry(
|
|
430
|
+
scope=scope,
|
|
431
|
+
kind=kind, # type: ignore[arg-type]
|
|
432
|
+
key=key,
|
|
433
|
+
content=content,
|
|
434
|
+
)
|
|
435
|
+
block = _MemoryBlock(
|
|
436
|
+
entry=entry,
|
|
437
|
+
start=start_match.start(),
|
|
438
|
+
end=end_match.end(),
|
|
439
|
+
)
|
|
440
|
+
blocks.append(block)
|
|
441
|
+
|
|
442
|
+
if not heading_valid:
|
|
443
|
+
issues.append(
|
|
444
|
+
MemoryIssue(
|
|
445
|
+
path=path,
|
|
446
|
+
code="invalid_heading",
|
|
447
|
+
key=key,
|
|
448
|
+
message=f"expected heading: {expected_heading}",
|
|
449
|
+
)
|
|
450
|
+
)
|
|
451
|
+
continue
|
|
452
|
+
if content == "":
|
|
453
|
+
issues.append(
|
|
454
|
+
MemoryIssue(
|
|
455
|
+
path=path,
|
|
456
|
+
code="empty_content",
|
|
457
|
+
key=key,
|
|
458
|
+
message="memory entry content is empty",
|
|
459
|
+
)
|
|
460
|
+
)
|
|
461
|
+
continue
|
|
462
|
+
if key in seen_keys:
|
|
463
|
+
issues.append(
|
|
464
|
+
MemoryIssue(
|
|
465
|
+
path=path,
|
|
466
|
+
code="duplicate_key",
|
|
467
|
+
key=key,
|
|
468
|
+
message="memory key appears more than once",
|
|
469
|
+
)
|
|
470
|
+
)
|
|
471
|
+
continue
|
|
472
|
+
seen_keys.add(key)
|
|
473
|
+
if contains_sensitive_memory_content(content):
|
|
474
|
+
issues.append(
|
|
475
|
+
MemoryIssue(
|
|
476
|
+
path=path,
|
|
477
|
+
code="sensitive_content",
|
|
478
|
+
key=key,
|
|
479
|
+
message="entry was withheld because it may contain sensitive content",
|
|
480
|
+
)
|
|
481
|
+
)
|
|
482
|
+
continue
|
|
483
|
+
safe_entries.append(entry)
|
|
484
|
+
|
|
485
|
+
if len(ENTRY_END_PATTERN.findall(raw_text)) != len(blocks):
|
|
486
|
+
issues.append(
|
|
487
|
+
MemoryIssue(
|
|
488
|
+
path=path,
|
|
489
|
+
code="malformed_marker",
|
|
490
|
+
message="memory end marker count does not match parsed entries",
|
|
491
|
+
)
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
return MemoryDocument(
|
|
495
|
+
path=path,
|
|
496
|
+
scope=scope,
|
|
497
|
+
entries=tuple(safe_entries),
|
|
498
|
+
issues=tuple(issues),
|
|
499
|
+
raw_text=raw_text,
|
|
500
|
+
_blocks=tuple(blocks),
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _raise_for_blocking_issues(document: MemoryDocument) -> None:
|
|
505
|
+
blocking = [issue for issue in document.issues if issue.blocking]
|
|
506
|
+
if blocking:
|
|
507
|
+
details = "; ".join(issue.display for issue in blocking)
|
|
508
|
+
raise MemoryFormatError(
|
|
509
|
+
"Memory file contains structural errors; refusing automatic edit: "
|
|
510
|
+
+ details
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _validate_scope(scope: MemoryScope) -> MemoryScope:
|
|
515
|
+
if scope not in {"user", "project"}:
|
|
516
|
+
raise ValueError(f"Unsupported memory scope: {scope}")
|
|
517
|
+
return scope
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _validate_kind(kind: MemoryKind) -> MemoryKind:
|
|
521
|
+
if kind not in MEMORY_KINDS:
|
|
522
|
+
raise ValueError(f"Unsupported memory kind: {kind}")
|
|
523
|
+
return kind
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _render_memory_block(entry: MemoryEntry, *, newline: str) -> str:
|
|
527
|
+
return newline.join(
|
|
528
|
+
[
|
|
529
|
+
f"<!-- mycode-memory:entry kind={entry.kind} key={entry.key} -->",
|
|
530
|
+
f"### {entry.key}",
|
|
531
|
+
"",
|
|
532
|
+
entry.content,
|
|
533
|
+
"<!-- mycode-memory:end -->",
|
|
534
|
+
]
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _memory_file_header(scope: MemoryScope, *, newline: str) -> str:
|
|
539
|
+
title = "User" if scope == "user" else "Project"
|
|
540
|
+
return newline.join(
|
|
541
|
+
[
|
|
542
|
+
f"# MyCode {title} Memory",
|
|
543
|
+
"",
|
|
544
|
+
"<!-- mycode-memory-format: 1 -->",
|
|
545
|
+
"",
|
|
546
|
+
"This file is the editable source of truth for long-term memory.",
|
|
547
|
+
"Edit an entry body to correct it, or delete its complete marker block to forget it.",
|
|
548
|
+
]
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _atomic_write(path: Path, content: bytes) -> None:
|
|
553
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
554
|
+
temporary_path: Path | None = None
|
|
555
|
+
try:
|
|
556
|
+
with tempfile.NamedTemporaryFile(
|
|
557
|
+
mode="wb",
|
|
558
|
+
dir=path.parent,
|
|
559
|
+
prefix=f".{path.name}.",
|
|
560
|
+
suffix=".tmp",
|
|
561
|
+
delete=False,
|
|
562
|
+
) as temporary_file:
|
|
563
|
+
temporary_file.write(content)
|
|
564
|
+
temporary_file.flush()
|
|
565
|
+
os.fsync(temporary_file.fileno())
|
|
566
|
+
temporary_path = Path(temporary_file.name)
|
|
567
|
+
os.replace(temporary_path, path)
|
|
568
|
+
finally:
|
|
569
|
+
if temporary_path is not None and temporary_path.exists():
|
|
570
|
+
temporary_path.unlink()
|