branchkit 0.1.0__tar.gz → 0.2.0__tar.gz
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.
- {branchkit-0.1.0 → branchkit-0.2.0}/PKG-INFO +1 -1
- {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/__init__.py +3 -0
- branchkit-0.2.0/branchkit/actor.py +56 -0
- {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/closed_vocab_gen.py +9 -7
- {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/collection.py +80 -1
- {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/collection_log.py +1 -1
- {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/commands.py +10 -6
- {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/contracts_gen.py +11 -2
- {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/debug.py +1 -1
- {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/effects.py +2 -2
- {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/harness.py +7 -2
- {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/listen.py +1 -1
- {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/log_events_gen.py +6 -2
- {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/methods_gen.py +82 -16
- {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/mirror.py +6 -2
- {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/plugin.py +124 -8
- {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/proxy.py +1 -1
- {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/settings.py +70 -7
- {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/types_gen.py +122 -37
- {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/ui.py +13 -2
- {branchkit-0.1.0 → branchkit-0.2.0}/branchkit.egg-info/PKG-INFO +1 -1
- {branchkit-0.1.0 → branchkit-0.2.0}/branchkit.egg-info/SOURCES.txt +4 -1
- {branchkit-0.1.0 → branchkit-0.2.0}/pyproject.toml +1 -1
- branchkit-0.2.0/tests/test_collection.py +109 -0
- {branchkit-0.1.0 → branchkit-0.2.0}/tests/test_kernel.py +103 -3
- branchkit-0.2.0/tests/test_settings_tabs.py +76 -0
- {branchkit-0.1.0 → branchkit-0.2.0}/LICENSE +0 -0
- {branchkit-0.1.0 → branchkit-0.2.0}/README.md +0 -0
- {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/correlation.py +0 -0
- {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/hud.py +0 -0
- {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/log.py +0 -0
- {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/settings_route.py +0 -0
- {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/upstream.py +0 -0
- {branchkit-0.1.0 → branchkit-0.2.0}/branchkit.egg-info/dependency_links.txt +0 -0
- {branchkit-0.1.0 → branchkit-0.2.0}/branchkit.egg-info/top_level.txt +0 -0
- {branchkit-0.1.0 → branchkit-0.2.0}/setup.cfg +0 -0
|
@@ -23,6 +23,7 @@ from . import proxy as _proxy
|
|
|
23
23
|
_proxy.install_proxy_from_env()
|
|
24
24
|
|
|
25
25
|
from .plugin import (
|
|
26
|
+
DetachedError,
|
|
26
27
|
PluginCore,
|
|
27
28
|
RecordingDisabledError,
|
|
28
29
|
RpcCallError,
|
|
@@ -32,6 +33,7 @@ from .plugin import (
|
|
|
32
33
|
plugin_data_dir,
|
|
33
34
|
plugin_dir,
|
|
34
35
|
)
|
|
36
|
+
from .actor import acting_for, get_current_actor
|
|
35
37
|
from .collection import CollectionMixin, list_opts, scope_collection, scope_group
|
|
36
38
|
from .collection_log import CollectionLogMixin, log_list_opts
|
|
37
39
|
from .debug import DebugMixin
|
|
@@ -58,6 +60,7 @@ from .settings_route import method_post, method_url
|
|
|
58
60
|
from .ui import (
|
|
59
61
|
Expr,
|
|
60
62
|
args,
|
|
63
|
+
js,
|
|
61
64
|
confirm_button,
|
|
62
65
|
expr,
|
|
63
66
|
input_value,
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# The actor label — who this plugin is acting *on behalf of*.
|
|
2
|
+
#
|
|
3
|
+
# A host-shaped plugin runs things the platform does not model: a scripting
|
|
4
|
+
# host runs script files, the browser plugin fronts an extension, an
|
|
5
|
+
# ambassador fronts an external app. Every platform call it makes is its own,
|
|
6
|
+
# over its own session, so audit rows, collection records, and events can name
|
|
7
|
+
# only the plugin. `acting_for` stamps the finer-grained label on the outbound
|
|
8
|
+
# envelope, and the platform carries it into what it writes.
|
|
9
|
+
#
|
|
10
|
+
# Observability only, by construction. The platform never consults the label
|
|
11
|
+
# for any decision — it cannot, because the host supplies it from inside its
|
|
12
|
+
# own process, so a lying host would only lie about a label it already had the
|
|
13
|
+
# grant to act under. Setting it widens nothing and narrows nothing; it makes
|
|
14
|
+
# the trail readable. Per-hosted-thing ENFORCEMENT would need real delegated
|
|
15
|
+
# identities, which is a different (unbuilt) feature.
|
|
16
|
+
#
|
|
17
|
+
# Scoped with a `contextvars.ContextVar`, exactly like the ambient inbound
|
|
18
|
+
# correlation: hosted things interleave across tasks, and a module global
|
|
19
|
+
# would let one hosted thing's label ride another's calls.
|
|
20
|
+
|
|
21
|
+
from contextlib import contextmanager
|
|
22
|
+
from contextvars import ContextVar
|
|
23
|
+
|
|
24
|
+
_current: ContextVar[str] = ContextVar("branchkit_actor", default="")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def set_actor(actor: str | None):
|
|
28
|
+
"""Make `actor` ambient for the current context. Returns a token for
|
|
29
|
+
`reset_actor`. A falsy actor sets the empty ambient — "no label"."""
|
|
30
|
+
return _current.set(actor or "")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def reset_actor(token) -> None:
|
|
34
|
+
_current.reset(token)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_current_actor() -> str:
|
|
38
|
+
"""The actor label outbound calls currently carry, or "" if none."""
|
|
39
|
+
return _current.get()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@contextmanager
|
|
43
|
+
def acting_for(actor: str | None):
|
|
44
|
+
"""Stamp `actor` on every RPC made inside the block.
|
|
45
|
+
|
|
46
|
+
with acting_for("headphones.py"):
|
|
47
|
+
await plugin.dispatch(action)
|
|
48
|
+
|
|
49
|
+
Nested blocks restore the outer label on exit, so one hosted thing
|
|
50
|
+
invoking another leaves the trail intact.
|
|
51
|
+
"""
|
|
52
|
+
token = set_actor(actor)
|
|
53
|
+
try:
|
|
54
|
+
yield
|
|
55
|
+
finally:
|
|
56
|
+
reset_actor(token)
|
|
@@ -9,16 +9,16 @@ from typing import NotRequired, TypedDict
|
|
|
9
9
|
INPUT_DIRECTIVE_LIFT_KEYS_ON_DISPATCH = "lift_keys_on_dispatch"
|
|
10
10
|
|
|
11
11
|
# EFFECT_* are the registered effect names plugins may declare in
|
|
12
|
-
# `
|
|
12
|
+
# `consumes.effects`. Source of truth:
|
|
13
13
|
# `actuator/src/effects.rs::EffectDef` inventory entries.
|
|
14
14
|
EFFECT_DISABLE_SCREEN_DIM = "disable_screen_dim"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
EFFECT_PREVENT_FOCUS_STEAL = "prevent_focus_steal"
|
|
15
|
+
EFFECT_PREVENT_SYSTEM_SLEEP = "prevent_system_sleep"
|
|
16
|
+
EFFECT_SIGNAL_CLIPBOARD_IN_USE = "signal_clipboard_in_use"
|
|
18
17
|
EFFECT_SIGNAL_RECORDING_ACTIVE = "signal_recording_active"
|
|
19
18
|
EFFECT_SUPPRESS_HUDS = "suppress_huds"
|
|
20
19
|
EFFECT_SUPPRESS_KEYBINDS = "suppress_keybinds"
|
|
21
20
|
EFFECT_SUPPRESS_NOTIFICATIONS = "suppress_notifications"
|
|
21
|
+
EFFECT_SUPPRESS_VOICE_COMMANDS = "suppress_voice_commands"
|
|
22
22
|
|
|
23
23
|
# The full closed-vocabulary sets.
|
|
24
24
|
KNOWN_INPUT_DIRECTIVES = (
|
|
@@ -27,13 +27,13 @@ KNOWN_INPUT_DIRECTIVES = (
|
|
|
27
27
|
|
|
28
28
|
KNOWN_EFFECTS = (
|
|
29
29
|
"disable_screen_dim",
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"prevent_focus_steal",
|
|
30
|
+
"prevent_system_sleep",
|
|
31
|
+
"signal_clipboard_in_use",
|
|
33
32
|
"signal_recording_active",
|
|
34
33
|
"suppress_huds",
|
|
35
34
|
"suppress_keybinds",
|
|
36
35
|
"suppress_notifications",
|
|
36
|
+
"suppress_voice_commands",
|
|
37
37
|
)
|
|
38
38
|
|
|
39
39
|
# ERROR_KIND_* are the closed-vocabulary `kind` values the actuator puts
|
|
@@ -89,7 +89,9 @@ KNOWN_ERROR_KINDS = (
|
|
|
89
89
|
FaultData = TypedDict("FaultData", {
|
|
90
90
|
"kind": str,
|
|
91
91
|
"collection": NotRequired[str],
|
|
92
|
+
"consent": NotRequired[str],
|
|
92
93
|
"detail": NotRequired[str],
|
|
93
94
|
"id": NotRequired[str],
|
|
94
95
|
"op": NotRequired[str],
|
|
96
|
+
"privilege": NotRequired[str],
|
|
95
97
|
})
|
|
@@ -70,9 +70,88 @@ class CollectionMixin:
|
|
|
70
70
|
return rec if isinstance(rec, dict) else None
|
|
71
71
|
|
|
72
72
|
async def list(self, name: str, opts: dict | None = None) -> list[dict]:
|
|
73
|
+
"""Read ONE PAGE of records.
|
|
74
|
+
|
|
75
|
+
Omitting `opts` does NOT mean "every record": the platform applies a
|
|
76
|
+
default limit when the caller supplies none, so a large collection
|
|
77
|
+
comes back truncated, and this method discards `total` so it cannot
|
|
78
|
+
tell you that happened.
|
|
79
|
+
|
|
80
|
+
Choose deliberately: some records -> `list` with an explicit `limit`;
|
|
81
|
+
every record -> `list_all`; a page plus the real count -> `list_page`.
|
|
82
|
+
"""
|
|
73
83
|
res = await self.collection_list(name, opts)
|
|
74
84
|
return (res or {}).get("records") or []
|
|
75
85
|
|
|
86
|
+
async def list_all(self, name: str) -> list[dict]:
|
|
87
|
+
"""Read EVERY record, defeating the platform's default list limit.
|
|
88
|
+
|
|
89
|
+
Use this when the read has to be exhaustive - clearing a collection,
|
|
90
|
+
reconciling against it, counting it. `list` returns one page and
|
|
91
|
+
discards `total`, so a caller using it cannot tell a complete read
|
|
92
|
+
from a capped one; that is a quiet correctness bug wherever
|
|
93
|
+
completeness was assumed.
|
|
94
|
+
|
|
95
|
+
Prefer `list` with an explicit `limit` when you only need some
|
|
96
|
+
records: this one is deliberately unbounded.
|
|
97
|
+
"""
|
|
98
|
+
return await self._list_exhaustive(name, compacted=False)
|
|
99
|
+
|
|
100
|
+
async def list_all_compacted(self, name: str) -> list[dict]:
|
|
101
|
+
"""`list_all` over the compacted-changelog projection - every folded
|
|
102
|
+
record, one per key. A keyed log with more live keys than the cap
|
|
103
|
+
otherwise folds to a view its reader believes is whole."""
|
|
104
|
+
return await self._list_exhaustive(name, compacted=True)
|
|
105
|
+
|
|
106
|
+
async def _list_exhaustive(self, name: str, compacted: bool) -> list[dict]:
|
|
107
|
+
"""Reads a collection completely, re-reading while it grows.
|
|
108
|
+
|
|
109
|
+
Normally two round trips, not a cursor walk: `total` comes back with
|
|
110
|
+
the first page, so the second read is bounded exactly. Cursor paging
|
|
111
|
+
would be wrong anyway on contribution-keyed storage, where `cursor` is
|
|
112
|
+
a no-op.
|
|
113
|
+
|
|
114
|
+
The probe passes an EXPLICIT limit rather than omitting one. Reading
|
|
115
|
+
with no limit to discover `total` would trip the platform's
|
|
116
|
+
default-limit warning on every call - this helper would manufacture
|
|
117
|
+
the exact noise that warning exists to surface, burying real
|
|
118
|
+
occurrences underneath it.
|
|
119
|
+
|
|
120
|
+
`total` is the FOLDED count when compacted, so the short-circuit is a
|
|
121
|
+
real one on both projections rather than a guaranteed miss.
|
|
122
|
+
"""
|
|
123
|
+
first_page = 1000
|
|
124
|
+
|
|
125
|
+
async def page(limit: int) -> tuple[list[dict], int]:
|
|
126
|
+
opts: dict = {"limit": limit}
|
|
127
|
+
if compacted:
|
|
128
|
+
opts["compacted"] = True
|
|
129
|
+
return await self.list_page(name, opts)
|
|
130
|
+
|
|
131
|
+
# Re-reads until the page covers `total`, because `total` is
|
|
132
|
+
# observed on the read that returns it and the collection can grow
|
|
133
|
+
# between reads. Taking the first `total` on faith would hand back a
|
|
134
|
+
# short result reported as complete - the same "mirror declares
|
|
135
|
+
# itself Ready over a truncated read" this helper exists to prevent,
|
|
136
|
+
# just with a narrower window: one write landing mid-refresh is
|
|
137
|
+
# enough.
|
|
138
|
+
#
|
|
139
|
+
# Bounded rather than unbounded: a collection written faster than it
|
|
140
|
+
# can be read is not a condition to spin on, and a caller waiting on
|
|
141
|
+
# a mirror refresh should get an answer. Practically it settles on
|
|
142
|
+
# the second read.
|
|
143
|
+
max_passes = 5
|
|
144
|
+
limit = first_page
|
|
145
|
+
for _ in range(max_passes):
|
|
146
|
+
records, total = await page(limit)
|
|
147
|
+
if len(records) >= total:
|
|
148
|
+
return records
|
|
149
|
+
limit = total
|
|
150
|
+
raise RuntimeError(
|
|
151
|
+
f"collection {name!r} kept growing across {max_passes} exhaustive "
|
|
152
|
+
"reads; read it with an explicit limit and page with cursor instead"
|
|
153
|
+
)
|
|
154
|
+
|
|
76
155
|
async def list_compacted(self, name: str, opts: dict | None = None) -> list[dict]:
|
|
77
156
|
"""The compacted-changelog projection of a keyed log — one folded
|
|
78
157
|
record per key instead of the raw append history."""
|
|
@@ -120,7 +199,7 @@ class CollectionMixin:
|
|
|
120
199
|
``{"put": n, "deleted": n, "skipped": n}``.
|
|
121
200
|
|
|
122
201
|
Scope is required and never inferred — pass `scope_collection()` or
|
|
123
|
-
`scope_group(...)`. See
|
|
202
|
+
`scope_group(...)`. See docs/design/DESIGN_COLLECTION_REPLACE.md."""
|
|
124
203
|
# Refused locally rather than sent: guessing between "everything I
|
|
125
204
|
# own here" and "the subset under this key space" is how a refresh
|
|
126
205
|
# silently becomes a wipe.
|
|
@@ -62,7 +62,7 @@ class CollectionLogMixin:
|
|
|
62
62
|
"""Annotate a keyed log (`log` preset, `id_strategy: by_field`):
|
|
63
63
|
appends `payload` with `key` stamped into the key field, as a fresh
|
|
64
64
|
append; same-key appends fold. Read the merged view with
|
|
65
|
-
`list_compacted`. See
|
|
65
|
+
`list_compacted`. See docs/design/DESIGN_LOG_ANNOTATION_PROJECTION.md."""
|
|
66
66
|
await self.collection_append_keyed(key, name, payload)
|
|
67
67
|
|
|
68
68
|
async def list_log(self, name: str, opts: dict | None = None) -> list[dict]:
|
|
@@ -69,9 +69,10 @@ class CommandBuilder:
|
|
|
69
69
|
|
|
70
70
|
def action(self, type: str, params: dict | None = None) -> "CommandBuilder":
|
|
71
71
|
"""Set the action fired on match. `type` is the action's type (a
|
|
72
|
-
built-in like "key" or a dotted plugin action); `params`
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
built-in like "key" or a dotted plugin action); `params` nest under
|
|
73
|
+
the action object's `params` key — one dialect, see
|
|
74
|
+
DESIGN_ONE_PARAMS_DIALECT.md."""
|
|
75
|
+
self._spec["action"] = {"type": type, "params": params} if params else {"type": type}
|
|
75
76
|
return self
|
|
76
77
|
|
|
77
78
|
def requires_tags(self, *tags: str) -> "CommandBuilder":
|
|
@@ -102,9 +103,12 @@ class CommandBuilder:
|
|
|
102
103
|
return self
|
|
103
104
|
|
|
104
105
|
def discovery(self, mode: str) -> "CommandBuilder":
|
|
105
|
-
"""Declare the command's prefix-discovery affordance ("prefix"
|
|
106
|
-
"exclusive"
|
|
107
|
-
|
|
106
|
+
"""Declare the command's prefix-discovery affordance ("prefix",
|
|
107
|
+
"exclusive", or "select" — select points at entries via
|
|
108
|
+
platform-assigned alphabet codewords instead of their names). Valid
|
|
109
|
+
only on a literal-prefix + single-tail-capture pattern. See
|
|
110
|
+
docs/design/DESIGN_DISCOVERABLE_PREFIX.md and
|
|
111
|
+
DESIGN_SELECTION_PRIMITIVE.md."""
|
|
108
112
|
self._spec["discovery"] = mode
|
|
109
113
|
return self
|
|
110
114
|
|
|
@@ -59,6 +59,7 @@ METHOD_INPUT_DOUBLE_CLICK = "input.double_click" # since 0.1.0
|
|
|
59
59
|
METHOD_INPUT_DRAG = "input.drag" # since 0.1.0
|
|
60
60
|
METHOD_INPUT_LIST_INPUT_SOURCES = "input.list_input_sources" # since 0.1.0
|
|
61
61
|
METHOD_INPUT_MOUSE_BUTTON = "input.mouse_button" # since 0.1.0
|
|
62
|
+
METHOD_INPUT_PARSE_KEY_EVENT = "input.parse_key_event" # since 0.1.0
|
|
62
63
|
METHOD_INPUT_PRESS_KEY = "input.press_key" # since 0.1.0
|
|
63
64
|
METHOD_INPUT_RAW_KEY = "input.raw_key" # since 0.1.0
|
|
64
65
|
METHOD_INPUT_RIGHT_CLICK = "input.right_click" # since 0.1.0
|
|
@@ -67,7 +68,6 @@ METHOD_INPUT_SELECT_ALL = "input.select_all" # since 0.1.0
|
|
|
67
68
|
METHOD_INPUT_SWITCH_INPUT_SOURCE = "input.switch_input_source" # since 0.1.0
|
|
68
69
|
METHOD_INPUT_TRIPLE_CLICK = "input.triple_click" # since 0.1.0
|
|
69
70
|
METHOD_INPUT_TYPE_TEXT = "input.type_text" # since 0.1.0
|
|
70
|
-
METHOD_KEY_NAMES_SET = "key_names.set" # since 0.1.0
|
|
71
71
|
METHOD_KEYBINDS_REGISTER = "keybinds.register" # since 0.1.0
|
|
72
72
|
METHOD_MODEL_DELETE = "model.delete" # since 0.1.0
|
|
73
73
|
METHOD_NATIVE_ACCENT_COLOR = "native.accent_color" # since 0.1.0
|
|
@@ -587,9 +587,15 @@ METHOD_PIPELINES_STOP = "pipelines.stop" # since 0.1.0
|
|
|
587
587
|
METHOD_PIPELINES_WARM = "pipelines.warm" # since 0.1.0
|
|
588
588
|
METHOD_PLUGIN_DATA_EXPORT = "plugin.data.export" # since 0.1.0
|
|
589
589
|
METHOD_PLUGIN_DEBUG = "plugin.debug" # since 0.1.0
|
|
590
|
+
METHOD_PLUGIN_REPORT_HEALTH = "plugin.report_health" # since 0.1.0
|
|
590
591
|
METHOD_PRIVACY_GET_RECORDING = "privacy.get_recording" # stable, since 0.1.0
|
|
591
592
|
METHOD_PRIVACY_SET_RECORDING = "privacy.set_recording" # stable, since 0.1.0
|
|
593
|
+
METHOD_PRIVILEGES_LIST = "privileges.list" # since 0.1.0
|
|
594
|
+
METHOD_PRIVILEGES_RELINQUISH = "privileges.relinquish" # since 0.1.0
|
|
595
|
+
METHOD_PRIVILEGES_REQUEST = "privileges.request" # since 0.1.0
|
|
592
596
|
METHOD_RECOGNITION_BIAS_APPLY = "recognition.bias.apply" # since 0.1.0
|
|
597
|
+
METHOD_RECOGNITION_BIAS_GET = "recognition.bias.get" # since 0.1.0
|
|
598
|
+
METHOD_RECOGNITION_BIAS_SET = "recognition.bias.set" # since 0.1.0
|
|
593
599
|
METHOD_RECOGNITION_REDECODE = "recognition.redecode" # since 0.1.0
|
|
594
600
|
METHOD_SELECTION_PICK = "selection.pick" # since 0.1.0
|
|
595
601
|
METHOD_SELECTION_SET = "selection.set" # since 0.1.0
|
|
@@ -609,11 +615,12 @@ METHOD_TRIAL_ENTER_CONTEXT = "trial.enter_context" # since 0.1.0
|
|
|
609
615
|
METHOD_TRIAL_REGISTER_FIXTURE = "trial.register_fixture" # since 0.1.0
|
|
610
616
|
METHOD_TRIAL_RESOLVE_SAMPLES = "trial.resolve_samples" # since 0.1.0
|
|
611
617
|
METHOD_VOCABULARY_COMMIT = "vocabulary.commit" # since 0.1.0
|
|
618
|
+
METHOD_WIRING_DESCRIBE = "wiring.describe" # since 0.1.0
|
|
612
619
|
|
|
613
620
|
# RPC method names: actuator → plugin (use with plugin.handle()).
|
|
614
621
|
HOOK_ON_ACTION = "on_action" # since 0.1.0
|
|
615
622
|
HOOK_ON_COMMANDS_CHANGED = "on_commands_changed" # since 0.1.0
|
|
616
|
-
|
|
623
|
+
HOOK_ON_TRANSCRIPT = "on_transcript" # since 0.1.0
|
|
617
624
|
HOOK_RENDER_SETTINGS = "render_settings" # since 0.1.0
|
|
618
625
|
HOOK_TRIAL_APPLY_FIXTURE = "trial_apply_fixture" # since 0.1.0
|
|
619
626
|
HOOK_TRIAL_RELEASE_FIXTURE = "trial_release_fixture" # since 0.1.0
|
|
@@ -630,6 +637,7 @@ EVENT_COLLECTION_UPDATED = "_platform.collection.updated"
|
|
|
630
637
|
EVENT_DISCOVERY_CLOSED = "_platform.discovery.closed"
|
|
631
638
|
EVENT_DISPLAY_CHANGED = "_platform.display.changed"
|
|
632
639
|
EVENT_EFFECT_DISPLACED = "_platform.effect.displaced"
|
|
640
|
+
EVENT_EFFECT_OWNERSHIP_CHANGED = "_platform.effect.ownership_changed"
|
|
633
641
|
EVENT_HID_CONNECTED = "_platform.hid.connected"
|
|
634
642
|
EVENT_HID_DISCONNECTED = "_platform.hid.disconnected"
|
|
635
643
|
EVENT_HID_INPUT = "_platform.hid.input"
|
|
@@ -649,6 +657,7 @@ EVENT_PLUGIN_DEGRADED = "_platform.plugin.degraded"
|
|
|
649
657
|
EVENT_PLUGIN_DISABLED = "_platform.plugin.disabled"
|
|
650
658
|
EVENT_PLUGIN_ENABLED = "_platform.plugin.enabled"
|
|
651
659
|
EVENT_POWER_CHANGED = "_platform.power.changed"
|
|
660
|
+
EVENT_PRIVILEGE_GRANTED = "_platform.privilege.granted"
|
|
652
661
|
EVENT_SELECTION_PICKED = "_platform.selection.picked"
|
|
653
662
|
EVENT_SYSTEM_DID_WAKE = "_platform.system.did_wake"
|
|
654
663
|
EVENT_SYSTEM_WILL_SLEEP = "_platform.system.will_sleep"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Per-plugin leveled log façade over `plugin.debug` (the RPC method).
|
|
2
2
|
Python twin of debug.{go,ts}. Lines land in
|
|
3
3
|
`<app_support>/plugin-logs/<pluginID>.log`; warn/error cross-post to
|
|
4
|
-
actuator.log. See
|
|
4
|
+
actuator.log. See docs/design/DESIGN_PLUGIN_LOG_LEVELS.md."""
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""Capability-mechanism helpers (effects). Python twin of effects.{go,ts}.
|
|
2
|
-
See
|
|
2
|
+
See docs/design/DESIGN_CAPABILITY_MECHANISM.md."""
|
|
3
3
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
@@ -15,7 +15,7 @@ def _optional_str(v) -> str | None:
|
|
|
15
15
|
class EffectsMixin:
|
|
16
16
|
async def assert_effect(self, name: str) -> dict:
|
|
17
17
|
"""Declare this plugin is asserting `name` (which must be declared
|
|
18
|
-
in the manifest's `
|
|
18
|
+
in the manifest's `consumes.effects[*].asserts`). Returns
|
|
19
19
|
``{"granted", "already_held", "displaced", "enforced"}``."""
|
|
20
20
|
res = await self.effects_assert(name)
|
|
21
21
|
return {
|
|
@@ -275,7 +275,11 @@ def find_harness_binary() -> str:
|
|
|
275
275
|
env = os.environ.get("BRANCHKIT_TEST_HARNESS")
|
|
276
276
|
if env:
|
|
277
277
|
return env
|
|
278
|
+
# The installed app ships the harness in its Resources; the cargo
|
|
279
|
+
# target paths serve app-repo checkouts.
|
|
278
280
|
candidates = [
|
|
281
|
+
"/Applications/BranchKit.app/Contents/Resources/branchkit-test-harness",
|
|
282
|
+
os.path.expanduser("~/Applications/BranchKit.app/Contents/Resources/branchkit-test-harness"),
|
|
279
283
|
"target/debug/branchkit-test-harness",
|
|
280
284
|
"target/release/branchkit-test-harness",
|
|
281
285
|
"../target/debug/branchkit-test-harness",
|
|
@@ -291,6 +295,7 @@ def find_harness_binary() -> str:
|
|
|
291
295
|
if found:
|
|
292
296
|
return found
|
|
293
297
|
raise OSError(
|
|
294
|
-
"harness: cannot find branchkit-test-harness binary. "
|
|
295
|
-
"
|
|
298
|
+
"harness: cannot find branchkit-test-harness binary. It ships inside "
|
|
299
|
+
"BranchKit.app (Contents/Resources); install the app, or set "
|
|
300
|
+
"BRANCHKIT_TEST_HARNESS to a harness binary."
|
|
296
301
|
)
|
|
@@ -9,7 +9,7 @@ network namespace, where a self-bound 127.0.0.1 is a private dead
|
|
|
9
9
|
loopback — the inherited host-loopback listener is the only reachable
|
|
10
10
|
surface. CPython serves an inherited fd natively
|
|
11
11
|
(`socket.socket(fileno=...)`), so unlike TS there is no two-runtime
|
|
12
|
-
dance. See the actuator's
|
|
12
|
+
dance. See the actuator's docs/design/DESIGN_SANDBOX_LOOPBACK_FDPASS.md."""
|
|
13
13
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# Run: just contracts
|
|
3
3
|
#
|
|
4
4
|
# BranchKit observability bus — closed log-event vocabulary.
|
|
5
|
-
# See
|
|
5
|
+
# See docs/design/DESIGN_OBSERVABILITY_BUS.md for the architectural contract.
|
|
6
6
|
|
|
7
7
|
from typing import TypedDict
|
|
8
8
|
|
|
@@ -14,6 +14,8 @@ LOG_EVENT_CAPTURE_PROGRESS = "capture.progress"
|
|
|
14
14
|
LOG_EVENT_COMMAND_MATCH_DECISION = "command.match_decision"
|
|
15
15
|
LOG_EVENT_COMMAND_MATCHED = "command.matched"
|
|
16
16
|
LOG_EVENT_COMMAND_NO_MATCH = "command.no_match"
|
|
17
|
+
LOG_EVENT_CONSENT_DECISION = "consent.decision"
|
|
18
|
+
LOG_EVENT_CONSENT_PRIVILEGED_OP = "consent.privileged_op"
|
|
17
19
|
LOG_EVENT_DIAGNOSTIC_MESSAGE = "diagnostic.message"
|
|
18
20
|
LOG_EVENT_DISPATCH_ACTION_DISPATCHED = "dispatch.action_dispatched"
|
|
19
21
|
LOG_EVENT_DISPATCH_ACTION_PANICKED = "dispatch.action_panicked"
|
|
@@ -84,6 +86,8 @@ LOG_EVENT_REGISTRY: dict[str, LogEventMeta] = {
|
|
|
84
86
|
"command.match_decision": {"name": "command.match_decision", "summary": "Diagnostic snapshot of the matcher's tiebreaker choice — which category won, which gates were active, whether a gated Partial was suppressed.", "since": "0.1.0", "source": "dispatch", "severity": "debug", "redaction": "none"},
|
|
85
87
|
"command.matched": {"name": "command.matched", "summary": "Speech recognition produced a match against a registered command.", "since": "0.1.0", "source": "dispatch", "severity": "info", "redaction": "none"},
|
|
86
88
|
"command.no_match": {"name": "command.no_match", "summary": "Speech input did not match any registered command.", "since": "0.1.0", "source": "dispatch", "severity": "debug", "redaction": "none"},
|
|
89
|
+
"consent.decision": {"name": "consent.decision", "summary": "A consent decision on any axis (privilege, dispatch, event subscription, collection read/write, effect, trusted author): granted (always/once), denied, dismissed, revoked, or reconciled by an update — with the surface that produced it. Audit-eligible.", "since": "0.1.0", "source": "plugins", "severity": "info", "redaction": "none"},
|
|
90
|
+
"consent.privileged_op": {"name": "consent.privileged_op", "summary": "A plugin invoked (or was refused) an operation gated on a dangerous privilege. Audit-eligible.", "since": "0.1.0", "source": "plugins", "severity": "info", "redaction": "none"},
|
|
87
91
|
"diagnostic.message": {"name": "diagnostic.message", "summary": "Catch-all for ad-hoc operational text that doesn't fit a typed variant.", "since": "0.1.0", "source": "generic", "severity": "debug", "redaction": "none"},
|
|
88
92
|
"dispatch.action_dispatched": {"name": "dispatch.action_dispatched", "summary": "An action entered the dispatch pipeline.", "since": "0.1.0", "source": "dispatch", "severity": "debug", "redaction": "none"},
|
|
89
93
|
"dispatch.action_panicked": {"name": "dispatch.action_panicked", "summary": "A dispatch handler panicked. Process likely degraded; investigate.", "since": "0.1.0", "source": "dispatch", "severity": "error", "redaction": "none"},
|
|
@@ -104,7 +108,7 @@ LOG_EVENT_REGISTRY: dict[str, LogEventMeta] = {
|
|
|
104
108
|
"native.window_skipped": {"name": "native.window_skipped", "summary": "World poller skipped a non-standard window (debug-level).", "since": "0.1.0", "source": "native", "severity": "debug", "redaction": "none"},
|
|
105
109
|
"native.world_poller_event": {"name": "native.world_poller_event", "summary": "World poller lifecycle event (started, seeded, batch processed, idle).", "since": "0.1.0", "source": "native", "severity": "debug", "redaction": "none"},
|
|
106
110
|
"operation.completed": {"name": "operation.completed", "summary": "An operation handler completed execution.", "since": "0.1.0", "source": "dispatch", "severity": "debug", "redaction": "none"},
|
|
107
|
-
"plugin.degraded": {"name": "plugin.degraded", "summary": "A
|
|
111
|
+
"plugin.degraded": {"name": "plugin.degraded", "summary": "A plugin is running but not fully working — either its RPC channel hit the consecutive-timeout threshold (reason absent), or the plugin reported the fault itself via plugin.report_health (reason present).", "since": "0.1.0", "source": "plugins", "severity": "warn", "redaction": "none"},
|
|
108
112
|
"plugin.diagnostic": {"name": "plugin.diagnostic", "summary": "A plugin emitted a warn- or error-level diagnostic via plugin.debug; cross-posted to actuator.log so plugin-level failures interleave with the actuator's view of dispatch / coordination.", "since": "0.2.0", "source": "plugins", "severity": "warn", "redaction": "none"},
|
|
109
113
|
"plugin.exited": {"name": "plugin.exited", "summary": "A managed plugin process exited (clean or crashed).", "since": "0.1.0", "source": "plugins", "severity": "info", "redaction": "none"},
|
|
110
114
|
"plugin.lifecycle_op": {"name": "plugin.lifecycle_op", "summary": "A plugin lifecycle operation (install, uninstall, update, reload, hot-reload, enable, disable).", "since": "0.1.0", "source": "plugins", "severity": "info", "redaction": "none"},
|
|
@@ -60,6 +60,7 @@ from .contracts_gen import (
|
|
|
60
60
|
METHOD_INPUT_DRAG,
|
|
61
61
|
METHOD_INPUT_LIST_INPUT_SOURCES,
|
|
62
62
|
METHOD_INPUT_MOUSE_BUTTON,
|
|
63
|
+
METHOD_INPUT_PARSE_KEY_EVENT,
|
|
63
64
|
METHOD_INPUT_PRESS_KEY,
|
|
64
65
|
METHOD_INPUT_RAW_KEY,
|
|
65
66
|
METHOD_INPUT_RIGHT_CLICK,
|
|
@@ -69,7 +70,6 @@ from .contracts_gen import (
|
|
|
69
70
|
METHOD_INPUT_TRIPLE_CLICK,
|
|
70
71
|
METHOD_INPUT_TYPE_TEXT,
|
|
71
72
|
METHOD_KEYBINDS_REGISTER,
|
|
72
|
-
METHOD_KEY_NAMES_SET,
|
|
73
73
|
METHOD_MODEL_DELETE,
|
|
74
74
|
METHOD_NATIVE_ACCENT_COLOR,
|
|
75
75
|
METHOD_NATIVE_ACCESSIBILITY_DISPLAY_INVERT,
|
|
@@ -588,9 +588,15 @@ from .contracts_gen import (
|
|
|
588
588
|
METHOD_PIPELINES_WARM,
|
|
589
589
|
METHOD_PLUGIN_DATA_EXPORT,
|
|
590
590
|
METHOD_PLUGIN_DEBUG,
|
|
591
|
+
METHOD_PLUGIN_REPORT_HEALTH,
|
|
591
592
|
METHOD_PRIVACY_GET_RECORDING,
|
|
592
593
|
METHOD_PRIVACY_SET_RECORDING,
|
|
594
|
+
METHOD_PRIVILEGES_LIST,
|
|
595
|
+
METHOD_PRIVILEGES_RELINQUISH,
|
|
596
|
+
METHOD_PRIVILEGES_REQUEST,
|
|
593
597
|
METHOD_RECOGNITION_BIAS_APPLY,
|
|
598
|
+
METHOD_RECOGNITION_BIAS_GET,
|
|
599
|
+
METHOD_RECOGNITION_BIAS_SET,
|
|
594
600
|
METHOD_RECOGNITION_REDECODE,
|
|
595
601
|
METHOD_SELECTION_PICK,
|
|
596
602
|
METHOD_SELECTION_SET,
|
|
@@ -610,6 +616,7 @@ from .contracts_gen import (
|
|
|
610
616
|
METHOD_TRIAL_REGISTER_FIXTURE,
|
|
611
617
|
METHOD_TRIAL_RESOLVE_SAMPLES,
|
|
612
618
|
METHOD_VOCABULARY_COMMIT,
|
|
619
|
+
METHOD_WIRING_DESCRIBE,
|
|
613
620
|
)
|
|
614
621
|
|
|
615
622
|
if TYPE_CHECKING:
|
|
@@ -662,9 +669,9 @@ if TYPE_CHECKING:
|
|
|
662
669
|
HidElementEntry,
|
|
663
670
|
InputClipboardReadFormatResponse,
|
|
664
671
|
InputClipboardReadResponse,
|
|
672
|
+
InputParseKeyEventResponse,
|
|
665
673
|
InputSource,
|
|
666
674
|
InstalledApp,
|
|
667
|
-
KeyNamesSetResponse,
|
|
668
675
|
KeybindsRegisterResponse,
|
|
669
676
|
ListOpts,
|
|
670
677
|
ListeningPort,
|
|
@@ -952,8 +959,13 @@ if TYPE_CHECKING:
|
|
|
952
959
|
PluginDataExportResponse,
|
|
953
960
|
PrinterInfo,
|
|
954
961
|
PrivacyGetRecordingResponse,
|
|
962
|
+
PrivilegeStatusEntry,
|
|
963
|
+
PrivilegesRelinquishResponse,
|
|
964
|
+
PrivilegesRequestResponse,
|
|
955
965
|
ProcessInfo,
|
|
956
966
|
RecognitionBiasApplyResponse,
|
|
967
|
+
RecognitionBiasGetResponse,
|
|
968
|
+
RecognitionBiasSetResponse,
|
|
957
969
|
RecognitionRedecodeResponse,
|
|
958
970
|
RedecodeItem,
|
|
959
971
|
ReminderItem,
|
|
@@ -974,6 +986,7 @@ if TYPE_CHECKING:
|
|
|
974
986
|
UsbDevice,
|
|
975
987
|
WindowDetail,
|
|
976
988
|
WindowFrame,
|
|
989
|
+
WiringCollection,
|
|
977
990
|
WorldModel,
|
|
978
991
|
)
|
|
979
992
|
|
|
@@ -1226,14 +1239,10 @@ class MethodsMixin:
|
|
|
1226
1239
|
async def discovery_closed(self) -> None:
|
|
1227
1240
|
await self.call(METHOD_DISCOVERY_CLOSED)
|
|
1228
1241
|
|
|
1229
|
-
async def dispatch(self, action: Any
|
|
1242
|
+
async def dispatch(self, action: Any) -> DispatchResponse:
|
|
1230
1243
|
params: dict[str, Any] = {
|
|
1231
1244
|
"action": action,
|
|
1232
1245
|
}
|
|
1233
|
-
if session_id is not None:
|
|
1234
|
-
params["session_id"] = session_id
|
|
1235
|
-
if trace_id is not None:
|
|
1236
|
-
params["trace_id"] = trace_id
|
|
1237
1246
|
result = await self.call(METHOD_DISPATCH, params)
|
|
1238
1247
|
return result
|
|
1239
1248
|
|
|
@@ -1420,6 +1429,24 @@ class MethodsMixin:
|
|
|
1420
1429
|
params["button"] = button
|
|
1421
1430
|
await self.call(METHOD_INPUT_MOUSE_BUTTON, params)
|
|
1422
1431
|
|
|
1432
|
+
async def input_parse_key_event(self, alt: bool | None = None, code: str | None = None, ctrl: bool | None = None, key: str | None = None, meta: bool | None = None, shift: bool | None = None) -> InputParseKeyEventResponse:
|
|
1433
|
+
params: dict[str, Any] = {
|
|
1434
|
+
}
|
|
1435
|
+
if alt is not None:
|
|
1436
|
+
params["alt"] = alt
|
|
1437
|
+
if code is not None:
|
|
1438
|
+
params["code"] = code
|
|
1439
|
+
if ctrl is not None:
|
|
1440
|
+
params["ctrl"] = ctrl
|
|
1441
|
+
if key is not None:
|
|
1442
|
+
params["key"] = key
|
|
1443
|
+
if meta is not None:
|
|
1444
|
+
params["meta"] = meta
|
|
1445
|
+
if shift is not None:
|
|
1446
|
+
params["shift"] = shift
|
|
1447
|
+
result = await self.call(METHOD_INPUT_PARSE_KEY_EVENT, params)
|
|
1448
|
+
return result
|
|
1449
|
+
|
|
1423
1450
|
async def input_press_key(self, code: int | None = None, modifiers: list[str] | None = None, name: str | None = None) -> None:
|
|
1424
1451
|
params: dict[str, Any] = {
|
|
1425
1452
|
}
|
|
@@ -1482,14 +1509,6 @@ class MethodsMixin:
|
|
|
1482
1509
|
}
|
|
1483
1510
|
await self.call(METHOD_INPUT_TYPE_TEXT, params)
|
|
1484
1511
|
|
|
1485
|
-
async def key_names_set(self, names: dict[str, int] | None = None) -> KeyNamesSetResponse:
|
|
1486
|
-
params: dict[str, Any] = {
|
|
1487
|
-
}
|
|
1488
|
-
if names is not None:
|
|
1489
|
-
params["names"] = names
|
|
1490
|
-
result = await self.call(METHOD_KEY_NAMES_SET, params)
|
|
1491
|
-
return result
|
|
1492
|
-
|
|
1493
1512
|
async def keybinds_register(self, snapshot: Any) -> KeybindsRegisterResponse:
|
|
1494
1513
|
params: dict[str, Any] = {
|
|
1495
1514
|
"snapshot": snapshot,
|
|
@@ -4184,10 +4203,11 @@ class MethodsMixin:
|
|
|
4184
4203
|
result = await self.call(METHOD_PIPELINES_GRAMMAR, params)
|
|
4185
4204
|
return result
|
|
4186
4205
|
|
|
4187
|
-
async def pipelines_inject(self, event_type: str, name: str, data: Any | None = None) -> PipelinesInjectResponse:
|
|
4206
|
+
async def pipelines_inject(self, event_type: str, name: str, stage: str, data: Any | None = None) -> PipelinesInjectResponse:
|
|
4188
4207
|
params: dict[str, Any] = {
|
|
4189
4208
|
"event_type": event_type,
|
|
4190
4209
|
"name": name,
|
|
4210
|
+
"stage": stage,
|
|
4191
4211
|
}
|
|
4192
4212
|
if data is not None:
|
|
4193
4213
|
params["data"] = data
|
|
@@ -4247,6 +4267,14 @@ class MethodsMixin:
|
|
|
4247
4267
|
params["tag"] = tag
|
|
4248
4268
|
await self.call(METHOD_PLUGIN_DEBUG, params)
|
|
4249
4269
|
|
|
4270
|
+
async def plugin_report_health(self, degraded: bool, reason: str | None = None) -> None:
|
|
4271
|
+
params: dict[str, Any] = {
|
|
4272
|
+
"degraded": degraded,
|
|
4273
|
+
}
|
|
4274
|
+
if reason is not None:
|
|
4275
|
+
params["reason"] = reason
|
|
4276
|
+
await self.call(METHOD_PLUGIN_REPORT_HEALTH, params)
|
|
4277
|
+
|
|
4250
4278
|
async def privacy_get_recording(self, name: str) -> PrivacyGetRecordingResponse:
|
|
4251
4279
|
params: dict[str, Any] = {
|
|
4252
4280
|
"name": name,
|
|
@@ -4261,6 +4289,26 @@ class MethodsMixin:
|
|
|
4261
4289
|
}
|
|
4262
4290
|
await self.call(METHOD_PRIVACY_SET_RECORDING, params)
|
|
4263
4291
|
|
|
4292
|
+
async def privileges_list(self) -> list[PrivilegeStatusEntry]:
|
|
4293
|
+
result = await self.call(METHOD_PRIVILEGES_LIST)
|
|
4294
|
+
return (result or {}).get("privileges") or []
|
|
4295
|
+
|
|
4296
|
+
async def privileges_relinquish(self, privilege: str) -> PrivilegesRelinquishResponse:
|
|
4297
|
+
params: dict[str, Any] = {
|
|
4298
|
+
"privilege": privilege,
|
|
4299
|
+
}
|
|
4300
|
+
result = await self.call(METHOD_PRIVILEGES_RELINQUISH, params)
|
|
4301
|
+
return result
|
|
4302
|
+
|
|
4303
|
+
async def privileges_request(self, privilege: str, reason: str | None = None) -> PrivilegesRequestResponse:
|
|
4304
|
+
params: dict[str, Any] = {
|
|
4305
|
+
"privilege": privilege,
|
|
4306
|
+
}
|
|
4307
|
+
if reason is not None:
|
|
4308
|
+
params["reason"] = reason
|
|
4309
|
+
result = await self.call(METHOD_PRIVILEGES_REQUEST, params)
|
|
4310
|
+
return result
|
|
4311
|
+
|
|
4264
4312
|
async def recognition_bias_apply(self, strength: float, force: bool | None = None) -> RecognitionBiasApplyResponse:
|
|
4265
4313
|
params: dict[str, Any] = {
|
|
4266
4314
|
"strength": strength,
|
|
@@ -4270,6 +4318,20 @@ class MethodsMixin:
|
|
|
4270
4318
|
result = await self.call(METHOD_RECOGNITION_BIAS_APPLY, params)
|
|
4271
4319
|
return result
|
|
4272
4320
|
|
|
4321
|
+
async def recognition_bias_get(self) -> RecognitionBiasGetResponse:
|
|
4322
|
+
result = await self.call(METHOD_RECOGNITION_BIAS_GET)
|
|
4323
|
+
return result
|
|
4324
|
+
|
|
4325
|
+
async def recognition_bias_set(self, enabled: bool | None = None, strength: float | None = None) -> RecognitionBiasSetResponse:
|
|
4326
|
+
params: dict[str, Any] = {
|
|
4327
|
+
}
|
|
4328
|
+
if enabled is not None:
|
|
4329
|
+
params["enabled"] = enabled
|
|
4330
|
+
if strength is not None:
|
|
4331
|
+
params["strength"] = strength
|
|
4332
|
+
result = await self.call(METHOD_RECOGNITION_BIAS_SET, params)
|
|
4333
|
+
return result
|
|
4334
|
+
|
|
4273
4335
|
async def recognition_redecode(self, items: list["RedecodeItem"], model: str, stage: str, max_active: int | None = None) -> RecognitionRedecodeResponse:
|
|
4274
4336
|
params: dict[str, Any] = {
|
|
4275
4337
|
"items": items,
|
|
@@ -4429,3 +4491,7 @@ class MethodsMixin:
|
|
|
4429
4491
|
|
|
4430
4492
|
async def vocabulary_commit(self) -> None:
|
|
4431
4493
|
await self.call(METHOD_VOCABULARY_COMMIT)
|
|
4494
|
+
|
|
4495
|
+
async def wiring_describe(self) -> list[WiringCollection]:
|
|
4496
|
+
result = await self.call(METHOD_WIRING_DESCRIBE)
|
|
4497
|
+
return (result or {}).get("collections") or []
|