seedcode-cli 6.1.5__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 (114) hide show
  1. seedcode/__init__.py +14 -0
  2. seedcode/__main__.py +12 -0
  3. seedcode/app.py +508 -0
  4. seedcode/apps/__init__.py +32 -0
  5. seedcode/apps/discovery.py +241 -0
  6. seedcode/apps/installer.py +164 -0
  7. seedcode/apps/launcher.py +156 -0
  8. seedcode/apps/verifier.py +119 -0
  9. seedcode/assets/logo.txt +15 -0
  10. seedcode/cli.py +95 -0
  11. seedcode/commands/__init__.py +81 -0
  12. seedcode/commands/about.py +34 -0
  13. seedcode/commands/agent.py +94 -0
  14. seedcode/commands/assist.py +201 -0
  15. seedcode/commands/clear.py +20 -0
  16. seedcode/commands/desktop.py +104 -0
  17. seedcode/commands/doctor.py +152 -0
  18. seedcode/commands/help.py +61 -0
  19. seedcode/commands/history.py +365 -0
  20. seedcode/commands/palette.py +100 -0
  21. seedcode/commands/provider.py +451 -0
  22. seedcode/commands/theme.py +76 -0
  23. seedcode/computer/__init__.py +98 -0
  24. seedcode/computer/browser.py +276 -0
  25. seedcode/computer/browser_cdp.py +567 -0
  26. seedcode/computer/browser_engine.py +546 -0
  27. seedcode/computer/browser_extract.py +301 -0
  28. seedcode/computer/browser_popups.py +329 -0
  29. seedcode/computer/browser_selenium.py +209 -0
  30. seedcode/computer/browser_skills.py +245 -0
  31. seedcode/computer/catalog.py +200 -0
  32. seedcode/computer/controller.py +324 -0
  33. seedcode/computer/dispatcher.py +272 -0
  34. seedcode/computer/dpi.py +185 -0
  35. seedcode/computer/engine.py +105 -0
  36. seedcode/computer/keyboard.py +101 -0
  37. seedcode/computer/logbook.py +104 -0
  38. seedcode/computer/mouse.py +48 -0
  39. seedcode/computer/ocr.py +213 -0
  40. seedcode/computer/operator_skills.py +577 -0
  41. seedcode/computer/permissions.py +203 -0
  42. seedcode/computer/recovery.py +115 -0
  43. seedcode/computer/registry.py +107 -0
  44. seedcode/computer/resolver.py +434 -0
  45. seedcode/computer/screen.py +130 -0
  46. seedcode/computer/screen_state.py +412 -0
  47. seedcode/computer/selfguard.py +197 -0
  48. seedcode/computer/semantic.py +100 -0
  49. seedcode/computer/skills.py +139 -0
  50. seedcode/computer/state.py +199 -0
  51. seedcode/computer/verifier.py +177 -0
  52. seedcode/computer/vision.py +327 -0
  53. seedcode/computer/windows.py +217 -0
  54. seedcode/config/__init__.py +8 -0
  55. seedcode/config/defaults.py +22 -0
  56. seedcode/config/manager.py +62 -0
  57. seedcode/core/__init__.py +31 -0
  58. seedcode/core/agent.py +534 -0
  59. seedcode/core/chat.py +128 -0
  60. seedcode/core/client.py +9 -0
  61. seedcode/core/errors.py +199 -0
  62. seedcode/core/identity.py +66 -0
  63. seedcode/core/identity_store.py +119 -0
  64. seedcode/core/lifecycle.py +240 -0
  65. seedcode/core/limits.py +35 -0
  66. seedcode/core/models.py +347 -0
  67. seedcode/core/project.py +96 -0
  68. seedcode/core/providers/__init__.py +58 -0
  69. seedcode/core/providers/aerolink.py +324 -0
  70. seedcode/core/providers/base.py +230 -0
  71. seedcode/core/providers/freemodel.py +931 -0
  72. seedcode/core/providers/ollama.py +262 -0
  73. seedcode/core/providers/openrouter.py +393 -0
  74. seedcode/core/streaming.py +21 -0
  75. seedcode/memory/__init__.py +8 -0
  76. seedcode/memory/manager.py +47 -0
  77. seedcode/memory/storage.py +38 -0
  78. seedcode/memory/store.py +257 -0
  79. seedcode/tools/__init__.py +35 -0
  80. seedcode/tools/base.py +179 -0
  81. seedcode/tools/desktop.py +371 -0
  82. seedcode/tools/filesystem.py +309 -0
  83. seedcode/tools/git.py +72 -0
  84. seedcode/tools/patch.py +170 -0
  85. seedcode/tools/permissions.py +288 -0
  86. seedcode/tools/search.py +137 -0
  87. seedcode/tools/terminal.py +200 -0
  88. seedcode/tools/textio.py +59 -0
  89. seedcode/ui/__init__.py +164 -0
  90. seedcode/ui/badges.py +64 -0
  91. seedcode/ui/banner.py +78 -0
  92. seedcode/ui/dashboard.py +197 -0
  93. seedcode/ui/dialog.py +62 -0
  94. seedcode/ui/fuzzy.py +128 -0
  95. seedcode/ui/layout.py +54 -0
  96. seedcode/ui/menu.py +61 -0
  97. seedcode/ui/palette.py +40 -0
  98. seedcode/ui/progress.py +41 -0
  99. seedcode/ui/prompts.py +16 -0
  100. seedcode/ui/renderer.py +36 -0
  101. seedcode/ui/searchbox.py +70 -0
  102. seedcode/ui/selector.py +514 -0
  103. seedcode/ui/statusbar.py +38 -0
  104. seedcode/ui/textbox.py +61 -0
  105. seedcode/ui/theme.py +204 -0
  106. seedcode/ui/tree.py +91 -0
  107. seedcode/utils/__init__.py +22 -0
  108. seedcode/utils/helpers.py +97 -0
  109. seedcode/utils/logger.py +65 -0
  110. seedcode_cli-6.1.5.dist-info/METADATA +368 -0
  111. seedcode_cli-6.1.5.dist-info/RECORD +114 -0
  112. seedcode_cli-6.1.5.dist-info/WHEEL +4 -0
  113. seedcode_cli-6.1.5.dist-info/entry_points.txt +2 -0
  114. seedcode_cli-6.1.5.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,47 @@
