windcode 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 (143) hide show
  1. windcode/__init__.py +6 -0
  2. windcode/__main__.py +4 -0
  3. windcode/auth/__init__.py +11 -0
  4. windcode/auth/store.py +90 -0
  5. windcode/cli.py +181 -0
  6. windcode/config/__init__.py +41 -0
  7. windcode/config/loader.py +117 -0
  8. windcode/config/models.py +203 -0
  9. windcode/config/writer.py +74 -0
  10. windcode/context/__init__.py +18 -0
  11. windcode/context/compactor.py +72 -0
  12. windcode/context/estimator.py +89 -0
  13. windcode/context/truncation.py +87 -0
  14. windcode/domain/__init__.py +1 -0
  15. windcode/domain/errors.py +33 -0
  16. windcode/domain/events.py +597 -0
  17. windcode/domain/messages.py +226 -0
  18. windcode/domain/models.py +73 -0
  19. windcode/domain/subagents.py +263 -0
  20. windcode/domain/tools.py +55 -0
  21. windcode/extensions/__init__.py +27 -0
  22. windcode/extensions/commands.py +25 -0
  23. windcode/extensions/discovery.py +139 -0
  24. windcode/extensions/events.py +58 -0
  25. windcode/extensions/hooks/__init__.py +4 -0
  26. windcode/extensions/hooks/dispatcher.py +107 -0
  27. windcode/extensions/hooks/executor.py +49 -0
  28. windcode/extensions/hooks/loader.py +101 -0
  29. windcode/extensions/hooks/models.py +109 -0
  30. windcode/extensions/mcp/__init__.py +10 -0
  31. windcode/extensions/mcp/adapter.py +101 -0
  32. windcode/extensions/mcp/catalog.py +153 -0
  33. windcode/extensions/mcp/client.py +273 -0
  34. windcode/extensions/mcp/runtime.py +144 -0
  35. windcode/extensions/mcp/tools.py +570 -0
  36. windcode/extensions/models.py +168 -0
  37. windcode/extensions/paths.py +71 -0
  38. windcode/extensions/plugins/__init__.py +3 -0
  39. windcode/extensions/plugins/installer.py +93 -0
  40. windcode/extensions/plugins/manifest.py +166 -0
  41. windcode/extensions/runtime.py +366 -0
  42. windcode/extensions/service.py +437 -0
  43. windcode/extensions/skills/__init__.py +3 -0
  44. windcode/extensions/skills/loader.py +67 -0
  45. windcode/extensions/skills/parser.py +57 -0
  46. windcode/extensions/skills/tools.py +213 -0
  47. windcode/extensions/snapshot.py +57 -0
  48. windcode/extensions/state.py +158 -0
  49. windcode/instructions/__init__.py +8 -0
  50. windcode/instructions/loader.py +50 -0
  51. windcode/memory/__init__.py +57 -0
  52. windcode/memory/extraction.py +83 -0
  53. windcode/memory/models.py +218 -0
  54. windcode/memory/refiner.py +189 -0
  55. windcode/memory/security.py +23 -0
  56. windcode/memory/service.py +179 -0
  57. windcode/memory/store.py +362 -0
  58. windcode/observability/__init__.py +4 -0
  59. windcode/observability/redaction.py +95 -0
  60. windcode/observability/trace.py +115 -0
  61. windcode/policy/__init__.py +22 -0
  62. windcode/policy/engine.py +137 -0
  63. windcode/policy/models.py +69 -0
  64. windcode/providers/__init__.py +29 -0
  65. windcode/providers/_utils.py +19 -0
  66. windcode/providers/anthropic.py +215 -0
  67. windcode/providers/base.py +46 -0
  68. windcode/providers/catalog.py +119 -0
  69. windcode/providers/errors.py +96 -0
  70. windcode/providers/openai_compat.py +231 -0
  71. windcode/providers/openai_responses.py +189 -0
  72. windcode/providers/registry.py +105 -0
  73. windcode/runtime/__init__.py +15 -0
  74. windcode/runtime/control.py +89 -0
  75. windcode/runtime/event_bus.py +67 -0
  76. windcode/runtime/loop.py +510 -0
  77. windcode/runtime/prompts.py +132 -0
  78. windcode/runtime/report.py +64 -0
  79. windcode/runtime/retry.py +73 -0
  80. windcode/runtime/scheduler.py +194 -0
  81. windcode/runtime/subagents/__init__.py +28 -0
  82. windcode/runtime/subagents/approvals.py +88 -0
  83. windcode/runtime/subagents/budgets.py +79 -0
  84. windcode/runtime/subagents/coordinator.py +714 -0
  85. windcode/runtime/subagents/factory.py +311 -0
  86. windcode/runtime/subagents/roles.py +74 -0
  87. windcode/runtime/subagents/verification.py +53 -0
  88. windcode/sandbox/__init__.py +3 -0
  89. windcode/sandbox/bwrap.py +79 -0
  90. windcode/sdk.py +1259 -0
  91. windcode/sessions/__init__.py +23 -0
  92. windcode/sessions/artifacts.py +69 -0
  93. windcode/sessions/models.py +104 -0
  94. windcode/sessions/store.py +167 -0
  95. windcode/sessions/tree.py +35 -0
  96. windcode/tools/__init__.py +10 -0
  97. windcode/tools/apply_patch.py +191 -0
  98. windcode/tools/ask_user.py +58 -0
  99. windcode/tools/builtins.py +44 -0
  100. windcode/tools/edit_file.py +54 -0
  101. windcode/tools/filesystem.py +77 -0
  102. windcode/tools/glob.py +35 -0
  103. windcode/tools/grep.py +66 -0
  104. windcode/tools/memory.py +400 -0
  105. windcode/tools/read_file.py +54 -0
  106. windcode/tools/registry.py +120 -0
  107. windcode/tools/shell.py +159 -0
  108. windcode/tools/subagents/__init__.py +31 -0
  109. windcode/tools/subagents/cancel.py +35 -0
  110. windcode/tools/subagents/integrate.py +54 -0
  111. windcode/tools/subagents/list.py +46 -0
  112. windcode/tools/subagents/spawn.py +86 -0
  113. windcode/tools/subagents/wait.py +74 -0
  114. windcode/tools/write_file.py +61 -0
  115. windcode/tui/__init__.py +3 -0
  116. windcode/tui/app.py +1015 -0
  117. windcode/tui/commands.py +90 -0
  118. windcode/tui/permission_display.py +24 -0
  119. windcode/tui/styles.tcss +591 -0
  120. windcode/tui/widgets/__init__.py +31 -0
  121. windcode/tui/widgets/approval.py +98 -0
  122. windcode/tui/widgets/command_menu.py +90 -0
  123. windcode/tui/widgets/extensions.py +48 -0
  124. windcode/tui/widgets/input.py +110 -0
  125. windcode/tui/widgets/memory.py +143 -0
  126. windcode/tui/widgets/messages.py +299 -0
  127. windcode/tui/widgets/models.py +565 -0
  128. windcode/tui/widgets/question.py +46 -0
  129. windcode/tui/widgets/sessions.py +68 -0
  130. windcode/tui/widgets/status.py +46 -0
  131. windcode/tui/widgets/subagents.py +195 -0
  132. windcode/tui/widgets/tools.py +66 -0
  133. windcode/tui/widgets/welcome.py +149 -0
  134. windcode/types.py +80 -0
  135. windcode/worktrees/__init__.py +24 -0
  136. windcode/worktrees/git.py +92 -0
  137. windcode/worktrees/manager.py +265 -0
  138. windcode/worktrees/models.py +62 -0
  139. windcode-0.1.0.dist-info/METADATA +207 -0
  140. windcode-0.1.0.dist-info/RECORD +143 -0
  141. windcode-0.1.0.dist-info/WHEEL +4 -0
  142. windcode-0.1.0.dist-info/entry_points.txt +2 -0
  143. windcode-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from windcode.sandbox import BubblewrapSandbox
