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.
- seedcode/__init__.py +14 -0
- seedcode/__main__.py +12 -0
- seedcode/app.py +508 -0
- seedcode/apps/__init__.py +32 -0
- seedcode/apps/discovery.py +241 -0
- seedcode/apps/installer.py +164 -0
- seedcode/apps/launcher.py +156 -0
- seedcode/apps/verifier.py +119 -0
- seedcode/assets/logo.txt +15 -0
- seedcode/cli.py +95 -0
- seedcode/commands/__init__.py +81 -0
- seedcode/commands/about.py +34 -0
- seedcode/commands/agent.py +94 -0
- seedcode/commands/assist.py +201 -0
- seedcode/commands/clear.py +20 -0
- seedcode/commands/desktop.py +104 -0
- seedcode/commands/doctor.py +152 -0
- seedcode/commands/help.py +61 -0
- seedcode/commands/history.py +365 -0
- seedcode/commands/palette.py +100 -0
- seedcode/commands/provider.py +451 -0
- seedcode/commands/theme.py +76 -0
- seedcode/computer/__init__.py +98 -0
- seedcode/computer/browser.py +276 -0
- seedcode/computer/browser_cdp.py +567 -0
- seedcode/computer/browser_engine.py +546 -0
- seedcode/computer/browser_extract.py +301 -0
- seedcode/computer/browser_popups.py +329 -0
- seedcode/computer/browser_selenium.py +209 -0
- seedcode/computer/browser_skills.py +245 -0
- seedcode/computer/catalog.py +200 -0
- seedcode/computer/controller.py +324 -0
- seedcode/computer/dispatcher.py +272 -0
- seedcode/computer/dpi.py +185 -0
- seedcode/computer/engine.py +105 -0
- seedcode/computer/keyboard.py +101 -0
- seedcode/computer/logbook.py +104 -0
- seedcode/computer/mouse.py +48 -0
- seedcode/computer/ocr.py +213 -0
- seedcode/computer/operator_skills.py +577 -0
- seedcode/computer/permissions.py +203 -0
- seedcode/computer/recovery.py +115 -0
- seedcode/computer/registry.py +107 -0
- seedcode/computer/resolver.py +434 -0
- seedcode/computer/screen.py +130 -0
- seedcode/computer/screen_state.py +412 -0
- seedcode/computer/selfguard.py +197 -0
- seedcode/computer/semantic.py +100 -0
- seedcode/computer/skills.py +139 -0
- seedcode/computer/state.py +199 -0
- seedcode/computer/verifier.py +177 -0
- seedcode/computer/vision.py +327 -0
- seedcode/computer/windows.py +217 -0
- seedcode/config/__init__.py +8 -0
- seedcode/config/defaults.py +22 -0
- seedcode/config/manager.py +62 -0
- seedcode/core/__init__.py +31 -0
- seedcode/core/agent.py +534 -0
- seedcode/core/chat.py +128 -0
- seedcode/core/client.py +9 -0
- seedcode/core/errors.py +199 -0
- seedcode/core/identity.py +66 -0
- seedcode/core/identity_store.py +119 -0
- seedcode/core/lifecycle.py +240 -0
- seedcode/core/limits.py +35 -0
- seedcode/core/models.py +347 -0
- seedcode/core/project.py +96 -0
- seedcode/core/providers/__init__.py +58 -0
- seedcode/core/providers/aerolink.py +324 -0
- seedcode/core/providers/base.py +230 -0
- seedcode/core/providers/freemodel.py +931 -0
- seedcode/core/providers/ollama.py +262 -0
- seedcode/core/providers/openrouter.py +393 -0
- seedcode/core/streaming.py +21 -0
- seedcode/memory/__init__.py +8 -0
- seedcode/memory/manager.py +47 -0
- seedcode/memory/storage.py +38 -0
- seedcode/memory/store.py +257 -0
- seedcode/tools/__init__.py +35 -0
- seedcode/tools/base.py +179 -0
- seedcode/tools/desktop.py +371 -0
- seedcode/tools/filesystem.py +309 -0
- seedcode/tools/git.py +72 -0
- seedcode/tools/patch.py +170 -0
- seedcode/tools/permissions.py +288 -0
- seedcode/tools/search.py +137 -0
- seedcode/tools/terminal.py +200 -0
- seedcode/tools/textio.py +59 -0
- seedcode/ui/__init__.py +164 -0
- seedcode/ui/badges.py +64 -0
- seedcode/ui/banner.py +78 -0
- seedcode/ui/dashboard.py +197 -0
- seedcode/ui/dialog.py +62 -0
- seedcode/ui/fuzzy.py +128 -0
- seedcode/ui/layout.py +54 -0
- seedcode/ui/menu.py +61 -0
- seedcode/ui/palette.py +40 -0
- seedcode/ui/progress.py +41 -0
- seedcode/ui/prompts.py +16 -0
- seedcode/ui/renderer.py +36 -0
- seedcode/ui/searchbox.py +70 -0
- seedcode/ui/selector.py +514 -0
- seedcode/ui/statusbar.py +38 -0
- seedcode/ui/textbox.py +61 -0
- seedcode/ui/theme.py +204 -0
- seedcode/ui/tree.py +91 -0
- seedcode/utils/__init__.py +22 -0
- seedcode/utils/helpers.py +97 -0
- seedcode/utils/logger.py +65 -0
- seedcode_cli-6.1.5.dist-info/METADATA +368 -0
- seedcode_cli-6.1.5.dist-info/RECORD +114 -0
- seedcode_cli-6.1.5.dist-info/WHEEL +4 -0
- seedcode_cli-6.1.5.dist-info/entry_points.txt +2 -0
- 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
|
seedcode/memory/store.py
ADDED
|
@@ -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
|