1
+ """Management of the saved-session collection: listing saved transcripts.
2
+
3
+ Where :mod:`seedcode.memory.storage` persists a single live session, this
4
+ module reads back across all saved sessions on disk (used by /history).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+
11
+ from ..utils.helpers import history_dir
12
+
13
+
14
+ def list_sessions(provider_id: str = "") -> list[tuple[str, int]]:
15
+ """Return ``(session_id, message_count)`` for saved sessions, newest first.
16
+
17
+ With ``provider_id``, only that provider's own history is listed.
18
+ """
19
+ sessions: list[tuple[str, int]] = []
20
+ for path in sorted(history_dir(provider_id).glob("session-*.json"), reverse=True):
21
+ try:
22
+ data = json.loads(path.read_text(encoding="utf-8"))
23
+ sid = path.stem.replace("session-", "")
24
+ sessions.append((sid, len(data)))
25
+ except (json.JSONDecodeError, OSError):
26
+ continue
27
+ return sessions
28
+
29
+
30
+ def load_session(provider_id: str, session_id: str) -> list[dict]:
31
+ """Load one saved transcript as raw message dicts ([] on any failure)."""
32
+ path = history_dir(provider_id) / f"session-{session_id}.json"
33
+ try:
34
+ data = json.loads(path.read_text(encoding="utf-8"))
35
+ return data if isinstance(data, list) else []
36
+ except (json.JSONDecodeError, OSError):
37
+ return []
38
+
39
+
40
+ def delete_session(provider_id: str, session_id: str) -> bool:
41
+ """Delete one saved transcript; True when the file is gone afterwards."""
42
+ path = history_dir(provider_id) / f"session-{session_id}.json"
43
+ try:
44
+ path.unlink(missing_ok=True)
45
+ return True
46
+ except OSError:
47
+ return False
@@ -0,0 +1,38 @@
1
+ """Conversation history persistence.
2
+
3
+ Each session is stored as a JSON file under ``~/.seedcode/history``. History is
4
+ best-effort: failures to read or write are swallowed so a disk hiccup never
5
+ interrupts a chat.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+
12
+ from ..core.models import Message
13
+ from ..utils.helpers import history_dir, restrict_permissions, session_id
14
+
15
+
16
+ class HistoryStore:
17
+ """Append-friendly JSON transcript for a single chat session.
18
+
19
+ Transcripts are stored per provider (``history/<provider>/...``) so each
20
+ backend keeps its own independent history.
21
+ """
22
+
23
+ def __init__(self, sid: str | None = None, provider_id: str = "") -> None:
24
+ self.session_id = sid or session_id()
25
+ self.provider_id = provider_id
26
+ self.path = history_dir(provider_id) / f"session-{self.session_id}.json"
27
+
28
+ def save(self, messages: list[Message]) -> None:
29
+ """Write the full transcript (excluding system messages) to disk."""
30
+ payload = [m.model_dump() for m in messages if m.role != "system"]
31
+ try:
32
+ self.path.write_text(
33
+ json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8"
34
+ )
35
+ restrict_permissions(self.path)
36
+ except OSError:
37
+ # History is a convenience, never a hard requirement.
38
+ pass
@@ -0,0 +1,257 @@
1
+ """Persistent local memory: indexed JSON records under ``~/.seedcode/memory``.
2
+
3
+ Design rules the tests pin:
4
+
5
+ * **Structured records, not transcripts.** Each record is a small JSON file
6
+ (``<namespace>/<name>.json``) plus a per-namespace ``index.json`` holding
7
+ id/summary/tags/timestamps. Retrieval reads the *index*, then loads only
8
+ the referenced records — never the whole directory.
9
+ * **Namespaces.** ``sessions`` (task records), ``desktop`` (apps, monitors,
10
+ environment), ``user`` (operational preferences), ``web`` (extracted
11
+ pages), ``files`` (downloaded/extracted artifacts).
12
+ * **Secret filtering.** Keys matching secret-ish names (password, token,
13
+ api_key, cookie, secret...) are rejected at write time with
14
+ :class:`SecurityError`; redaction masks values in anything that slips
15
+ through nested structures. Secrets belong in a credential manager, never
16
+ in memory JSON.
17
+ * **Local-first.** Everything lives under the per-user app dir; nothing
18
+ uploads. Corruption is contained: a bad record/index degrades to empty
19
+ for that namespace without touching the others.
20
+
21
+ Storage is atomic (write-to-temp + replace) so a crash never leaves a
22
+ half-written record behind.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import re
29
+ import time
30
+ from dataclasses import dataclass, field
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ from ..core.errors import SecurityError
35
+ from ..utils.helpers import app_dir
36
+
37
+ NAMESPACES = ("sessions", "desktop", "user", "web", "files")
38
+
39
+ # Keys that must never be persisted into normal memory.
40
+ _SECRET_KEY_RE = re.compile(
41
+ r"(password|passwd|pwd|secret|token|api[_-]?key|apikey|credential|"
42
+ r"auth|cookie|session[_-]?id|private[_-]?key|access[_-]?key)",
43
+ re.IGNORECASE,
44
+ )
45
+
46
+
47
+ def mask_secret(value: Any) -> str:
48
+ """Mask a secret-ish value for logs/echoes: keep 2 leading chars."""
49
+ text = str(value)
50
+ if len(text) <= 4:
51
+ return "*" * len(text)
52
+ return text[:2] + "*" * (len(text) - 2)
53
+
54
+
55
+ @dataclass(slots=True)
56
+ class MemoryRecord:
57
+ """One retrievable memory entry (metadata lives in the index)."""
58
+
59
+ id: str
60
+ namespace: str
61
+ summary: str
62
+ data: dict[str, Any] = field(default_factory=dict)
63
+ tags: list[str] = field(default_factory=list)
64
+ created_at: float = field(default_factory=time.time)
65
+
66
+
67
+ def _assert_no_secrets(data: Any, path: str = "") -> None:
68
+ """Reject nested secret-looking keys before anything touches disk."""
69
+ if isinstance(data, dict):
70
+ for key, value in data.items():
71
+ here = f"{path}.{key}" if path else str(key)
72
+ if _SECRET_KEY_RE.search(str(key)):
73
+ raise SecurityError(
74
+ f"Refusing to store a secret-looking field ('{here}') in "
75
+ "memory. Credentials belong in a credential manager."
76
+ )
77
+ _assert_no_secrets(value, here)
78
+ elif isinstance(data, list):
79
+ for i, item in enumerate(data):
80
+ _assert_no_secrets(item, f"{path}[{i}]")
81
+
82
+
83
+ class MemoryStore:
84
+ """Namespaced, indexed, atomic JSON record store."""
85
+
86
+ def __init__(self, root: Path | None = None) -> None:
87
+ self._root = root or (app_dir() / "memory")
88
+ try:
89
+ self._root.mkdir(parents=True, exist_ok=True)
90
+ except OSError:
91
+ pass # writes will fail later and are handled per-call
92
+
93
+ # --- paths ---------------------------------------------------------------
94
+ def _ns_dir(self, namespace: str) -> Path:
95
+ ns = namespace.strip().lower()
96
+ if ns not in NAMESPACES:
97
+ raise ValueError(
98
+ f"Unknown memory namespace '{namespace}'. Choose one of: "
99
+ f"{', '.join(NAMESPACES)}."
100
+ )
101
+ return self._root / ns
102
+
103
+ def _index_path(self, namespace: str) -> Path:
104
+ return self._ns_dir(namespace) / "index.json"
105
+
106
+ def _record_path(self, namespace: str, record_id: str) -> Path:
107
+ safe = re.sub(r"[^a-zA-Z0-9._-]", "_", record_id)[:80] or "record"
108
+ return self._ns_dir(namespace) / f"{safe}.json"
109
+
110
+ # --- atomic IO -------------------------------------------------------------
111
+ @staticmethod
112
+ def _write_atomic(path: Path, payload: dict[str, Any]) -> bool:
113
+ try:
114
+ path.parent.mkdir(parents=True, exist_ok=True)
115
+ tmp = path.with_suffix(path.suffix + ".tmp")
116
+ tmp.write_text(
117
+ json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8"
118
+ )
119
+ tmp.replace(path)
120
+ return True
121
+ except (OSError, ValueError):
122
+ return False
123
+
124
+ @staticmethod
125
+ def _read_json(path: Path) -> dict[str, Any] | None:
126
+ try:
127
+ data = json.loads(path.read_text(encoding="utf-8"))
128
+ return data if isinstance(data, dict) else None
129
+ except (OSError, ValueError):
130
+ return None # corrupt or missing -> treated as empty
131
+
132
+ # --- index -----------------------------------------------------------------
133
+ def _load_index(self, namespace: str) -> dict[str, Any]:
134
+ data = self._read_json(self._index_path(namespace))
135
+ if data is None:
136
+ return {"records": {}}
137
+ records = data.get("records")
138
+ return {"records": records} if isinstance(records, dict) else {"records": {}}
139
+
140
+ def _save_index(self, namespace: str, index: dict[str, Any]) -> bool:
141
+ return self._write_atomic(self._index_path(namespace), index)
142
+
143
+ # --- public API ----------------------------------------------------------------
144
+ def put(
145
+ self,
146
+ namespace: str,
147
+ record_id: str,
148
+ summary: str,
149
+ data: dict[str, Any] | None = None,
150
+ tags: list[str] | None = None,
151
+ ) -> bool:
152
+ """Store a record + index entry (secret-checked). False on IO failure."""
153
+ _assert_no_secrets(data or {})
154
+ _assert_no_secrets({"summary": summary})
155
+ record = {
156
+ "id": record_id,
157
+ "namespace": namespace,
158
+ "summary": summary,
159
+ "tags": list(tags or []),
160
+ "created_at": time.time(),
161
+ "data": data or {},
162
+ }
163
+ if not self._write_atomic(self._record_path(namespace, record_id), record):
164
+ return False
165
+ index = self._load_index(namespace)
166
+ index["records"][record_id] = {
167
+ "summary": summary,
168
+ "tags": list(tags or []),
169
+ "created_at": record["created_at"],
170
+ }
171
+ return self._save_index(namespace, index)
172
+
173
+ def get(self, namespace: str, record_id: str) -> MemoryRecord | None:
174
+ """Load one record by id (None when missing/corrupt)."""
175
+ raw = self._read_json(self._record_path(namespace, record_id))
176
+ if raw is None or not isinstance(raw.get("id"), str):
177
+ return None
178
+ return MemoryRecord(
179
+ id=raw["id"],
180
+ namespace=raw.get("namespace", namespace),
181
+ summary=raw.get("summary", ""),
182
+ data=raw.get("data", {}) if isinstance(raw.get("data"), dict) else {},
183
+ tags=raw.get("tags", []) if isinstance(raw.get("tags"), list) else [],
184
+ created_at=float(raw.get("created_at", 0.0)),
185
+ )
186
+
187
+ def delete(self, namespace: str, record_id: str) -> bool:
188
+ """Remove a record and its index entry."""
189
+ try:
190
+ self._record_path(namespace, record_id).unlink(missing_ok=True)
191
+ except OSError:
192
+ return False
193
+ index = self._load_index(namespace)
194
+ index["records"].pop(record_id, None)
195
+ return self._save_index(namespace, index)
196
+
197
+ def query(
198
+ self, namespace: str, *, text: str = "", tags: list[str] | None = None,
199
+ limit: int = 10,
200
+ ) -> list[dict[str, Any]]:
201
+ """Index-level search (no record loads): newest first, filtered.
202
+
203
+ Matches on summary + tags + id. Returns index entries
204
+ ``{id, summary, tags, created_at}`` — callers load full records
205
+ selectively via :meth:`get`.
206
+ """
207
+ index = self._load_index(namespace)
208
+ wanted_tags = {t.lower() for t in (tags or [])}
209
+ needle = (text or "").strip().lower()
210
+ hits: list[dict[str, Any]] = []
211
+ for record_id, entry in index["records"].items():
212
+ if not isinstance(entry, dict):
213
+ continue
214
+ entry_tags = [str(t).lower() for t in entry.get("tags", [])]
215
+ if wanted_tags and not wanted_tags.issubset(set(entry_tags)):
216
+ continue
217
+ haystack = " ".join([
218
+ record_id, str(entry.get("summary", "")), " ".join(entry_tags),
219
+ ]).lower()
220
+ if needle and needle not in haystack:
221
+ continue
222
+ hits.append({
223
+ "id": record_id,
224
+ "summary": entry.get("summary", ""),
225
+ "tags": entry.get("tags", []),
226
+ "created_at": entry.get("created_at", 0.0),
227
+ })
228
+ hits.sort(key=lambda h: h["created_at"], reverse=True)
229
+ return hits[: max(1, limit)]
230
+
231
+ def namespaces(self) -> list[str]:
232
+ """Namespaces that exist on disk (of the known set)."""
233
+ return [ns for ns in NAMESPACES if self._ns_dir(ns).is_dir()]
234
+
235
+
236
+ # --- process-wide store ---------------------------------------------------------------
237
+ _STORE: MemoryStore | None = None
238
+
239
+
240
+ def memory_store(root: Path | None = None) -> MemoryStore:
241
+ """The process-wide :class:`MemoryStore` (root injectable for tests)."""
242
+ global _STORE
243
+ if _STORE is None or root is not None:
244
+ if _STORE is None:
245
+ _STORE = MemoryStore(root)
246
+ return _STORE
247
+
248
+
249
+ def reset_memory_store() -> None:
250
+ global _STORE
251
+ _STORE = None
252
+
253
+
254
+ __all__ = [
255
+ "NAMESPACES", "MemoryRecord", "MemoryStore", "mask_secret",
256
+ "memory_store", "reset_memory_store",
257
+ ]
@@ -0,0 +1,35 @@
1
+ """Tool engine for Seed Code agent mode.
2
+
3
+ Tools are the actions the agent may take on the user's machine: reading and
4
+ writing files, searching, running commands, and using git. Every tool is
5
+ registered in a table (mirroring the slash-command registry) and every
6
+ execution passes through the :class:`~seedcode.tools.permissions.PermissionManager`
7
+ first, so what the agent may touch is decided in exactly one place.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .base import Tool, ToolError, ToolResult, TOOL_REGISTRY, get_tool, tool_manifest
13
+ from .permissions import (
14
+ PermissionError_,
15
+ PermissionLevel,
16
+ PermissionManager,
17
+ PermissionMode,
18
+ )
19
+
20
+ # Import tool modules for their registration side effects (same pattern as
21
+ # seedcode.commands): each module's @register calls populate TOOL_REGISTRY.
22
+ from . import desktop, filesystem, git, patch, search, terminal # noqa: E402,F401
23
+
24
+ __all__ = [
25
+ "PermissionError_",
26
+ "PermissionLevel",
27
+ "PermissionManager",
28
+ "PermissionMode",
29
+ "TOOL_REGISTRY",
30
+ "Tool",
31
+ "ToolError",
32
+ "ToolResult",
33
+ "get_tool",
34
+ "tool_manifest",
35
+ ]
seedcode/tools/base.py ADDED
@@ -0,0 +1,179 @@
1
+ """Tool contracts and registry for agent mode.
2
+
3
+ A tool is a named action with a JSON-friendly argument schema and a runner.
4
+ The registry is the single source of truth for what the agent can do; the
5
+ system prompt shown to the model is generated from it (:func:`tool_manifest`),
6
+ so the documentation the model sees can never drift from the implementation.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import TYPE_CHECKING, Any, Callable
13
+
14
+ if TYPE_CHECKING:
15
+ from .permissions import PermissionManager
16
+
17
+ # Output larger than this is truncated before it re-enters the conversation,
18
+ # keeping a single tool call from blowing the model's context window.
19
+ MAX_OUTPUT_CHARS = 12_000
20
+
21
+
22
+ class ToolError(Exception):
23
+ """A tool could not complete; the message is fed back to the model."""
24
+
25
+
26
+ def int_arg(args: dict[str, Any], name: str, default: int, lo: int, hi: int) -> int:
27
+ """Coerce an integer argument, clamped to [lo, hi].
28
+
29
+ Models sometimes send numbers as strings or send junk; a friendly
30
+ :class:`ToolError` beats the raw ``ValueError`` the generic crash guard
31
+ would otherwise surface.
32
+ """
33
+ raw = args.get(name, default)
34
+ try:
35
+ value = int(raw)
36
+ except (TypeError, ValueError):
37
+ raise ToolError(f"Argument '{name}' must be a whole number, got {raw!r}.") from None
38
+ return max(lo, min(value, hi))
39
+
40
+
41
+ @dataclass(slots=True)
42
+ class ToolResult:
43
+ """Outcome of one tool execution, fed back into the conversation."""
44
+
45
+ ok: bool
46
+ output: str
47
+
48
+ def for_model(self) -> str:
49
+ """Result text as the model sees it, truncated to a safe size."""
50
+ text = self.output if self.output.strip() else "(no output)"
51
+ if len(text) > MAX_OUTPUT_CHARS:
52
+ omitted = len(text) - MAX_OUTPUT_CHARS
53
+ text = text[:MAX_OUTPUT_CHARS] + f"\n... [truncated {omitted} chars]"
54
+ status = "OK" if self.ok else "ERROR"
55
+ return f"[{status}] {text}"
56
+
57
+
58
+ Runner = Callable[["PermissionManager", dict[str, Any]], ToolResult]
59
+
60
+
61
+ @dataclass(slots=True)
62
+ class Tool:
63
+ """One agent-callable action."""
64
+
65
+ name: str
66
+ description: str
67
+ # arg name -> one-line description; "(optional)" marks optional args.
68
+ args: dict[str, str]
69
+ # True when the tool changes files or system state (drives permissions).
70
+ mutates: bool
71
+ runner: Runner
72
+ # Which engine the tool belongs to: "core" (files/search/git/shell) or
73
+ # "desktop" (the Computer Engine). Drives manifest filtering so plain
74
+ # agent mode never advertises desktop tools.
75
+ group: str = "core"
76
+ # arg name -> JSON-schema type for non-string args ("integer",
77
+ # "boolean", ...). Unlisted args are strings. Drives native tool schemas.
78
+ types: dict[str, str] = field(default_factory=dict)
79
+
80
+ def run(self, permissions: "PermissionManager", args: dict[str, Any]) -> ToolResult:
81
+ missing = [
82
+ name
83
+ for name, desc in self.args.items()
84
+ if "(optional)" not in desc and name not in args
85
+ ]
86
+ if missing:
87
+ raise ToolError(
88
+ f"Tool '{self.name}' is missing required argument(s): {', '.join(missing)}."
89
+ )
90
+ return self.runner(permissions, args)
91
+
92
+
93
+ TOOL_REGISTRY: dict[str, Tool] = {}
94
+
95
+
96
+ def register(
97
+ name: str,
98
+ description: str,
99
+ args: dict[str, str],
100
+ *,
101
+ mutates: bool,
102
+ group: str = "core",
103
+ types: dict[str, str] | None = None,
104
+ ) -> Callable[[Runner], Runner]:
105
+ """Decorator registering a runner in the tool table."""
106
+
107
+ def wrap(runner: Runner) -> Runner:
108
+ TOOL_REGISTRY[name] = Tool(
109
+ name=name,
110
+ description=description,
111
+ args=args,
112
+ mutates=mutates,
113
+ runner=runner,
114
+ group=group,
115
+ types=types or {},
116
+ )
117
+ return runner
118
+
119
+ return wrap
120
+
121
+
122
+ def get_tool(name: str) -> Tool:
123
+ tool = TOOL_REGISTRY.get((name or "").strip().lower())
124
+ if tool is None:
125
+ known = ", ".join(sorted(TOOL_REGISTRY))
126
+ raise ToolError(f"Unknown tool '{name}'. Available tools: {known}.")
127
+ return tool
128
+
129
+
130
+ def tool_manifest(groups: tuple[str, ...] = ("core",)) -> str:
131
+ """Tool documentation injected into the agent system prompt.
132
+
133
+ Only tools in ``groups`` are listed, so what the model is told matches
134
+ what the session actually allows (e.g. desktop tools only appear when
135
+ desktop mode is on).
136
+ """
137
+ lines = []
138
+ for tool in sorted(TOOL_REGISTRY.values(), key=lambda t: t.name):
139
+ if tool.group not in groups:
140
+ continue
141
+ arg_desc = ", ".join(f'"{a}": {d}' for a, d in tool.args.items()) or "none"
142
+ lines.append(f"- {tool.name}: {tool.description}\n args: {arg_desc}")
143
+ return "\n".join(lines)
144
+
145
+
146
+ def tool_specs(groups: tuple[str, ...] = ("core",)) -> list[dict[str, Any]]:
147
+ """Neutral name/description/parameters specs for native tool calling.
148
+
149
+ Generated from the same registry as :func:`tool_manifest`, so the schema
150
+ a provider sends can never drift from the implementation. Each entry is
151
+ ``{"name", "description", "parameters"}`` with ``parameters`` a
152
+ JSON-schema object; args whose description lacks "(optional)" are
153
+ required.
154
+ """
155
+ specs: list[dict[str, Any]] = []
156
+ for tool in sorted(TOOL_REGISTRY.values(), key=lambda t: t.name):
157
+ if tool.group not in groups:
158
+ continue
159
+ properties: dict[str, Any] = {}
160
+ required: list[str] = []
161
+ for arg, desc in tool.args.items():
162
+ properties[arg] = {
163
+ "type": tool.types.get(arg, "string"),
164
+ "description": desc,
165
+ }
166
+ if "(optional)" not in desc:
167
+ required.append(arg)
168
+ specs.append(
169
+ {
170
+ "name": tool.name,
171
+ "description": tool.description,
172
+ "parameters": {
173
+ "type": "object",
174
+ "properties": properties,
175
+ "required": required,
176
+ },
177
+ }
178
+ )
179
+ return specs