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.
- windcode/__init__.py +6 -0
- windcode/__main__.py +4 -0
- windcode/auth/__init__.py +11 -0
- windcode/auth/store.py +90 -0
- windcode/cli.py +181 -0
- windcode/config/__init__.py +41 -0
- windcode/config/loader.py +117 -0
- windcode/config/models.py +203 -0
- windcode/config/writer.py +74 -0
- windcode/context/__init__.py +18 -0
- windcode/context/compactor.py +72 -0
- windcode/context/estimator.py +89 -0
- windcode/context/truncation.py +87 -0
- windcode/domain/__init__.py +1 -0
- windcode/domain/errors.py +33 -0
- windcode/domain/events.py +597 -0
- windcode/domain/messages.py +226 -0
- windcode/domain/models.py +73 -0
- windcode/domain/subagents.py +263 -0
- windcode/domain/tools.py +55 -0
- windcode/extensions/__init__.py +27 -0
- windcode/extensions/commands.py +25 -0
- windcode/extensions/discovery.py +139 -0
- windcode/extensions/events.py +58 -0
- windcode/extensions/hooks/__init__.py +4 -0
- windcode/extensions/hooks/dispatcher.py +107 -0
- windcode/extensions/hooks/executor.py +49 -0
- windcode/extensions/hooks/loader.py +101 -0
- windcode/extensions/hooks/models.py +109 -0
- windcode/extensions/mcp/__init__.py +10 -0
- windcode/extensions/mcp/adapter.py +101 -0
- windcode/extensions/mcp/catalog.py +153 -0
- windcode/extensions/mcp/client.py +273 -0
- windcode/extensions/mcp/runtime.py +144 -0
- windcode/extensions/mcp/tools.py +570 -0
- windcode/extensions/models.py +168 -0
- windcode/extensions/paths.py +71 -0
- windcode/extensions/plugins/__init__.py +3 -0
- windcode/extensions/plugins/installer.py +93 -0
- windcode/extensions/plugins/manifest.py +166 -0
- windcode/extensions/runtime.py +366 -0
- windcode/extensions/service.py +437 -0
- windcode/extensions/skills/__init__.py +3 -0
- windcode/extensions/skills/loader.py +67 -0
- windcode/extensions/skills/parser.py +57 -0
- windcode/extensions/skills/tools.py +213 -0
- windcode/extensions/snapshot.py +57 -0
- windcode/extensions/state.py +158 -0
- windcode/instructions/__init__.py +8 -0
- windcode/instructions/loader.py +50 -0
- windcode/memory/__init__.py +57 -0
- windcode/memory/extraction.py +83 -0
- windcode/memory/models.py +218 -0
- windcode/memory/refiner.py +189 -0
- windcode/memory/security.py +23 -0
- windcode/memory/service.py +179 -0
- windcode/memory/store.py +362 -0
- windcode/observability/__init__.py +4 -0
- windcode/observability/redaction.py +95 -0
- windcode/observability/trace.py +115 -0
- windcode/policy/__init__.py +22 -0
- windcode/policy/engine.py +137 -0
- windcode/policy/models.py +69 -0
- windcode/providers/__init__.py +29 -0
- windcode/providers/_utils.py +19 -0
- windcode/providers/anthropic.py +215 -0
- windcode/providers/base.py +46 -0
- windcode/providers/catalog.py +119 -0
- windcode/providers/errors.py +96 -0
- windcode/providers/openai_compat.py +231 -0
- windcode/providers/openai_responses.py +189 -0
- windcode/providers/registry.py +105 -0
- windcode/runtime/__init__.py +15 -0
- windcode/runtime/control.py +89 -0
- windcode/runtime/event_bus.py +67 -0
- windcode/runtime/loop.py +510 -0
- windcode/runtime/prompts.py +132 -0
- windcode/runtime/report.py +64 -0
- windcode/runtime/retry.py +73 -0
- windcode/runtime/scheduler.py +194 -0
- windcode/runtime/subagents/__init__.py +28 -0
- windcode/runtime/subagents/approvals.py +88 -0
- windcode/runtime/subagents/budgets.py +79 -0
- windcode/runtime/subagents/coordinator.py +714 -0
- windcode/runtime/subagents/factory.py +311 -0
- windcode/runtime/subagents/roles.py +74 -0
- windcode/runtime/subagents/verification.py +53 -0
- windcode/sandbox/__init__.py +3 -0
- windcode/sandbox/bwrap.py +79 -0
- windcode/sdk.py +1259 -0
- windcode/sessions/__init__.py +23 -0
- windcode/sessions/artifacts.py +69 -0
- windcode/sessions/models.py +104 -0
- windcode/sessions/store.py +167 -0
- windcode/sessions/tree.py +35 -0
- windcode/tools/__init__.py +10 -0
- windcode/tools/apply_patch.py +191 -0
- windcode/tools/ask_user.py +58 -0
- windcode/tools/builtins.py +44 -0
- windcode/tools/edit_file.py +54 -0
- windcode/tools/filesystem.py +77 -0
- windcode/tools/glob.py +35 -0
- windcode/tools/grep.py +66 -0
- windcode/tools/memory.py +400 -0
- windcode/tools/read_file.py +54 -0
- windcode/tools/registry.py +120 -0
- windcode/tools/shell.py +159 -0
- windcode/tools/subagents/__init__.py +31 -0
- windcode/tools/subagents/cancel.py +35 -0
- windcode/tools/subagents/integrate.py +54 -0
- windcode/tools/subagents/list.py +46 -0
- windcode/tools/subagents/spawn.py +86 -0
- windcode/tools/subagents/wait.py +74 -0
- windcode/tools/write_file.py +61 -0
- windcode/tui/__init__.py +3 -0
- windcode/tui/app.py +1015 -0
- windcode/tui/commands.py +90 -0
- windcode/tui/permission_display.py +24 -0
- windcode/tui/styles.tcss +591 -0
- windcode/tui/widgets/__init__.py +31 -0
- windcode/tui/widgets/approval.py +98 -0
- windcode/tui/widgets/command_menu.py +90 -0
- windcode/tui/widgets/extensions.py +48 -0
- windcode/tui/widgets/input.py +110 -0
- windcode/tui/widgets/memory.py +143 -0
- windcode/tui/widgets/messages.py +299 -0
- windcode/tui/widgets/models.py +565 -0
- windcode/tui/widgets/question.py +46 -0
- windcode/tui/widgets/sessions.py +68 -0
- windcode/tui/widgets/status.py +46 -0
- windcode/tui/widgets/subagents.py +195 -0
- windcode/tui/widgets/tools.py +66 -0
- windcode/tui/widgets/welcome.py +149 -0
- windcode/types.py +80 -0
- windcode/worktrees/__init__.py +24 -0
- windcode/worktrees/git.py +92 -0
- windcode/worktrees/manager.py +265 -0
- windcode/worktrees/models.py +62 -0
- windcode-0.1.0.dist-info/METADATA +207 -0
- windcode-0.1.0.dist-info/RECORD +143 -0
- windcode-0.1.0.dist-info/WHEEL +4 -0
- windcode-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|
windcode/tools/memory.py
ADDED
|
@@ -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)
|