6
+ from windcode.tools.apply_patch import ApplyPatchTool
7
+ from windcode.tools.ask_user import AskUserTool
8
+ from windcode.tools.edit_file import EditFileTool
9
+ from windcode.tools.glob import GlobTool
10
+ from windcode.tools.grep import GrepTool
11
+ from windcode.tools.read_file import ReadFileTool
12
+ from windcode.tools.registry import ToolRegistry
13
+ from windcode.tools.shell import ShellTool
14
+ from windcode.tools.write_file import WriteFileTool
15
+
16
+ if TYPE_CHECKING:
17
+ from windcode.runtime.subagents.coordinator import SubagentCoordinator
18
+
19
+
20
+ def add_subagent_tools(registry: ToolRegistry, coordinator: SubagentCoordinator) -> ToolRegistry:
21
+ from windcode.tools.subagents import register_subagent_tools
22
+
23
+ register_subagent_tools(registry, coordinator)
24
+ return registry
25
+
26
+
27
+ def create_builtin_registry(
28
+ *,
29
+ sandbox: BubblewrapSandbox | None = None,
30
+ shell_timeout: float = 120.0,
31
+ ) -> ToolRegistry:
32
+ registry = ToolRegistry()
33
+ for tool in (
34
+ ReadFileTool(),
35
+ WriteFileTool(),
36
+ EditFileTool(),
37
+ ApplyPatchTool(),
38
+ GlobTool(),
39
+ GrepTool(),
40
+ ShellTool(sandbox=sandbox, default_timeout=shell_timeout),
41
+ AskUserTool(),
42
+ ):
43
+ registry.register(tool)
44
+ return registry
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import cast
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+ from windcode.domain.tools import ToolContext, ToolEffect, ToolResult
8
+ from windcode.tools.filesystem import content_sha256, require_workspace_path
9
+ from windcode.tools.write_file import WriteFileInput, WriteFileTool
10
+
11
+
12
+ class EditFileInput(BaseModel):
13
+ model_config = ConfigDict(extra="forbid")
14
+
15
+ path: str = Field(min_length=1)
16
+ old_text: str = Field(min_length=1)
17
+ new_text: str
18
+ expected_sha256: str | None = None
19
+
20
+
21
+ class EditFileTool:
22
+ name = "edit_file"
23
+ description = (
24
+ "Replace one exact, unique text occurrence without overwriting concurrent changes."
25
+ )
26
+ input_model = EditFileInput
27
+ effects = frozenset({ToolEffect.WORKSPACE_WRITE})
28
+
29
+ async def execute(self, context: ToolContext, arguments: BaseModel) -> ToolResult:
30
+ parsed = cast(EditFileInput, arguments)
31
+ path = require_workspace_path(context.workspace, parsed.path)
32
+ content = path.read_text(encoding="utf-8")
33
+ digest = content_sha256(content)
34
+ if parsed.expected_sha256 is not None and parsed.expected_sha256 != digest:
35
+ return ToolResult(
36
+ output="file changed since it was read; expected digest does not match",
37
+ is_error=True,
38
+ data={"error": "stale_content", "actual_sha256": digest},
39
+ )
40
+ matches = content.count(parsed.old_text)
41
+ if matches != 1:
42
+ return ToolResult(
43
+ output=f"old_text must match exactly once; found {matches} matches",
44
+ is_error=True,
45
+ data={"error": "non_unique_match", "matches": matches},
46
+ )
47
+ return await WriteFileTool().execute(
48
+ context,
49
+ WriteFileInput(
50
+ path=parsed.path,
51
+ content=content.replace(parsed.old_text, parsed.new_text, 1),
52
+ expected_sha256=digest,
53
+ ),
54
+ )
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import os
5
+ import stat
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from uuid import uuid4
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class ResolvedPath:
13
+ requested: Path
14
+ path: Path
15
+ workspace: Path
16
+ inside_workspace: bool
17
+ symlink_escape: bool
18
+
19
+
20
+ def resolve_path(workspace: Path, requested: str | Path) -> ResolvedPath:
21
+ root = workspace.expanduser().resolve()
22
+ raw = Path(requested).expanduser()
23
+ candidate = raw if raw.is_absolute() else root / raw
24
+ lexical_inside = Path(os.path.abspath(candidate)).is_relative_to(root)
25
+ resolved = candidate.resolve(strict=False)
26
+ inside = resolved.is_relative_to(root)
27
+ return ResolvedPath(
28
+ requested=raw,
29
+ path=resolved,
30
+ workspace=root,
31
+ inside_workspace=inside,
32
+ symlink_escape=lexical_inside and not inside,
33
+ )
34
+
35
+
36
+ def require_workspace_path(workspace: Path, requested: str | Path) -> Path:
37
+ resolved = resolve_path(workspace, requested)
38
+ if not resolved.inside_workspace:
39
+ reason = (
40
+ "symbolic link escapes workspace"
41
+ if resolved.symlink_escape
42
+ else "path is outside workspace"
43
+ )
44
+ raise ValueError(f"{requested}: {reason}")
45
+ return resolved.path
46
+
47
+
48
+ def content_sha256(content: bytes | str) -> str:
49
+ encoded = content.encode("utf-8") if isinstance(content, str) else content
50
+ return hashlib.sha256(encoded).hexdigest()
51
+
52
+
53
+ def file_sha256(path: Path) -> str:
54
+ return content_sha256(path.read_bytes())
55
+
56
+
57
+ def atomic_write_text(path: Path, content: str) -> None:
58
+ path.parent.mkdir(parents=True, exist_ok=True)
59
+ previous_mode: int | None = None
60
+ if path.exists():
61
+ previous_mode = stat.S_IMODE(path.stat().st_mode)
62
+ temporary = path.with_name(f".{path.name}.tmp-{uuid4().hex}")
63
+ try:
64
+ with temporary.open("w", encoding="utf-8", newline="") as stream:
65
+ stream.write(content)
66
+ stream.flush()
67
+ os.fsync(stream.fileno())
68
+ if previous_mode is not None:
69
+ temporary.chmod(previous_mode)
70
+ temporary.replace(path)
71
+ directory_fd = os.open(path.parent, os.O_RDONLY)
72
+ try:
73
+ os.fsync(directory_fd)
74
+ finally:
75
+ os.close(directory_fd)
76
+ finally:
77
+ temporary.unlink(missing_ok=True)
windcode/tools/glob.py ADDED
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import cast
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+ from windcode.domain.tools import ToolContext, ToolEffect, ToolResult
8
+
9
+
10
+ class GlobInput(BaseModel):
11
+ model_config = ConfigDict(extra="forbid")
12
+
13
+ pattern: str = Field(min_length=1)
14
+ limit: int = Field(default=200, ge=1, le=2_000)
15
+
16
+
17
+ class GlobTool:
18
+ name = "glob"
19
+ description = "Match workspace files using a glob pattern and return stable sorted paths."
20
+ input_model = GlobInput
21
+ effects = frozenset({ToolEffect.READ})
22
+
23
+ async def execute(self, context: ToolContext, arguments: BaseModel) -> ToolResult:
24
+ parsed = cast(GlobInput, arguments)
25
+ root = context.workspace.resolve()
26
+ matches = sorted(
27
+ str(path.resolve().relative_to(root))
28
+ for path in root.glob(parsed.pattern)
29
+ if path.is_file() and path.resolve().is_relative_to(root)
30
+ )
31
+ selected = matches[: parsed.limit]
32
+ return ToolResult(
33
+ output="\n".join(selected),
34
+ data={"count": len(selected), "truncated": len(matches) > len(selected)},
35
+ )
windcode/tools/grep.py ADDED
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import cast
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field
7
+
8
+ from windcode.domain.tools import ToolContext, ToolEffect, ToolResult
9
+
10
+
11
+ class GrepInput(BaseModel):
12
+ model_config = ConfigDict(extra="forbid")
13
+
14
+ pattern: str = Field(min_length=1)
15
+ glob: str = "**/*"
16
+ context_lines: int = Field(default=0, ge=0, le=10)
17
+ limit: int = Field(default=200, ge=1, le=2_000)
18
+ case_sensitive: bool = True
19
+
20
+
21
+ class GrepTool:
22
+ name = "grep"
23
+ description = "Search UTF-8 workspace text with a regular expression and bounded context."
24
+ input_model = GrepInput
25
+ effects = frozenset({ToolEffect.READ})
26
+ max_file_bytes = 2_000_000
27
+
28
+ async def execute(self, context: ToolContext, arguments: BaseModel) -> ToolResult:
29
+ parsed = cast(GrepInput, arguments)
30
+ flags = 0 if parsed.case_sensitive else re.IGNORECASE
31
+ expression = re.compile(parsed.pattern, flags)
32
+ root = context.workspace.resolve()
33
+ output: list[str] = []
34
+ matched = 0
35
+ for path in sorted(root.glob(parsed.glob)):
36
+ resolved = path.resolve()
37
+ if not path.is_file() or not resolved.is_relative_to(root):
38
+ continue
39
+ raw = path.read_bytes()
40
+ if len(raw) > self.max_file_bytes or b"\x00" in raw:
41
+ continue
42
+ try:
43
+ lines = raw.decode("utf-8").splitlines()
44
+ except UnicodeDecodeError:
45
+ continue
46
+ relative = resolved.relative_to(root)
47
+ for index, line in enumerate(lines):
48
+ if expression.search(line) is None:
49
+ continue
50
+ matched += 1
51
+ start = max(0, index - parsed.context_lines)
52
+ end = min(len(lines), index + parsed.context_lines + 1)
53
+ for line_index in range(start, end):
54
+ separator = ":" if line_index == index else "-"
55
+ output.append(
56
+ f"{relative}{separator}{line_index + 1}{separator}{lines[line_index]}"
57
+ )
58
+ if matched >= parsed.limit:
59
+ return ToolResult(
60
+ output="\n".join(output),
61
+ data={"matches": matched, "truncated": True},
62
+ )
63
+ return ToolResult(
64
+ output="\n".join(output),
65
+ data={"matches": matched, "truncated": False},
66
+ )
@@ -0,0 +1,400 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Awaitable, Callable
5
+ from typing import Any, cast
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+ from windcode.domain.tools import ToolContext, ToolEffect, ToolResult
10
+ from windcode.memory import (
11
+ MemoryActivation,
12
+ MemoryKind,
13
+ MemoryRecord,
14
+ MemoryScope,
15
+ MemoryService,
16
+ MemorySource,
17
+ MemoryStatus,
18
+ classify_memory_intent,
19
+ explicitly_always_project_fact,
20
+ has_explicit_memory_intent,
21
+ )
22
+ from windcode.memory.security import SensitiveMemoryError, validate_memory_text
23
+ from windcode.tools.registry import ToolRegistry
24
+
25
+ MemoryToolObserver = Callable[[str, dict[str, Any]], Awaitable[None]]
26
+
27
+
28
+ class MemorySearchInput(BaseModel):
29
+ model_config = ConfigDict(extra="forbid")
30
+
31
+ query: str = Field(min_length=1, max_length=2_000)
32
+ limit: int = Field(default=5, ge=1, le=20)
33
+ kind: MemoryKind | None = None
34
+ scope: MemoryScope | None = None
35
+ status: MemoryStatus | None = None
36
+ activation: MemoryActivation | None = None
37
+
38
+
39
+ class MemoryListInput(BaseModel):
40
+ model_config = ConfigDict(extra="forbid")
41
+
42
+ limit: int = Field(default=20, ge=1, le=100)
43
+ kind: MemoryKind | None = None
44
+ scope: MemoryScope | None = None
45
+ status: MemoryStatus | None = None
46
+ activation: MemoryActivation | None = None
47
+
48
+
49
+ class MemoryGetInput(BaseModel):
50
+ model_config = ConfigDict(extra="forbid")
51
+
52
+ memory_id: str = Field(min_length=1, max_length=64)
53
+
54
+
55
+ class MemoryWriteInput(BaseModel):
56
+ model_config = ConfigDict(extra="forbid")
57
+
58
+ content: str = Field(min_length=1, max_length=4_000)
59
+ kind: MemoryKind | None = None
60
+ scope: MemoryScope | None = None
61
+
62
+
63
+ def _record_data(record: MemoryRecord, *, include_body: bool) -> dict[str, Any]:
64
+ data: dict[str, Any] = {
65
+ "memory_id": record.memory_id,
66
+ "kind": record.kind.value,
67
+ "scope": record.scope.value,
68
+ "status": record.status.value,
69
+ "activation": record.activation.value,
70
+ "priority": record.priority,
71
+ "title": record.title,
72
+ "summary": record.summary,
73
+ "tags": list(record.tags),
74
+ "confidence": record.confidence,
75
+ "updated_at": record.updated_at.isoformat(),
76
+ "evidence": list(record.evidence),
77
+ }
78
+ if include_body:
79
+ data["body"] = record.body
80
+ return data
81
+
82
+
83
+ def _matches(
84
+ record: MemoryRecord,
85
+ *,
86
+ kind: MemoryKind | None,
87
+ scope: MemoryScope | None,
88
+ activation: MemoryActivation | None,
89
+ ) -> bool:
90
+ return (
91
+ (kind is None or record.kind is kind)
92
+ and (scope is None or record.scope is scope)
93
+ and (activation is None or record.activation is activation)
94
+ )
95
+
96
+
97
+ def _query_statuses(status: MemoryStatus | None) -> tuple[MemoryStatus, ...]:
98
+ return (MemoryStatus.ACTIVE, MemoryStatus.CANDIDATE) if status is None else (status,)
99
+
100
+
101
+ def _status_label(status: MemoryStatus | None) -> str:
102
+ return "active,candidate" if status is None else status.value
103
+
104
+
105
+ def _bounded(items: list[dict[str, Any]], max_chars: int) -> tuple[list[dict[str, Any]], bool]:
106
+ selected: list[dict[str, Any]] = []
107
+ size = 2
108
+ for item in items:
109
+ encoded = json.dumps(item, ensure_ascii=False)
110
+ if selected and size + len(encoded) + 1 > max_chars:
111
+ return selected, True
112
+ if not selected and len(encoded) + 2 > max_chars:
113
+ compact = dict(item)
114
+ compact.pop("body", None)
115
+ compact["truncated"] = True
116
+ return [compact], True
117
+ selected.append(item)
118
+ size += len(encoded) + 1
119
+ return selected, False
120
+
121
+
122
+ class _MemoryTool:
123
+ effects = frozenset({ToolEffect.READ})
124
+
125
+ def __init__(
126
+ self,
127
+ service: MemoryService,
128
+ observer: MemoryToolObserver,
129
+ *,
130
+ max_chars: int,
131
+ ) -> None:
132
+ self.service = service
133
+ self.observer = observer
134
+ self.max_chars = max_chars
135
+
136
+ async def _observe(self, action: str, details: dict[str, Any]) -> None:
137
+ await self.observer(action, details)
138
+
139
+
140
+ class MemorySearchTool(_MemoryTool):
141
+ name = "memory_search"
142
+ description = (
143
+ "Search visible long-term memories when the user explicitly asks what is remembered "
144
+ "or requests memory lookup. By default include active and candidate records and report "
145
+ "their status accurately. Use this instead of searching workspace files."
146
+ )
147
+ input_model = MemorySearchInput
148
+
149
+ async def execute(self, context: ToolContext, arguments: BaseModel) -> ToolResult:
150
+ del context
151
+ parsed = cast(MemorySearchInput, arguments)
152
+ candidates = self.service.store.search(
153
+ parsed.query,
154
+ project_id=self.service.project_id,
155
+ limit=min(100, max(parsed.limit * 4, 20)),
156
+ statuses=_query_statuses(parsed.status),
157
+ kind=parsed.kind,
158
+ scope=parsed.scope,
159
+ activation=parsed.activation,
160
+ )
161
+ records = [result.record for result in candidates][: parsed.limit]
162
+ raw_items = [_record_data(record, include_body=True) for record in records]
163
+ items, truncated = _bounded(raw_items, self.max_chars)
164
+ data = {
165
+ "query": parsed.query,
166
+ "count": len(items),
167
+ "truncated": truncated,
168
+ "memories": items,
169
+ }
170
+ await self._observe(
171
+ "searched",
172
+ {
173
+ "query": parsed.query,
174
+ "count": len(items),
175
+ "status": _status_label(parsed.status),
176
+ },
177
+ )
178
+ return ToolResult(json.dumps(data, ensure_ascii=False), data=data)
179
+
180
+
181
+ class MemoryListTool(_MemoryTool):
182
+ name = "memory_list"
183
+ description = (
184
+ "List visible long-term memories for broad requests such as 'show what you remember'. "
185
+ "By default include active and candidate records and report their status accurately. "
186
+ "Use filters instead of searching workspace files."
187
+ )
188
+ input_model = MemoryListInput
189
+
190
+ async def execute(self, context: ToolContext, arguments: BaseModel) -> ToolResult:
191
+ del context
192
+ parsed = cast(MemoryListInput, arguments)
193
+ statuses = _query_statuses(parsed.status)
194
+ records = [
195
+ record
196
+ for record in self.service.store.list(
197
+ project_id=self.service.project_id,
198
+ )
199
+ if record.status in statuses
200
+ and _matches(
201
+ record,
202
+ kind=parsed.kind,
203
+ scope=parsed.scope,
204
+ activation=parsed.activation,
205
+ )
206
+ ][: parsed.limit]
207
+ raw_items = [_record_data(record, include_body=False) for record in records]
208
+ items, truncated = _bounded(raw_items, self.max_chars)
209
+ data = {"count": len(items), "truncated": truncated, "memories": items}
210
+ await self._observe(
211
+ "listed",
212
+ {"count": len(items), "status": _status_label(parsed.status)},
213
+ )
214
+ return ToolResult(json.dumps(data, ensure_ascii=False), data=data)
215
+
216
+
217
+ class MemoryGetTool(_MemoryTool):
218
+ name = "memory_get"
219
+ description = (
220
+ "Read one visible long-term memory by its exact ID or unique ID prefix. "
221
+ "Never read memory Markdown files directly."
222
+ )
223
+ input_model = MemoryGetInput
224
+
225
+ async def execute(self, context: ToolContext, arguments: BaseModel) -> ToolResult:
226
+ del context
227
+ parsed = cast(MemoryGetInput, arguments)
228
+ matches = tuple(
229
+ record
230
+ for record in self.service.store.list(project_id=self.service.project_id)
231
+ if record.memory_id.startswith(parsed.memory_id)
232
+ )
233
+ if len(matches) != 1:
234
+ return ToolResult(
235
+ "memory ID does not exist or prefix is not unique",
236
+ is_error=True,
237
+ data={"error": "memory_not_found_or_ambiguous"},
238
+ )
239
+ raw = _record_data(matches[0], include_body=True)
240
+ items, truncated = _bounded([raw], self.max_chars)
241
+ data = {"memory": items[0], "truncated": truncated}
242
+ await self._observe(
243
+ "retrieved",
244
+ {"memory_id": matches[0].memory_id, "status": matches[0].status.value},
245
+ )
246
+ return ToolResult(json.dumps(data, ensure_ascii=False), data=data)
247
+
248
+
249
+ class MemoryWriteTool(_MemoryTool):
250
+ name = "memory_write"
251
+ description = (
252
+ "Write a long-term memory only when the current user explicitly asks to remember it. "
253
+ "User facts, project knowledge, experiences, and references become active; only SOPs "
254
+ "remain candidates. Never claim it was saved before this tool succeeds."
255
+ )
256
+ input_model = MemoryWriteInput
257
+ effects = frozenset({ToolEffect.OUTSIDE_WORKSPACE})
258
+
259
+ def __init__(
260
+ self,
261
+ service: MemoryService,
262
+ observer: MemoryToolObserver,
263
+ *,
264
+ max_chars: int,
265
+ user_prompt: str,
266
+ source: MemorySource,
267
+ enabled_kinds: frozenset[MemoryKind] | None = None,
268
+ ) -> None:
269
+ super().__init__(service, observer, max_chars=max_chars)
270
+ self.user_prompt = user_prompt
271
+ self.source = source
272
+ self.enabled_kinds = frozenset(MemoryKind) if enabled_kinds is None else enabled_kinds
273
+
274
+ @staticmethod
275
+ def _compact(text: str, limit: int) -> str:
276
+ compact = " ".join(text.split()).strip()
277
+ return compact if len(compact) <= limit else compact[: limit - 3].rstrip() + "..."
278
+
279
+ async def execute(self, context: ToolContext, arguments: BaseModel) -> ToolResult:
280
+ del context
281
+ parsed = cast(MemoryWriteInput, arguments)
282
+ if not has_explicit_memory_intent(self.user_prompt):
283
+ return ToolResult(
284
+ "the current user did not explicitly request long-term memory storage",
285
+ is_error=True,
286
+ data={"error": "explicit_memory_intent_required"},
287
+ )
288
+ classified_kind = classify_memory_intent(self.user_prompt)
289
+ kind = (
290
+ MemoryKind.EXPERIENCE
291
+ if classified_kind is MemoryKind.EXPERIENCE
292
+ else parsed.kind or classified_kind
293
+ )
294
+ if kind is None:
295
+ return ToolResult(
296
+ "memory kind could not be determined",
297
+ is_error=True,
298
+ data={"error": "memory_kind_required"},
299
+ )
300
+ if kind not in self.enabled_kinds:
301
+ return ToolResult(
302
+ f"memory kind is disabled: {kind.value}",
303
+ is_error=True,
304
+ data={"error": "memory_kind_disabled", "kind": kind.value},
305
+ )
306
+ project_fact = kind is not MemoryKind.USER_PROFILE and (
307
+ kind is not MemoryKind.REFERENCE or parsed.scope is MemoryScope.PROJECT
308
+ )
309
+ scope = parsed.scope or (MemoryScope.PROJECT if project_fact else MemoryScope.USER)
310
+ if kind is MemoryKind.USER_PROFILE and scope is not MemoryScope.USER:
311
+ return ToolResult(
312
+ "user profile memories must use user scope",
313
+ is_error=True,
314
+ data={"error": "invalid_memory_scope"},
315
+ )
316
+ content = parsed.content.strip()
317
+ try:
318
+ validate_memory_text(content, self.user_prompt)
319
+ except SensitiveMemoryError as exc:
320
+ return ToolResult(str(exc), is_error=True, data={"error": "sensitive_memory_rejected"})
321
+ normalized = " ".join(content.casefold().split())
322
+ existing = next(
323
+ (
324
+ record
325
+ for record in self.service.store.list(project_id=self.service.project_id)
326
+ if record.kind is kind
327
+ and record.scope is scope
328
+ and " ".join(record.body.casefold().split()) == normalized
329
+ ),
330
+ None,
331
+ )
332
+ if existing is not None:
333
+ data = {
334
+ "memory_id": existing.memory_id,
335
+ "status": existing.status.value,
336
+ "kind": existing.kind.value,
337
+ "scope": existing.scope.value,
338
+ "result": "already_exists",
339
+ }
340
+ await self._observe("already_exists", data)
341
+ return ToolResult(json.dumps(data, ensure_ascii=False), data=data)
342
+ activation = None
343
+ if kind is MemoryKind.PROJECT_KNOWLEDGE:
344
+ activation = (
345
+ MemoryActivation.ALWAYS
346
+ if explicitly_always_project_fact(self.user_prompt)
347
+ else MemoryActivation.MANUAL
348
+ )
349
+ candidate = self.service.create_candidate(
350
+ kind=kind,
351
+ scope=scope,
352
+ title=self._compact(content, 80),
353
+ summary=self._compact(content, 240),
354
+ body=content,
355
+ source=self.source,
356
+ evidence=(() if kind is MemoryKind.SOP else (self.user_prompt,)),
357
+ confidence=0.8,
358
+ activation=activation,
359
+ )
360
+ if kind is MemoryKind.SOP:
361
+ record = candidate
362
+ action = "candidate_created"
363
+ else:
364
+ record = self.service.store.transition(candidate.memory_id, MemoryStatus.ACTIVE)
365
+ action = "activated"
366
+ data = {
367
+ "memory_id": record.memory_id,
368
+ "status": record.status.value,
369
+ "kind": record.kind.value,
370
+ "scope": record.scope.value,
371
+ "result": "stored",
372
+ }
373
+ await self._observe(action, data)
374
+ return ToolResult(json.dumps(data, ensure_ascii=False), data=data)
375
+
376
+
377
+ def register_memory_tools(
378
+ registry: ToolRegistry,
379
+ service: MemoryService,
380
+ observer: MemoryToolObserver,
381
+ *,
382
+ max_chars: int,
383
+ user_prompt: str,
384
+ source: MemorySource,
385
+ enabled_kinds: frozenset[MemoryKind] | None = None,
386
+ ) -> None:
387
+ for tool in (
388
+ MemorySearchTool(service, observer, max_chars=max_chars),
389
+ MemoryListTool(service, observer, max_chars=max_chars),
390
+ MemoryGetTool(service, observer, max_chars=max_chars),
391
+ MemoryWriteTool(
392
+ service,
393
+ observer,
394
+ max_chars=max_chars,
395
+ user_prompt=user_prompt,
396
+ source=source,
397
+ enabled_kinds=enabled_kinds,
398
+ ),
399
+ ):
400
+ registry.register(tool)