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
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()