kiwimatecoder 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.
- kiwimatecoder/__init__.py +8 -0
- kiwimatecoder/__main__.py +12 -0
- kiwimatecoder/acp/__init__.py +13 -0
- kiwimatecoder/acp/protocol.py +115 -0
- kiwimatecoder/acp/server.py +823 -0
- kiwimatecoder/acp/stdio.py +32 -0
- kiwimatecoder/agent.py +1162 -0
- kiwimatecoder/ai.py +68 -0
- kiwimatecoder/audit.py +65 -0
- kiwimatecoder/browser.py +261 -0
- kiwimatecoder/catalog.py +445 -0
- kiwimatecoder/checkpoints.py +103 -0
- kiwimatecoder/client.py +820 -0
- kiwimatecoder/clipboard.py +99 -0
- kiwimatecoder/commands.py +6757 -0
- kiwimatecoder/config.py +5335 -0
- kiwimatecoder/diagnostics.py +295 -0
- kiwimatecoder/documents.py +299 -0
- kiwimatecoder/evals/__init__.py +39 -0
- kiwimatecoder/evals/cases.py +230 -0
- kiwimatecoder/evals/render.py +46 -0
- kiwimatecoder/evals/runner.py +319 -0
- kiwimatecoder/events.py +95 -0
- kiwimatecoder/forge.py +172 -0
- kiwimatecoder/git.py +164 -0
- kiwimatecoder/headless.py +255 -0
- kiwimatecoder/hooks.py +170 -0
- kiwimatecoder/hunks.py +260 -0
- kiwimatecoder/i18n.py +368 -0
- kiwimatecoder/images.py +195 -0
- kiwimatecoder/index/__init__.py +14 -0
- kiwimatecoder/index/builder.py +376 -0
- kiwimatecoder/index/embeddings.py +151 -0
- kiwimatecoder/index/search.py +247 -0
- kiwimatecoder/index/store.py +251 -0
- kiwimatecoder/instructions.py +83 -0
- kiwimatecoder/jobs.py +594 -0
- kiwimatecoder/lsp/__init__.py +56 -0
- kiwimatecoder/lsp/client.py +548 -0
- kiwimatecoder/lsp/manager.py +353 -0
- kiwimatecoder/lsp/protocol.py +79 -0
- kiwimatecoder/main.py +4258 -0
- kiwimatecoder/mcp/__init__.py +45 -0
- kiwimatecoder/mcp/client.py +385 -0
- kiwimatecoder/mcp/errors.py +7 -0
- kiwimatecoder/mcp/manager.py +253 -0
- kiwimatecoder/mcp/protocol.py +132 -0
- kiwimatecoder/media.py +904 -0
- kiwimatecoder/memory.py +141 -0
- kiwimatecoder/network.py +112 -0
- kiwimatecoder/notify.py +139 -0
- kiwimatecoder/permissions.py +171 -0
- kiwimatecoder/pickers.py +417 -0
- kiwimatecoder/plugins.py +245 -0
- kiwimatecoder/pricing.py +167 -0
- kiwimatecoder/prompts.py +213 -0
- kiwimatecoder/providers.py +342 -0
- kiwimatecoder/redaction.py +66 -0
- kiwimatecoder/remote.py +294 -0
- kiwimatecoder/repl.py +1388 -0
- kiwimatecoder/routing.py +65 -0
- kiwimatecoder/sandbox.py +169 -0
- kiwimatecoder/sdk.py +146 -0
- kiwimatecoder/session.py +574 -0
- kiwimatecoder/share.py +386 -0
- kiwimatecoder/shell.py +660 -0
- kiwimatecoder/skills.py +138 -0
- kiwimatecoder/sync.py +513 -0
- kiwimatecoder/team.py +249 -0
- kiwimatecoder/telemetry.py +392 -0
- kiwimatecoder/templates.py +108 -0
- kiwimatecoder/tools/__init__.py +190 -0
- kiwimatecoder/tools/ask.py +62 -0
- kiwimatecoder/tools/base.py +69 -0
- kiwimatecoder/tools/browser.py +261 -0
- kiwimatecoder/tools/edit_file.py +143 -0
- kiwimatecoder/tools/forge.py +207 -0
- kiwimatecoder/tools/git.py +158 -0
- kiwimatecoder/tools/image.py +300 -0
- kiwimatecoder/tools/io.py +346 -0
- kiwimatecoder/tools/list_dir.py +67 -0
- kiwimatecoder/tools/lsp.py +254 -0
- kiwimatecoder/tools/memory.py +115 -0
- kiwimatecoder/tools/paths.py +234 -0
- kiwimatecoder/tools/read_file.py +113 -0
- kiwimatecoder/tools/registry.py +91 -0
- kiwimatecoder/tools/run_bash.py +189 -0
- kiwimatecoder/tools/search.py +224 -0
- kiwimatecoder/tools/selection.py +108 -0
- kiwimatecoder/tools/shell.py +158 -0
- kiwimatecoder/tools/skill.py +46 -0
- kiwimatecoder/tools/task.py +61 -0
- kiwimatecoder/tools/todo.py +77 -0
- kiwimatecoder/tools/web.py +146 -0
- kiwimatecoder/tools/write_file.py +83 -0
- kiwimatecoder/ui.py +189 -0
- kiwimatecoder/updater.py +502 -0
- kiwimatecoder/web.py +499 -0
- kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
- kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
- kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
- kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
- kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
- kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""list_dir tool: list the entries of a directory in the workspace."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from kiwimatecoder.session import Session
|
|
8
|
+
from kiwimatecoder.tools.base import FunctionTool, ToolResult
|
|
9
|
+
from kiwimatecoder.tools.paths import (
|
|
10
|
+
PathError,
|
|
11
|
+
get_workspace_ignore,
|
|
12
|
+
resolve_for_read,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
MAX_ENTRIES = 500
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _list_dir(args: dict[str, Any], session: Session) -> ToolResult:
|
|
19
|
+
path = str(args.get("path", ".") or ".")
|
|
20
|
+
try:
|
|
21
|
+
resolved = resolve_for_read(
|
|
22
|
+
path,
|
|
23
|
+
session.workspace_root,
|
|
24
|
+
trusted=bool(getattr(session, "trusted_workspace", False)),
|
|
25
|
+
)
|
|
26
|
+
except PathError as exc:
|
|
27
|
+
return ToolResult.error(str(exc))
|
|
28
|
+
|
|
29
|
+
if not resolved.exists():
|
|
30
|
+
return ToolResult.error(f"Directory not found: {path}")
|
|
31
|
+
if not resolved.is_dir():
|
|
32
|
+
return ToolResult.error(f"'{path}' is not a directory, use read_file")
|
|
33
|
+
|
|
34
|
+
ignore = get_workspace_ignore(session.workspace_root)
|
|
35
|
+
entries: list[str] = []
|
|
36
|
+
for child in sorted(resolved.iterdir(), key=lambda p: (p.is_file(), p.name)):
|
|
37
|
+
is_dir = child.is_dir()
|
|
38
|
+
if ignore.is_ignored(child, is_dir=is_dir):
|
|
39
|
+
continue
|
|
40
|
+
suffix = "/" if is_dir else ""
|
|
41
|
+
entries.append(child.name + suffix)
|
|
42
|
+
if len(entries) >= MAX_ENTRIES:
|
|
43
|
+
entries.append(f"... [truncated at {MAX_ENTRIES} entries]")
|
|
44
|
+
break
|
|
45
|
+
|
|
46
|
+
if not entries:
|
|
47
|
+
return ToolResult(content="[empty directory]")
|
|
48
|
+
return ToolResult(content="\n".join(entries))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
list_dir_tool = FunctionTool(
|
|
52
|
+
name="list_dir",
|
|
53
|
+
description="List the files and subdirectories of a directory in the workspace.",
|
|
54
|
+
parameters={
|
|
55
|
+
"type": "object",
|
|
56
|
+
"properties": {
|
|
57
|
+
"path": {
|
|
58
|
+
"type": "string",
|
|
59
|
+
"description": (
|
|
60
|
+
"Directory path relative to the workspace root. "
|
|
61
|
+
+ "Defaults to the workspace root."
|
|
62
|
+
),
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
func=_list_dir,
|
|
67
|
+
)
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Read-only LSP tools: diagnostics, definitions, and references.
|
|
2
|
+
|
|
3
|
+
These tools never mutate the workspace (``writes=False, runs=False``). They
|
|
4
|
+
report a clear error when LSP is disabled or no configured server matches the
|
|
5
|
+
file, and they never start a server under an extension the presets/overrides do
|
|
6
|
+
not claim.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from kiwimatecoder import config, lsp
|
|
15
|
+
from kiwimatecoder.session import Session
|
|
16
|
+
from kiwimatecoder.tools.base import FunctionTool, ToolResult
|
|
17
|
+
from kiwimatecoder.tools.paths import PathError, display_path, resolve_in_workspace
|
|
18
|
+
|
|
19
|
+
MAX_DIAGNOSTIC_FILES = 5
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _disabled_error() -> ToolResult:
|
|
23
|
+
return ToolResult.error(lsp.LSP_DISABLED_MESSAGE)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def format_diagnostic(path: str, diagnostic: dict[str, Any]) -> str:
|
|
27
|
+
line = (
|
|
28
|
+
f"{path}:{diagnostic['line']}:{diagnostic['character']} "
|
|
29
|
+
f"{diagnostic['severity']} {diagnostic['message']}"
|
|
30
|
+
)
|
|
31
|
+
source = diagnostic.get("source")
|
|
32
|
+
if source:
|
|
33
|
+
line += f" ({source})"
|
|
34
|
+
return line.rstrip()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _format_location(location: dict[str, Any], session: Session) -> str:
|
|
38
|
+
raw_path = str(location.get("path") or "")
|
|
39
|
+
display = raw_path
|
|
40
|
+
if raw_path:
|
|
41
|
+
try:
|
|
42
|
+
display = display_path(Path(raw_path), session.workspace_root)
|
|
43
|
+
except (OSError, ValueError):
|
|
44
|
+
display = raw_path
|
|
45
|
+
return f"{display}:{location.get('line')}:{location.get('character')}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _parse_position(args: dict[str, Any]) -> tuple[int, int] | ToolResult:
|
|
49
|
+
raw_line = args.get("line")
|
|
50
|
+
raw_character = args.get("character")
|
|
51
|
+
if raw_line is None or raw_character is None:
|
|
52
|
+
return ToolResult.error("'line' and 'character' are required (1-based)")
|
|
53
|
+
try:
|
|
54
|
+
line = int(raw_line)
|
|
55
|
+
character = int(raw_character)
|
|
56
|
+
except (TypeError, ValueError):
|
|
57
|
+
return ToolResult.error("'line' and 'character' must be integers (1-based)")
|
|
58
|
+
if line < 1 or character < 1:
|
|
59
|
+
return ToolResult.error("'line' and 'character' are 1-based and must be >= 1")
|
|
60
|
+
return line, character
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _include_declaration(args: dict[str, Any]) -> bool:
|
|
64
|
+
value = args.get("include_declaration")
|
|
65
|
+
if value is None:
|
|
66
|
+
return True
|
|
67
|
+
if isinstance(value, bool):
|
|
68
|
+
return value
|
|
69
|
+
return str(value).strip().lower() not in {"false", "0", "no", "off"}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _lsp_diagnostics(args: dict[str, Any], session: Session) -> ToolResult:
|
|
73
|
+
if not config.get_lsp()["enabled"]:
|
|
74
|
+
return _disabled_error()
|
|
75
|
+
raw_path = str(args.get("path") or "").strip()
|
|
76
|
+
if raw_path:
|
|
77
|
+
targets = [raw_path]
|
|
78
|
+
else:
|
|
79
|
+
targets = list(session.touched_files)[-MAX_DIAGNOSTIC_FILES:]
|
|
80
|
+
if not targets:
|
|
81
|
+
return ToolResult.error(
|
|
82
|
+
"No file given and none have been touched yet; pass a path."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
manager = lsp.ensure_manager(root=session.workspace_root)
|
|
86
|
+
lines: list[str] = []
|
|
87
|
+
failures: list[str] = []
|
|
88
|
+
for target in targets:
|
|
89
|
+
try:
|
|
90
|
+
resolved = resolve_in_workspace(target, session.workspace_root)
|
|
91
|
+
except PathError as exc:
|
|
92
|
+
failures.append(str(exc))
|
|
93
|
+
continue
|
|
94
|
+
display = display_path(resolved, session.workspace_root)
|
|
95
|
+
try:
|
|
96
|
+
diagnostics = manager.diagnostics_for(resolved)
|
|
97
|
+
except lsp.LspError as exc:
|
|
98
|
+
failures.append(f"{display}: {exc}")
|
|
99
|
+
continue
|
|
100
|
+
lines.extend(format_diagnostic(display, item) for item in diagnostics)
|
|
101
|
+
|
|
102
|
+
if lines:
|
|
103
|
+
return ToolResult(content="\n".join(lines))
|
|
104
|
+
if failures:
|
|
105
|
+
return ToolResult.error("; ".join(failures[:3]))
|
|
106
|
+
return ToolResult(content="No diagnostics.")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _lsp_definition(args: dict[str, Any], session: Session) -> ToolResult:
|
|
110
|
+
if not config.get_lsp()["enabled"]:
|
|
111
|
+
return _disabled_error()
|
|
112
|
+
path = str(args.get("path") or "").strip()
|
|
113
|
+
if not path:
|
|
114
|
+
return ToolResult.error("'path' is required")
|
|
115
|
+
position = _parse_position(args)
|
|
116
|
+
if isinstance(position, ToolResult):
|
|
117
|
+
return position
|
|
118
|
+
line, character = position
|
|
119
|
+
try:
|
|
120
|
+
resolved = resolve_in_workspace(path, session.workspace_root)
|
|
121
|
+
except PathError as exc:
|
|
122
|
+
return ToolResult.error(str(exc))
|
|
123
|
+
|
|
124
|
+
manager = lsp.ensure_manager(root=session.workspace_root)
|
|
125
|
+
try:
|
|
126
|
+
locations = manager.definition(resolved, line, character)
|
|
127
|
+
except lsp.LspError as exc:
|
|
128
|
+
return ToolResult.error(str(exc))
|
|
129
|
+
if not locations:
|
|
130
|
+
return ToolResult(
|
|
131
|
+
content=f"No definition found at {path}:{line}:{character}."
|
|
132
|
+
)
|
|
133
|
+
return ToolResult(
|
|
134
|
+
content="\n".join(_format_location(item, session) for item in locations)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _lsp_references(args: dict[str, Any], session: Session) -> ToolResult:
|
|
139
|
+
if not config.get_lsp()["enabled"]:
|
|
140
|
+
return _disabled_error()
|
|
141
|
+
path = str(args.get("path") or "").strip()
|
|
142
|
+
if not path:
|
|
143
|
+
return ToolResult.error("'path' is required")
|
|
144
|
+
position = _parse_position(args)
|
|
145
|
+
if isinstance(position, ToolResult):
|
|
146
|
+
return position
|
|
147
|
+
line, character = position
|
|
148
|
+
try:
|
|
149
|
+
resolved = resolve_in_workspace(path, session.workspace_root)
|
|
150
|
+
except PathError as exc:
|
|
151
|
+
return ToolResult.error(str(exc))
|
|
152
|
+
|
|
153
|
+
manager = lsp.ensure_manager(root=session.workspace_root)
|
|
154
|
+
try:
|
|
155
|
+
locations = manager.references(
|
|
156
|
+
resolved,
|
|
157
|
+
line,
|
|
158
|
+
character,
|
|
159
|
+
include_declaration=_include_declaration(args),
|
|
160
|
+
)
|
|
161
|
+
except lsp.LspError as exc:
|
|
162
|
+
return ToolResult.error(str(exc))
|
|
163
|
+
if not locations:
|
|
164
|
+
return ToolResult(
|
|
165
|
+
content=f"No references found at {path}:{line}:{character}."
|
|
166
|
+
)
|
|
167
|
+
return ToolResult(
|
|
168
|
+
content="\n".join(_format_location(item, session) for item in locations)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
lsp_diagnostics_tool = FunctionTool(
|
|
173
|
+
name="lsp_diagnostics",
|
|
174
|
+
description=(
|
|
175
|
+
"Get language-server diagnostics (errors, warnings) for a file, or for "
|
|
176
|
+
"the most recently touched files when no path is given. Requires LSP to "
|
|
177
|
+
"be enabled and a matching server installed. Use it after edits to "
|
|
178
|
+
"check the code compiles/type-checks."
|
|
179
|
+
),
|
|
180
|
+
parameters={
|
|
181
|
+
"type": "object",
|
|
182
|
+
"properties": {
|
|
183
|
+
"path": {
|
|
184
|
+
"type": "string",
|
|
185
|
+
"description": "File to check, relative to the workspace root.",
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
func=_lsp_diagnostics,
|
|
190
|
+
writes=False,
|
|
191
|
+
runs=False,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
lsp_definition_tool = FunctionTool(
|
|
195
|
+
name="lsp_definition",
|
|
196
|
+
description=(
|
|
197
|
+
"Find where the symbol at a 1-based line/character is defined using the "
|
|
198
|
+
"language server. Returns one location per line."
|
|
199
|
+
),
|
|
200
|
+
parameters={
|
|
201
|
+
"type": "object",
|
|
202
|
+
"properties": {
|
|
203
|
+
"path": {
|
|
204
|
+
"type": "string",
|
|
205
|
+
"description": "File to inspect, relative to the workspace root.",
|
|
206
|
+
},
|
|
207
|
+
"line": {
|
|
208
|
+
"type": "integer",
|
|
209
|
+
"description": "1-based line number of the symbol.",
|
|
210
|
+
},
|
|
211
|
+
"character": {
|
|
212
|
+
"type": "integer",
|
|
213
|
+
"description": "1-based column number of the symbol.",
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
"required": ["path", "line", "character"],
|
|
217
|
+
},
|
|
218
|
+
func=_lsp_definition,
|
|
219
|
+
writes=False,
|
|
220
|
+
runs=False,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
lsp_references_tool = FunctionTool(
|
|
224
|
+
name="lsp_references",
|
|
225
|
+
description=(
|
|
226
|
+
"Find every reference to the symbol at a 1-based line/character using "
|
|
227
|
+
"the language server. Returns one location per line."
|
|
228
|
+
),
|
|
229
|
+
parameters={
|
|
230
|
+
"type": "object",
|
|
231
|
+
"properties": {
|
|
232
|
+
"path": {
|
|
233
|
+
"type": "string",
|
|
234
|
+
"description": "File to inspect, relative to the workspace root.",
|
|
235
|
+
},
|
|
236
|
+
"line": {
|
|
237
|
+
"type": "integer",
|
|
238
|
+
"description": "1-based line number of the symbol.",
|
|
239
|
+
},
|
|
240
|
+
"character": {
|
|
241
|
+
"type": "integer",
|
|
242
|
+
"description": "1-based column number of the symbol.",
|
|
243
|
+
},
|
|
244
|
+
"include_declaration": {
|
|
245
|
+
"type": "boolean",
|
|
246
|
+
"description": "Include the declaration itself (default true).",
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
"required": ["path", "line", "character"],
|
|
250
|
+
},
|
|
251
|
+
func=_lsp_references,
|
|
252
|
+
writes=False,
|
|
253
|
+
runs=False,
|
|
254
|
+
)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""remember (approval-gated) and recall (read-only) persistent-memory tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from kiwimatecoder import config
|
|
8
|
+
from kiwimatecoder import memory as memory_module
|
|
9
|
+
from kiwimatecoder.redaction import redact
|
|
10
|
+
from kiwimatecoder.session import Session
|
|
11
|
+
from kiwimatecoder.tools.base import FunctionTool, ToolResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _remember(args: dict[str, Any], session: Session) -> ToolResult:
|
|
15
|
+
content = str(args.get("content") or "").strip()
|
|
16
|
+
if not content:
|
|
17
|
+
return ToolResult.error("'content' is required")
|
|
18
|
+
scope = str(args.get("scope") or "project").strip().lower()
|
|
19
|
+
try:
|
|
20
|
+
path = memory_module.append_memory(scope, session.workspace_root, content)
|
|
21
|
+
except ValueError as exc:
|
|
22
|
+
return ToolResult.error(str(exc))
|
|
23
|
+
return ToolResult(content=f"Remembered ({scope}): {path}\n- {content}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _recall(args: dict[str, Any], session: Session) -> ToolResult:
|
|
27
|
+
query = str(args.get("query") or "").strip()
|
|
28
|
+
settings = config.get_memory()
|
|
29
|
+
cap = int(settings["max_bytes"])
|
|
30
|
+
sections: list[str] = []
|
|
31
|
+
for scope in memory_module.SCOPES:
|
|
32
|
+
text = memory_module.read_memory(scope, session.workspace_root, cap)
|
|
33
|
+
if not text:
|
|
34
|
+
continue
|
|
35
|
+
if query:
|
|
36
|
+
text = "\n".join(
|
|
37
|
+
line
|
|
38
|
+
for line in text.splitlines()
|
|
39
|
+
if query.lower() in line.lower()
|
|
40
|
+
)
|
|
41
|
+
if not text.strip():
|
|
42
|
+
continue
|
|
43
|
+
path = memory_module.memory_path(scope, session.workspace_root)
|
|
44
|
+
sections.append(f"{scope} memory ({path}):\n{text}")
|
|
45
|
+
if not sections:
|
|
46
|
+
if query:
|
|
47
|
+
return ToolResult(content=f"No memory entries match {query!r}.")
|
|
48
|
+
return ToolResult(content="No memory stored yet.")
|
|
49
|
+
body = "\n\n".join(sections)
|
|
50
|
+
if len(body) > cap:
|
|
51
|
+
body = body[:cap] + "\n[truncated]"
|
|
52
|
+
return ToolResult(content=body)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def preview(args: dict[str, Any], session: Session) -> str:
|
|
56
|
+
"""Show the target file and bullet for the approval prompt."""
|
|
57
|
+
scope = str(args.get("scope") or "project").strip().lower()
|
|
58
|
+
try:
|
|
59
|
+
path = memory_module.memory_path(scope, session.workspace_root)
|
|
60
|
+
except ValueError as exc:
|
|
61
|
+
return str(exc)
|
|
62
|
+
content = str(args.get("content") or "").strip()
|
|
63
|
+
if not content:
|
|
64
|
+
return f"Append to memory {path}: (empty; will be rejected)"
|
|
65
|
+
return f"Append to memory {path}:\n- {redact(content)}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
remember_tool = FunctionTool(
|
|
69
|
+
name="remember",
|
|
70
|
+
description=(
|
|
71
|
+
"Persist a durable fact about the user or this project so it survives "
|
|
72
|
+
"future sessions. Use scope='project' for workspace-specific facts and "
|
|
73
|
+
"scope='user' for preferences that apply everywhere. Requires approval; "
|
|
74
|
+
"never store secrets, credentials, or API keys."
|
|
75
|
+
),
|
|
76
|
+
parameters={
|
|
77
|
+
"type": "object",
|
|
78
|
+
"properties": {
|
|
79
|
+
"content": {
|
|
80
|
+
"type": "string",
|
|
81
|
+
"description": "The fact to remember, as a short statement.",
|
|
82
|
+
},
|
|
83
|
+
"scope": {
|
|
84
|
+
"type": "string",
|
|
85
|
+
"enum": list(memory_module.SCOPES),
|
|
86
|
+
"description": (
|
|
87
|
+
"Where to store it: project (this workspace) or user "
|
|
88
|
+
"(all workspaces). Defaults to project."
|
|
89
|
+
),
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
"required": ["content"],
|
|
93
|
+
},
|
|
94
|
+
func=_remember,
|
|
95
|
+
writes=True,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
recall_tool = FunctionTool(
|
|
99
|
+
name="recall",
|
|
100
|
+
description=(
|
|
101
|
+
"Read persistent memory for the project and user. Pass a query to "
|
|
102
|
+
"filter entries by case-insensitive substring. Read-only, so it is "
|
|
103
|
+
"available in plan mode."
|
|
104
|
+
),
|
|
105
|
+
parameters={
|
|
106
|
+
"type": "object",
|
|
107
|
+
"properties": {
|
|
108
|
+
"query": {
|
|
109
|
+
"type": "string",
|
|
110
|
+
"description": "Optional case-insensitive filter.",
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
func=_recall,
|
|
115
|
+
)
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Workspace path resolution and sandboxing.
|
|
2
|
+
|
|
3
|
+
Every file-touching tool resolves its path through :func:`resolve_in_workspace`,
|
|
4
|
+
which rejects paths that escape the workspace root (via ``..`` or symlinks).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import fnmatch
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
DEFAULT_SKIP_DIRS: set[str] = {
|
|
14
|
+
".git",
|
|
15
|
+
"__pycache__",
|
|
16
|
+
".venv",
|
|
17
|
+
"venv",
|
|
18
|
+
"env",
|
|
19
|
+
"node_modules",
|
|
20
|
+
".mypy_cache",
|
|
21
|
+
".pytest_cache",
|
|
22
|
+
".ruff_cache",
|
|
23
|
+
".tox",
|
|
24
|
+
".eggs",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class WorkspaceIgnore:
|
|
29
|
+
"""Evaluates whether paths should be ignored based on default skip dirs and .gitignore."""
|
|
30
|
+
|
|
31
|
+
workspace_root: Path
|
|
32
|
+
rules: list[tuple[bool, str, bool]]
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
workspace_root: Path,
|
|
37
|
+
rules: list[tuple[bool, str, bool]] | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
self.workspace_root = workspace_root.resolve()
|
|
40
|
+
self.rules = (
|
|
41
|
+
rules if rules is not None else self._load_rules()
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def _load_rules(self) -> list[tuple[bool, str, bool]]:
|
|
45
|
+
gitignore_path = self.workspace_root / ".gitignore"
|
|
46
|
+
if not gitignore_path.is_file():
|
|
47
|
+
return []
|
|
48
|
+
rules: list[tuple[bool, str, bool]] = []
|
|
49
|
+
try:
|
|
50
|
+
content = gitignore_path.read_text(encoding="utf-8", errors="replace")
|
|
51
|
+
except OSError:
|
|
52
|
+
return []
|
|
53
|
+
|
|
54
|
+
for line in content.splitlines():
|
|
55
|
+
line = line.strip()
|
|
56
|
+
if not line or line.startswith("#"):
|
|
57
|
+
continue
|
|
58
|
+
is_neg = line.startswith("!")
|
|
59
|
+
if is_neg:
|
|
60
|
+
line = line[1:].strip()
|
|
61
|
+
if not line:
|
|
62
|
+
continue
|
|
63
|
+
dir_only = line.endswith("/")
|
|
64
|
+
if dir_only:
|
|
65
|
+
line = line[:-1]
|
|
66
|
+
if line.startswith("/"):
|
|
67
|
+
line = line[1:]
|
|
68
|
+
rules.append((is_neg, line, dir_only))
|
|
69
|
+
return rules
|
|
70
|
+
|
|
71
|
+
def is_ignored(self, path: Path, is_dir: bool = False) -> bool:
|
|
72
|
+
"""Check if path should be ignored."""
|
|
73
|
+
try:
|
|
74
|
+
resolved = path.resolve()
|
|
75
|
+
except OSError:
|
|
76
|
+
resolved = path
|
|
77
|
+
|
|
78
|
+
parts = resolved.parts
|
|
79
|
+
if any(part in DEFAULT_SKIP_DIRS for part in parts):
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
if not self.rules:
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
rel = resolved.relative_to(self.workspace_root).as_posix()
|
|
87
|
+
except ValueError:
|
|
88
|
+
rel = path.as_posix()
|
|
89
|
+
|
|
90
|
+
ignored = False
|
|
91
|
+
name = resolved.name
|
|
92
|
+
for is_neg, pat, dir_only in self.rules:
|
|
93
|
+
matches = False
|
|
94
|
+
if dir_only:
|
|
95
|
+
if is_dir:
|
|
96
|
+
if "/" in pat:
|
|
97
|
+
matches = fnmatch.fnmatch(rel, pat) or fnmatch.fnmatch(
|
|
98
|
+
rel, f"{pat}/*"
|
|
99
|
+
)
|
|
100
|
+
else:
|
|
101
|
+
matches = (
|
|
102
|
+
fnmatch.fnmatch(name, pat)
|
|
103
|
+
or fnmatch.fnmatch(rel, f"**/{pat}")
|
|
104
|
+
or fnmatch.fnmatch(rel, f"{pat}/*")
|
|
105
|
+
)
|
|
106
|
+
else:
|
|
107
|
+
if "/" in pat:
|
|
108
|
+
matches = fnmatch.fnmatch(
|
|
109
|
+
rel, f"{pat}/*"
|
|
110
|
+
) or fnmatch.fnmatch(rel, f"**/{pat}/*")
|
|
111
|
+
else:
|
|
112
|
+
matches = (
|
|
113
|
+
fnmatch.fnmatch(rel, f"{pat}/*")
|
|
114
|
+
or fnmatch.fnmatch(rel, f"**/{pat}/*")
|
|
115
|
+
or (pat in resolved.parts)
|
|
116
|
+
)
|
|
117
|
+
else:
|
|
118
|
+
if "/" in pat:
|
|
119
|
+
matches = fnmatch.fnmatch(rel, pat) or fnmatch.fnmatch(
|
|
120
|
+
rel, f"{pat}/*"
|
|
121
|
+
)
|
|
122
|
+
else:
|
|
123
|
+
matches = (
|
|
124
|
+
fnmatch.fnmatch(name, pat)
|
|
125
|
+
or fnmatch.fnmatch(rel, f"**/{pat}")
|
|
126
|
+
or fnmatch.fnmatch(rel, f"{pat}/*")
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
if matches:
|
|
130
|
+
ignored = not is_neg
|
|
131
|
+
|
|
132
|
+
return ignored
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
_IGNORE_CACHE: dict[str, tuple[float, WorkspaceIgnore]] = {}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def get_workspace_ignore(workspace_root: Path) -> WorkspaceIgnore:
|
|
139
|
+
"""Return a cached WorkspaceIgnore instance for the given root."""
|
|
140
|
+
root = workspace_root.resolve()
|
|
141
|
+
key = str(root)
|
|
142
|
+
gi_path = root / ".gitignore"
|
|
143
|
+
try:
|
|
144
|
+
mtime = gi_path.stat().st_mtime if gi_path.exists() else 0.0
|
|
145
|
+
except OSError:
|
|
146
|
+
mtime = 0.0
|
|
147
|
+
cached = _IGNORE_CACHE.get(key)
|
|
148
|
+
if cached is not None and cached[0] == mtime:
|
|
149
|
+
return cached[1]
|
|
150
|
+
ignore = WorkspaceIgnore(root)
|
|
151
|
+
_IGNORE_CACHE[key] = (mtime, ignore)
|
|
152
|
+
return ignore
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class PathError(ValueError):
|
|
156
|
+
"""Raised when a path escapes the workspace root."""
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def atomic_write_text(path: Path, content: str) -> None:
|
|
160
|
+
"""Write ``content`` to ``path`` atomically as UTF-8.
|
|
161
|
+
|
|
162
|
+
Writes to a sibling temp file then ``os.replace``s it into place, so a
|
|
163
|
+
crash mid-write cannot truncate the destination. Parent directories must
|
|
164
|
+
already exist.
|
|
165
|
+
"""
|
|
166
|
+
tmp = path.with_name(path.name + ".kiwi.tmp")
|
|
167
|
+
try:
|
|
168
|
+
tmp.write_text(content, encoding="utf-8")
|
|
169
|
+
os.replace(tmp, path)
|
|
170
|
+
except OSError:
|
|
171
|
+
try:
|
|
172
|
+
tmp.unlink()
|
|
173
|
+
except OSError:
|
|
174
|
+
pass
|
|
175
|
+
raise
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def resolve_in_workspace(path: str, workspace_root: Path) -> Path:
|
|
179
|
+
"""Resolve ``path`` against ``workspace_root`` and ensure it stays inside.
|
|
180
|
+
|
|
181
|
+
Symlinks are resolved before the containment check so they cannot be used
|
|
182
|
+
to escape the sandbox.
|
|
183
|
+
"""
|
|
184
|
+
root = workspace_root.resolve()
|
|
185
|
+
candidate = Path(path)
|
|
186
|
+
if not candidate.is_absolute():
|
|
187
|
+
candidate = root / candidate
|
|
188
|
+
resolved = candidate.resolve()
|
|
189
|
+
if resolved != root and not resolved.is_relative_to(root):
|
|
190
|
+
raise PathError(
|
|
191
|
+
f"Path '{path}' is outside the workspace root ({root}). "
|
|
192
|
+
+ "Access is restricted to the current project directory."
|
|
193
|
+
)
|
|
194
|
+
return resolved
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def resolve_for_read(
|
|
198
|
+
path: str, workspace_root: Path, trusted: bool = False
|
|
199
|
+
) -> Path:
|
|
200
|
+
"""Resolve a read-only path, allowing escapes only in trusted mode.
|
|
201
|
+
|
|
202
|
+
Trusted workspaces still prevent writes outside the root because only
|
|
203
|
+
read-only tools call this helper.
|
|
204
|
+
"""
|
|
205
|
+
if not trusted:
|
|
206
|
+
return resolve_in_workspace(path, workspace_root)
|
|
207
|
+
candidate = Path(path).expanduser()
|
|
208
|
+
if not candidate.is_absolute():
|
|
209
|
+
candidate = workspace_root / candidate
|
|
210
|
+
return candidate.resolve()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def display_path(path: Path, workspace_root: Path) -> str:
|
|
214
|
+
"""Return a path relative to the workspace root for display, if possible."""
|
|
215
|
+
root = workspace_root.resolve()
|
|
216
|
+
try:
|
|
217
|
+
return str(path.resolve().relative_to(root))
|
|
218
|
+
except ValueError:
|
|
219
|
+
return str(path)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def workspace_ref(path: Path, workspace_root: Path) -> str:
|
|
223
|
+
"""Return the portable form of a workspace-relative path, for storing in the session.
|
|
224
|
+
|
|
225
|
+
Unlike :func:`display_path` this always uses forward slashes, so entries in
|
|
226
|
+
``touched_files`` / ``context_files`` mean the same thing in a session JSON saved
|
|
227
|
+
on Windows and loaded on POSIX (or vice versa). A path outside the workspace is
|
|
228
|
+
returned unchanged.
|
|
229
|
+
"""
|
|
230
|
+
root = workspace_root.resolve()
|
|
231
|
+
try:
|
|
232
|
+
return path.resolve().relative_to(root).as_posix()
|
|
233
|
+
except ValueError:
|
|
234
|
+
return str(path)
|