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,577 @@
1
+ """Operator skills: screen intelligence, semantic UI, apps, web, memory.
2
+
3
+ The Phase-4+ operator layers exposed to the AI as permissioned skills, in
4
+ the same registry as the built-in catalog (:mod:`.catalog`). Each skill is
5
+ a thin, deterministic adapter:
6
+
7
+ * queries return **compact structured data** (ids, names, counts) — never
8
+ raw trees or screenshots;
9
+ * actions are **id/description-addressed** — the AI never supplies
10
+ coordinates, keystrokes, or selectors;
11
+ * failures surface as model-readable text (the structured error taxonomy's
12
+ messages), so the planner can re-plan; nothing here can terminate the
13
+ process (lifecycle) or touch SeedCode's own console (selfguard — the
14
+ underlying windows driver already refuses).
15
+
16
+ Importing this module registers the skills; :mod:`.catalog` imports it.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ from typing import Any
23
+
24
+ from ..core.errors import ApplicationNotFoundError, OperatorError
25
+ from ..tools.permissions import PermissionLevel
26
+ from .permissions import CATEGORY_INSTALL
27
+ from .skills import Outcome, SkillContext, SkillError, skill
28
+
29
+
30
+ def _compact(value: Any, limit: int = 3500) -> str:
31
+ """Compact JSON rendering with a size bound for model-facing output."""
32
+ try:
33
+ text = json.dumps(value, ensure_ascii=False, default=str)
34
+ except (TypeError, ValueError):
35
+ text = str(value)
36
+ return text if len(text) <= limit else text[:limit] + " …[truncated]"
37
+
38
+
39
+ # --- screen intelligence queries ------------------------------------------------
40
+
41
+ @skill(
42
+ "screen_state",
43
+ "Structured screen state: active window, windows, monitors, and UI "
44
+ "elements with semantic element ids. Prefer over screenshots.",
45
+ PermissionLevel.DESKTOP,
46
+ {"include_elements": "(optional) false to omit the element list"},
47
+ )
48
+ def screen_state(ctx: SkillContext, params: dict[str, Any]) -> Outcome:
49
+ from .screen_state import get_screen_engine
50
+
51
+ include_elements = str(params.get("include_elements", "true")).lower() not in (
52
+ "false", "0", "no"
53
+ )
54
+ state = get_screen_engine().state_json(include_elements=include_elements)
55
+ return Outcome(_compact(state))
56
+
57
+
58
+ @skill(
59
+ "get_active_window",
60
+ "The currently focused window with app name, title, pid, and bounds.",
61
+ PermissionLevel.DESKTOP,
62
+ )
63
+ def get_active_window(ctx: SkillContext, params: dict[str, Any]) -> Outcome:
64
+ from .screen_state import get_screen_engine
65
+
66
+ win = get_screen_engine().active_window()
67
+ if win is None:
68
+ return Outcome("no focused window detected")
69
+ return Outcome(_compact({
70
+ "id": win.id, "app": _app_of(win.title), "title": win.title,
71
+ "pid": win.pid, "bounds": win.bounds,
72
+ }))
73
+
74
+
75
+ @skill(
76
+ "list_windows",
77
+ "All visible top-level windows with app, title, pid, and focus state.",
78
+ PermissionLevel.DESKTOP,
79
+ )
80
+ def list_windows_query(ctx: SkillContext, params: dict[str, Any]) -> Outcome:
81
+ from .screen_state import get_screen_engine
82
+
83
+ wins = get_screen_engine().list_windows()
84
+ return Outcome(_compact([
85
+ {"id": w.id, "app": _app_of(w.title), "title": w.title, "pid": w.pid,
86
+ "focused": w.focused, "minimized": w.minimized}
87
+ for w in wins
88
+ ]))
89
+
90
+
91
+ @skill(
92
+ "find_ui_element",
93
+ "Find a UI element by name/role (e.g. \"Play button\") and return its "
94
+ "semantic element id. The id is what click/type actions take.",
95
+ PermissionLevel.DESKTOP,
96
+ {"query": "name or description, e.g. 'Play' or 'search box'",
97
+ "window": "(optional) window title fragment to scan"},
98
+ )
99
+ def find_ui_element(ctx: SkillContext, params: dict[str, Any]) -> Outcome:
100
+ from .screen_state import get_screen_engine
101
+
102
+ query = str(params.get("query", "")).strip()
103
+ if not query:
104
+ raise SkillError("find_ui_element requires a 'query' parameter")
105
+ window = str(params.get("window", "")).strip() or None
106
+ el = get_screen_engine().find_element(query, window=window, fresh=True)
107
+ return Outcome(_compact({
108
+ "id": el.id, "role": el.role, "name": el.name, "enabled": el.enabled,
109
+ }))
110
+
111
+
112
+ @skill(
113
+ "get_focused_element",
114
+ "The element in the active window that currently has keyboard focus.",
115
+ PermissionLevel.DESKTOP,
116
+ )
117
+ def get_focused_element(ctx: SkillContext, params: dict[str, Any]) -> Outcome:
118
+ from .screen_state import get_screen_engine
119
+
120
+ engine = get_screen_engine()
121
+ # The UIA tree does not expose focus state per element in the light
122
+ # walker; report the focused window plus its first focusable candidate.
123
+ win = engine.active_window()
124
+ if win is None:
125
+ return Outcome("no focused window")
126
+ state = engine.state_json(include_elements=True)
127
+ elements = state.get("elements", [])
128
+ return Outcome(_compact({
129
+ "window": {"id": win.id, "title": win.title},
130
+ "elements": elements[:30],
131
+ }))
132
+
133
+
134
+ def _app_of(title: str) -> str:
135
+ for sep in (" — ", " - ", " – "):
136
+ if sep in title:
137
+ return title.rsplit(sep, 1)[-1].strip() or title.strip()
138
+ return title.strip()
139
+
140
+
141
+ # --- semantic element actions ---------------------------------------------------
142
+
143
+ @skill(
144
+ "click_element",
145
+ "Click a UI element by its semantic element id (from find_ui_element).",
146
+ PermissionLevel.DESKTOP,
147
+ {"element_id": "element id from a screen query", "double": "(optional) true to double-click"},
148
+ )
149
+ def click_element(ctx: SkillContext, params: dict[str, Any]) -> Outcome:
150
+ from .semantic import SemanticActions
151
+
152
+ element_id = _require_id(params)
153
+ double = str(params.get("double", "")).lower() in ("true", "1", "yes")
154
+ actions = SemanticActions()
155
+ detail = actions.click(element_id, double=double)
156
+ ctx.state.record_action(detail)
157
+ return Outcome(detail, {"element": element_id})
158
+
159
+
160
+ @skill(
161
+ "type_into_element",
162
+ "Type text into a UI element by element id (focuses it first).",
163
+ PermissionLevel.DESKTOP,
164
+ {"element_id": "element id from a screen query", "text": "text to type",
165
+ "secret": "(optional) true for passwords"},
166
+ )
167
+ def type_into_element(ctx: SkillContext, params: dict[str, Any]) -> Outcome:
168
+ from .semantic import SemanticActions
169
+
170
+ element_id = _require_id(params)
171
+ text = str(params.get("text", ""))
172
+ if not text:
173
+ raise SkillError("type_into_element requires 'text'")
174
+ secret = str(params.get("secret", "")).lower() in ("true", "1", "yes")
175
+ actions = SemanticActions()
176
+ detail = actions.type_into(element_id, text, secret=secret)
177
+ ctx.state.record_action(detail)
178
+ return Outcome(detail, {"element": element_id})
179
+
180
+
181
+ @skill(
182
+ "focus_element",
183
+ "Move keyboard focus to a UI element by element id.",
184
+ PermissionLevel.DESKTOP,
185
+ {"element_id": "element id from a screen query"},
186
+ )
187
+ def focus_element(ctx: SkillContext, params: dict[str, Any]) -> Outcome:
188
+ from .semantic import SemanticActions
189
+
190
+ detail = SemanticActions().focus(_require_id(params))
191
+ ctx.state.record_action(detail)
192
+ return Outcome(detail)
193
+
194
+
195
+ @skill(
196
+ "press_element",
197
+ "Focus an element by id, then send a keyboard shortcut to it.",
198
+ PermissionLevel.DESKTOP,
199
+ {"element_id": "element id from a screen query",
200
+ "keys": "e.g. \"enter\" or \"ctrl+s\""},
201
+ )
202
+ def press_element(ctx: SkillContext, params: dict[str, Any]) -> Outcome:
203
+ from .semantic import SemanticActions
204
+
205
+ element_id = _require_id(params)
206
+ keys = [k.strip() for k in str(params.get("keys", "")).replace("+", " ").split() if k.strip()]
207
+ if not keys:
208
+ raise SkillError("press_element requires 'keys'")
209
+ detail = SemanticActions().press(element_id, keys)
210
+ ctx.state.record_action(detail)
211
+ return Outcome(detail)
212
+
213
+
214
+ def _require_id(params: dict[str, Any]) -> str:
215
+ element_id = str(params.get("element_id", "")).strip()
216
+ if not element_id:
217
+ raise SkillError("this action requires an 'element_id' from a screen query")
218
+ return element_id
219
+
220
+
221
+ # --- application controller -------------------------------------------------------
222
+
223
+ @skill(
224
+ "app_open",
225
+ "Open an application by name: reuses/focuses a running instance, "
226
+ "launches via Start Menu shortcut or registered executable, then "
227
+ "verifies a window appeared. Errors list trusted install options.",
228
+ PermissionLevel.DESKTOP,
229
+ {"target": "application name, e.g. 'Spotify' or 'Calculator'"},
230
+ )
231
+ def app_open(ctx: SkillContext, params: dict[str, Any]) -> Outcome:
232
+ from ..apps.launcher import launch_app
233
+
234
+ target = str(params.get("target", "")).strip()
235
+ if not target:
236
+ raise SkillError("app_open requires a 'target' parameter")
237
+ try:
238
+ result = launch_app(target)
239
+ except ApplicationNotFoundError as exc:
240
+ # Missing app: surface the install option; installation is a
241
+ # separate, separately-confirmed skill (install_app).
242
+ raise SkillError(
243
+ f"{exc} Ask the user whether to install it (install_app)."
244
+ )
245
+ ctx.state.record_action(result.describe())
246
+ return Outcome(result.describe())
247
+
248
+
249
+ @skill(
250
+ "app_find",
251
+ "Check whether an application is installed and how it would be launched.",
252
+ PermissionLevel.DESKTOP,
253
+ {"target": "application name"},
254
+ )
255
+ def app_find(ctx: SkillContext, params: dict[str, Any]) -> Outcome:
256
+ from ..apps.discovery import find_app
257
+
258
+ target = str(params.get("target", "")).strip()
259
+ if not target:
260
+ raise SkillError("app_find requires a 'target' parameter")
261
+ try:
262
+ app = find_app(target)
263
+ except ApplicationNotFoundError as exc:
264
+ raise SkillError(str(exc))
265
+ return Outcome(_compact({
266
+ "name": app.name, "source": app.source, "target": app.target,
267
+ "exe": app.exe, "install_location": app.install_location,
268
+ }))
269
+
270
+
271
+ @skill(
272
+ "app_close",
273
+ "Close an application window by name. Refuses SeedCode's own terminal.",
274
+ PermissionLevel.DESKTOP,
275
+ {"target": "part of the window title"},
276
+ )
277
+ def app_close(ctx: SkillContext, params: dict[str, Any]) -> Outcome:
278
+ target = _req(params, "target")
279
+ ctx.controller.close_app(target)
280
+ ctx.state.record_action(f"closed {target}")
281
+ return Outcome(f"closed {target}", {"window_gone": target})
282
+
283
+
284
+ @skill(
285
+ "install_app",
286
+ "Install a missing application via a trusted source (winget). Always "
287
+ "asks the user first with the exact package and source shown.",
288
+ PermissionLevel.DESKTOP,
289
+ {"target": "application name", "confirm": "(optional) skip asking (ignored)"},
290
+ )
291
+ def install_app_skill(ctx: SkillContext, params: dict[str, Any]) -> Outcome:
292
+ from ..apps.installer import install_app
293
+
294
+ target = str(params.get("target", "")).strip()
295
+ if not target:
296
+ raise SkillError("install_app requires a 'target' parameter")
297
+
298
+ # The INSTALL category is sensitive: the gate re-asks every time and the
299
+ # model cannot mark anything trusted (installer.py refuses unknown
300
+ # sources). The confirm callback is the session's desktop gate.
301
+ def _confirm(plan_text: str) -> bool:
302
+ try:
303
+ ctx.permissions.desktop.check(CATEGORY_INSTALL, plan_text) # type: ignore[union-attr]
304
+ return True
305
+ except Exception:
306
+ return False
307
+
308
+ result = install_app(target, confirm=_confirm)
309
+ ctx.state.record_action(result.detail)
310
+ if not result.success:
311
+ raise SkillError(result.detail)
312
+ return Outcome(result.detail)
313
+
314
+
315
+ def _req(params: dict[str, Any], key: str) -> str:
316
+ val = str(params.get(key, "")).strip()
317
+ if not val:
318
+ raise SkillError(f"skill requires a '{key}' parameter")
319
+ return val
320
+
321
+
322
+ # --- web intelligence -----------------------------------------------------------
323
+
324
+ @skill(
325
+ "web_page_info",
326
+ "Current page url and title from the browser's DOM.",
327
+ PermissionLevel.DESKTOP,
328
+ )
329
+ def web_page_info(ctx: SkillContext, params: dict[str, Any]) -> Outcome:
330
+ from .browser_extract import get_web_extractor
331
+
332
+ try:
333
+ info = get_web_extractor().page_info()
334
+ except OperatorError as exc:
335
+ raise SkillError(str(exc))
336
+ return Outcome(_compact(info))
337
+
338
+
339
+ @skill(
340
+ "web_extract_text",
341
+ "Extract the page's readable text (DOM, no screenshots).",
342
+ PermissionLevel.DESKTOP,
343
+ )
344
+ def web_extract_text(ctx: SkillContext, params: dict[str, Any]) -> Outcome:
345
+ from .browser_extract import get_web_extractor
346
+
347
+ try:
348
+ extraction = get_web_extractor().page_text()
349
+ except OperatorError as exc:
350
+ raise SkillError(str(exc))
351
+ return Outcome(_compact(extraction.to_dict()))
352
+
353
+
354
+ @skill(
355
+ "web_extract_headings",
356
+ "Extract the page's headings (h1-h6) with their levels.",
357
+ PermissionLevel.DESKTOP,
358
+ )
359
+ def web_extract_headings(ctx: SkillContext, params: dict[str, Any]) -> Outcome:
360
+ from .browser_extract import get_web_extractor
361
+
362
+ try:
363
+ extraction = get_web_extractor().headings()
364
+ except OperatorError as exc:
365
+ raise SkillError(str(exc))
366
+ return Outcome(_compact(extraction.to_dict()))
367
+
368
+
369
+ @skill(
370
+ "web_extract_links",
371
+ "Extract visible links; optionally filter by a text fragment.",
372
+ PermissionLevel.DESKTOP,
373
+ {"filter": "(optional) keep links whose text/href contains this"},
374
+ )
375
+ def web_extract_links(ctx: SkillContext, params: dict[str, Any]) -> Outcome:
376
+ from .browser_extract import get_web_extractor
377
+
378
+ try:
379
+ extraction = get_web_extractor().links(
380
+ filter_text=str(params.get("filter", "")).strip()
381
+ )
382
+ except OperatorError as exc:
383
+ raise SkillError(str(exc))
384
+ return Outcome(_compact(extraction.to_dict()))
385
+
386
+
387
+ @skill(
388
+ "web_extract_tables",
389
+ "Extract every table on the page as rows of cells (DOM).",
390
+ PermissionLevel.DESKTOP,
391
+ )
392
+ def web_extract_tables(ctx: SkillContext, params: dict[str, Any]) -> Outcome:
393
+ from .browser_extract import get_web_extractor
394
+
395
+ try:
396
+ extraction = get_web_extractor().tables()
397
+ except OperatorError as exc:
398
+ raise SkillError(str(exc))
399
+ return Outcome(_compact(extraction.to_dict()))
400
+
401
+
402
+ @skill(
403
+ "web_extract_lists",
404
+ "Extract list structures (ul/ol) with their items.",
405
+ PermissionLevel.DESKTOP,
406
+ )
407
+ def web_extract_lists(ctx: SkillContext, params: dict[str, Any]) -> Outcome:
408
+ from .browser_extract import get_web_extractor
409
+
410
+ try:
411
+ extraction = get_web_extractor().lists()
412
+ except OperatorError as exc:
413
+ raise SkillError(str(exc))
414
+ return Outcome(_compact(extraction.to_dict()))
415
+
416
+
417
+ @skill(
418
+ "web_extract_metadata",
419
+ "Extract page metadata: description, og tags, canonical url, language.",
420
+ PermissionLevel.DESKTOP,
421
+ )
422
+ def web_extract_metadata(ctx: SkillContext, params: dict[str, Any]) -> Outcome:
423
+ from .browser_extract import get_web_extractor
424
+
425
+ try:
426
+ extraction = get_web_extractor().metadata()
427
+ except OperatorError as exc:
428
+ raise SkillError(str(exc))
429
+ return Outcome(_compact(extraction.to_dict()))
430
+
431
+
432
+ @skill(
433
+ "web_extract_structured",
434
+ "Extract JSON-LD structured data (products, articles, recipes...).",
435
+ PermissionLevel.DESKTOP,
436
+ )
437
+ def web_extract_structured(ctx: SkillContext, params: dict[str, Any]) -> Outcome:
438
+ from .browser_extract import get_web_extractor
439
+
440
+ try:
441
+ extraction = get_web_extractor().structured_data()
442
+ except OperatorError as exc:
443
+ raise SkillError(str(exc))
444
+ return Outcome(_compact(extraction.to_dict()))
445
+
446
+
447
+ @skill(
448
+ "web_find",
449
+ "Find where a phrase appears on the page, with surrounding context.",
450
+ PermissionLevel.DESKTOP,
451
+ {"query": "text to locate on the page"},
452
+ )
453
+ def web_find(ctx: SkillContext, params: dict[str, Any]) -> Outcome:
454
+ from .browser_extract import get_web_extractor
455
+
456
+ query = str(params.get("query", "")).strip()
457
+ if not query:
458
+ raise SkillError("web_find requires a 'query' parameter")
459
+ try:
460
+ extraction = get_web_extractor().find_on_page(query)
461
+ except OperatorError as exc:
462
+ raise SkillError(str(exc))
463
+ return Outcome(_compact(extraction.to_dict()))
464
+
465
+
466
+ @skill(
467
+ "web_wait_for",
468
+ "Wait (bounded, state-based) until a CSS selector exists and is visible.",
469
+ PermissionLevel.DESKTOP,
470
+ {"selector": "CSS selector to wait for",
471
+ "timeout": "(optional) seconds (max 30)"},
472
+ )
473
+ def web_wait_for(ctx: SkillContext, params: dict[str, Any]) -> Outcome:
474
+ from .browser_extract import get_web_extractor
475
+
476
+ selector = str(params.get("selector", "")).strip()
477
+ if not selector:
478
+ raise SkillError("web_wait_for requires a 'selector' parameter")
479
+ try:
480
+ timeout = float(params.get("timeout", 8.0) or 8.0)
481
+ except (TypeError, ValueError):
482
+ timeout = 8.0
483
+ try:
484
+ ok = get_web_extractor().wait_for_element(selector, timeout_s=timeout)
485
+ except OperatorError as exc:
486
+ raise SkillError(str(exc))
487
+ if not ok:
488
+ raise SkillError(f'"{selector}" did not appear within {timeout:g}s.')
489
+ return Outcome(f'"{selector}" is present and visible')
490
+
491
+
492
+ # --- memory ----------------------------------------------------------------------
493
+
494
+ @skill(
495
+ "memory_save",
496
+ "Persist a structured note into local memory (web findings, app usage, "
497
+ "preferences). Secret-like fields are refused.",
498
+ PermissionLevel.WORKSPACE,
499
+ {"namespace": "sessions | desktop | user | web | files",
500
+ "id": "record id, e.g. 'app:spotify'", "summary": "one-line summary",
501
+ "data": "(optional) JSON object to store",
502
+ "tags": "(optional) comma-separated tags"},
503
+ )
504
+ def memory_save(ctx: SkillContext, params: dict[str, Any]) -> Outcome:
505
+ from ..core.errors import SecurityError
506
+ from ..memory.store import memory_store
507
+
508
+ namespace = str(params.get("namespace", "")).strip()
509
+ record_id = str(params.get("id", "")).strip()
510
+ summary = str(params.get("summary", "")).strip()
511
+ if not namespace or not record_id or not summary:
512
+ raise SkillError("memory_save requires 'namespace', 'id', and 'summary'")
513
+ data = params.get("data")
514
+ if isinstance(data, str):
515
+ try:
516
+ data = json.loads(data) if data.strip() else {}
517
+ except ValueError:
518
+ raise SkillError("'data' must be a JSON object")
519
+ if data is not None and not isinstance(data, dict):
520
+ raise SkillError("'data' must be a JSON object")
521
+ tags = [t.strip() for t in str(params.get("tags", "")).split(",") if t.strip()]
522
+ try:
523
+ ok = memory_store().put(namespace, record_id, summary, data, tags)
524
+ except SecurityError as exc:
525
+ raise SkillError(str(exc))
526
+ if not ok:
527
+ raise SkillError("memory write failed (disk error)")
528
+ return Outcome(f"saved {namespace}/{record_id}")
529
+
530
+
531
+ @skill(
532
+ "memory_search",
533
+ "Search local memory indexes (compact results; load records with "
534
+ "memory_get). Use for \"what did I...\" questions.",
535
+ PermissionLevel.WORKSPACE,
536
+ {"query": "text to look for", "namespace": "(optional) restrict to one namespace",
537
+ "tags": "(optional) comma-separated tags"},
538
+ )
539
+ def memory_search(ctx: SkillContext, params: dict[str, Any]) -> Outcome:
540
+ from ..memory.store import memory_store
541
+
542
+ query = str(params.get("query", "")).strip()
543
+ namespace = str(params.get("namespace", "")).strip()
544
+ tags = [t.strip() for t in str(params.get("tags", "")).split(",") if t.strip()]
545
+ store = memory_store()
546
+ namespaces = [namespace] if namespace else list(store.namespaces()) or ["sessions"]
547
+ results: dict[str, Any] = {}
548
+ for ns in namespaces:
549
+ try:
550
+ hits = store.query(ns, text=query, tags=tags, limit=8)
551
+ except ValueError:
552
+ continue
553
+ if hits:
554
+ results[ns] = hits
555
+ return Outcome(_compact(results) if results else "no matching memory records")
556
+
557
+
558
+ @skill(
559
+ "memory_get",
560
+ "Load one memory record by namespace and id.",
561
+ PermissionLevel.WORKSPACE,
562
+ {"namespace": "the record's namespace", "id": "the record id"},
563
+ )
564
+ def memory_get(ctx: SkillContext, params: dict[str, Any]) -> Outcome:
565
+ from ..memory.store import memory_store
566
+
567
+ namespace = str(params.get("namespace", "")).strip()
568
+ record_id = str(params.get("id", "")).strip()
569
+ if not namespace or not record_id:
570
+ raise SkillError("memory_get requires 'namespace' and 'id'")
571
+ record = memory_store().get(namespace, record_id)
572
+ if record is None:
573
+ raise SkillError(f"no record '{record_id}' in '{namespace}'")
574
+ return Outcome(_compact({
575
+ "id": record.id, "summary": record.summary, "tags": record.tags,
576
+ "created_at": record.created_at, "data": record.data,
577
+ }))