Skip to content

Stateful Apps API

Package Exports

PARE applications extending Meta ARE with stateful navigation.

AppState

Bases: ABC

Base class for navigation states.

Each state represents a screen/view of the app on the mobile phone. Navigation states form an MDP where each state has specific available actions.

Note: Different from Meta AREs data state (JSON)

Source code in pare/apps/core.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
class AppState(ABC):
    """Base class for navigation states.

    Each state represents a screen/view of the app on the mobile phone.
    Navigation states form an MDP where each state has specific available actions.

    Note: Different from Meta AREs data state (JSON)
    """

    # ! TODO: We should also add a name here.

    def __init__(self) -> None:
        """Initialize the app tools."""
        self._app: App | None = None
        self._cached_tools: list[AppTool] | None = None

    def bind_to_app(self, app: App) -> None:
        """Bind this state to an app (late binding).

        Called automatically by StatefulApp.set_current_state().

        Args:
            app: The app this state belongs to
        """
        self._app = app

    @property
    def app(self) -> App:
        """Get the app this state is bound to."""
        return self._app

    def get_available_actions(self) -> list[AppTool]:
        """Get user tools (actions) available from this navigation state.

        These are valid actions for the user in this App MDP from this state.

        Returns:
            list[AppTool]: A list of AppTool objects representing the available actions.
        """
        if self._cached_tools is None:
            tools = []
            for _, method in inspect.getmembers(self, predicate=inspect.ismethod):
                if hasattr(method, "_is_user_tool"):  # check for user tool decorator
                    # IMPORTANT: For state-bound methods, extract the unbound function
                    # and explicitly set class_instance to the state instance.
                    # `method` is a bound method, but AppTool expects an unbound function
                    # so it can pass class_instance as the first argument.
                    unbound_func = method.__func__
                    tool = build_tool(self._app, unbound_func)
                    # Override class_instance to be the state instance, not the app
                    tool.class_instance = self
                    tools.append(tool)
            self._cached_tools = tools

        return self._cached_tools

    @abstractmethod
    def on_enter(self) -> None:
        """Called when transitioning into this state.

        Override to handle state initialization, load data, update anything, etc. We don't know if this is useful yet.
        """
        raise NotImplementedError("Subclasses must implement on_enter")

    @abstractmethod
    def on_exit(self) -> None:
        """Called when transitioning out of this state.

        Override to handle state cleanup, save data, etc. We don't know if this is useful yet.
        """
        raise NotImplementedError("Subclasses must implement on_exit")

app property

Get the app this state is bound to.

__init__()

Initialize the app tools.

Source code in pare/apps/core.py
31
32
33
34
def __init__(self) -> None:
    """Initialize the app tools."""
    self._app: App | None = None
    self._cached_tools: list[AppTool] | None = None

bind_to_app(app)

Bind this state to an app (late binding).

Called automatically by StatefulApp.set_current_state().

Parameters:

Name Type Description Default
app App

The app this state belongs to

required
Source code in pare/apps/core.py
36
37
38
39
40
41
42
43
44
def bind_to_app(self, app: App) -> None:
    """Bind this state to an app (late binding).

    Called automatically by StatefulApp.set_current_state().

    Args:
        app: The app this state belongs to
    """
    self._app = app

get_available_actions()

Get user tools (actions) available from this navigation state.

These are valid actions for the user in this App MDP from this state.

Returns:

Type Description
list[AppTool]

list[AppTool]: A list of AppTool objects representing the available actions.

Source code in pare/apps/core.py
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
def get_available_actions(self) -> list[AppTool]:
    """Get user tools (actions) available from this navigation state.

    These are valid actions for the user in this App MDP from this state.

    Returns:
        list[AppTool]: A list of AppTool objects representing the available actions.
    """
    if self._cached_tools is None:
        tools = []
        for _, method in inspect.getmembers(self, predicate=inspect.ismethod):
            if hasattr(method, "_is_user_tool"):  # check for user tool decorator
                # IMPORTANT: For state-bound methods, extract the unbound function
                # and explicitly set class_instance to the state instance.
                # `method` is a bound method, but AppTool expects an unbound function
                # so it can pass class_instance as the first argument.
                unbound_func = method.__func__
                tool = build_tool(self._app, unbound_func)
                # Override class_instance to be the state instance, not the app
                tool.class_instance = self
                tools.append(tool)
        self._cached_tools = tools

    return self._cached_tools

on_enter() abstractmethod

Called when transitioning into this state.

Override to handle state initialization, load data, update anything, etc. We don't know if this is useful yet.

Source code in pare/apps/core.py
76
77
78
79
80
81
82
@abstractmethod
def on_enter(self) -> None:
    """Called when transitioning into this state.

    Override to handle state initialization, load data, update anything, etc. We don't know if this is useful yet.
    """
    raise NotImplementedError("Subclasses must implement on_enter")

on_exit() abstractmethod

Called when transitioning out of this state.

Override to handle state cleanup, save data, etc. We don't know if this is useful yet.

Source code in pare/apps/core.py
84
85
86
87
88
89
90
@abstractmethod
def on_exit(self) -> None:
    """Called when transitioning out of this state.

    Override to handle state cleanup, save data, etc. We don't know if this is useful yet.
    """
    raise NotImplementedError("Subclasses must implement on_exit")

HomeScreenSystemApp

Bases: SystemApp

System app that exposes user tools for switching contexts.

Source code in pare/apps/system.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
class HomeScreenSystemApp(SystemApp):
    """System app that exposes user tools for switching contexts."""

    def __init__(self, *args: object, **kwargs: object) -> None:
        """Initialise the system app (callbacks will be set by environment)."""
        super().__init__(*args, **kwargs)
        self._switch_app: Callable[[str], str] | None = None
        self._open_app: Callable[[str], str] | None = None
        self._go_home: Callable[[], str] | None = None

    def set_callbacks(
        self,
        switch_app_callback: Callable[[str], str],
        open_app_callback: Callable[[str], str],
        go_home_callback: Callable[[], str],
    ) -> None:
        """Set the navigation callbacks (called by environment after initialization)."""
        self._switch_app = switch_app_callback
        self._open_app = open_app_callback
        self._go_home = go_home_callback

    @user_tool()
    @pare_event_registered()
    def go_home(self) -> str:
        """Return to the home screen. This will allow the user to open a new app.

        Returns:
            str: A message indicating the home screen action.
        """
        if self._go_home is None:
            raise RuntimeError("Callbacks not set - environment must call set_callbacks() first")
        return self._go_home()

    @user_tool()
    @pare_event_registered()
    def open_app(self, app_name: str) -> str:
        """Open the requested app. If the app is already open and it is in background, then the phone will switch to it. If the app is not open, then a it is opened to the home page of that app.

        Args:
            app_name: The name of the app to open (case-sensitive). The app must be availabe in the environment.

        Returns:
            str: A message indicating the open app action.
        """
        if self._open_app is None:
            raise RuntimeError("Callbacks not set - environment must call set_callbacks() first")
        return self._open_app(app_name)

    @user_tool()
    @pare_event_registered()
    def switch_app(self, app_name: str) -> str:
        """Switch to the requested app and preserve the current app state.

        Args:
            app_name: The name of the app to switch to (case-sensitive). The app must be open and availabe in the environment.

        Returns:
            str: A message indicating the switch app action.
        """
        if self._switch_app is None:
            raise RuntimeError("Callbacks not set - environment must call set_callbacks() first")
        return self._switch_app(app_name)

__init__(*args, **kwargs)

Initialise the system app (callbacks will be set by environment).

Source code in pare/apps/system.py
18
19
20
21
22
23
def __init__(self, *args: object, **kwargs: object) -> None:
    """Initialise the system app (callbacks will be set by environment)."""
    super().__init__(*args, **kwargs)
    self._switch_app: Callable[[str], str] | None = None
    self._open_app: Callable[[str], str] | None = None
    self._go_home: Callable[[], str] | None = None

go_home()

Return to the home screen. This will allow the user to open a new app.

Returns:

Name Type Description
str str

A message indicating the home screen action.

Source code in pare/apps/system.py
36
37
38
39
40
41
42
43
44
45
46
@user_tool()
@pare_event_registered()
def go_home(self) -> str:
    """Return to the home screen. This will allow the user to open a new app.

    Returns:
        str: A message indicating the home screen action.
    """
    if self._go_home is None:
        raise RuntimeError("Callbacks not set - environment must call set_callbacks() first")
    return self._go_home()

open_app(app_name)

Open the requested app. If the app is already open and it is in background, then the phone will switch to it. If the app is not open, then a it is opened to the home page of that app.

Parameters:

Name Type Description Default
app_name str

The name of the app to open (case-sensitive). The app must be availabe in the environment.

required

Returns:

Name Type Description
str str

A message indicating the open app action.

Source code in pare/apps/system.py
48
49
50
51
52
53
54
55
56
57
58
59
60
61
@user_tool()
@pare_event_registered()
def open_app(self, app_name: str) -> str:
    """Open the requested app. If the app is already open and it is in background, then the phone will switch to it. If the app is not open, then a it is opened to the home page of that app.

    Args:
        app_name: The name of the app to open (case-sensitive). The app must be availabe in the environment.

    Returns:
        str: A message indicating the open app action.
    """
    if self._open_app is None:
        raise RuntimeError("Callbacks not set - environment must call set_callbacks() first")
    return self._open_app(app_name)

set_callbacks(switch_app_callback, open_app_callback, go_home_callback)

Set the navigation callbacks (called by environment after initialization).

Source code in pare/apps/system.py
25
26
27
28
29
30
31
32
33
34
def set_callbacks(
    self,
    switch_app_callback: Callable[[str], str],
    open_app_callback: Callable[[str], str],
    go_home_callback: Callable[[], str],
) -> None:
    """Set the navigation callbacks (called by environment after initialization)."""
    self._switch_app = switch_app_callback
    self._open_app = open_app_callback
    self._go_home = go_home_callback

switch_app(app_name)

Switch to the requested app and preserve the current app state.

Parameters:

Name Type Description Default
app_name str

The name of the app to switch to (case-sensitive). The app must be open and availabe in the environment.

required

Returns:

Name Type Description
str str

A message indicating the switch app action.

Source code in pare/apps/system.py
63
64
65
66
67
68
69
70
71
72
73
74
75
76
@user_tool()
@pare_event_registered()
def switch_app(self, app_name: str) -> str:
    """Switch to the requested app and preserve the current app state.

    Args:
        app_name: The name of the app to switch to (case-sensitive). The app must be open and availabe in the environment.

    Returns:
        str: A message indicating the switch app action.
    """
    if self._switch_app is None:
        raise RuntimeError("Callbacks not set - environment must call set_callbacks() first")
    return self._switch_app(app_name)

PAREAgentUserInterface

Bases: AgentUserInterface

Agent-user interface extended with proactive proposal acceptance and rejection support.

Adds tools which the user agent uses to accept or reject the proactive agent's proposal.

Source code in pare/apps/proactive_aui.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
class PAREAgentUserInterface(AgentUserInterface):
    """Agent-user interface extended with proactive proposal acceptance and rejection support.

    Adds tools which the user agent uses to accept or reject the proactive agent's proposal.
    """

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        """Initialize the proactive agent-user interface.

        Args:
            args: Arguments to pass to the app
            kwargs: Keyword arguments to pass to the app
        """
        super().__init__(*args, **kwargs)

    @type_check
    @user_tool()
    @event_registered(operation_type=OperationType.WRITE, event_type=EventType.USER)
    def accept_proposal(self, content: str = "") -> str:
        """User accepts the pending proactive proposal.

        Args:
            content: The content of the message to send to the agent

        Returns:
            The message ID that was generated for this message, can be used for tracking
        """
        with disable_events():
            return self.send_message_to_agent(content=content)

    @type_check
    @user_tool()
    @event_registered(operation_type=OperationType.WRITE, event_type=EventType.USER)
    def reject_proposal(self, content: str = "") -> str:
        """User rejects the pending proactive proposal.

        Args:
            content: The content of the message to send to the agent

        Returns:
            The message ID that was generated for this message, can be used for tracking
        """
        with disable_events():
            return self.send_message_to_agent(content=content)

    @type_check
    @app_tool()
    @event_registered(event_type=EventType.AGENT)
    def wait(self) -> str:
        """Observe and wait without taking action.

        Use this when you want to continue monitoring but don't have a specific
        proposal or message for the user yet.

        Returns:
            Confirmation that the agent is in observation mode.
        """
        return "Continuing to observe user activity."

__init__(*args, **kwargs)

Initialize the proactive agent-user interface.

Parameters:

Name Type Description Default
args Any

Arguments to pass to the app

()
kwargs Any

Keyword arguments to pass to the app

{}
Source code in pare/apps/proactive_aui.py
21
22
23
24
25
26
27
28
def __init__(self, *args: Any, **kwargs: Any) -> None:
    """Initialize the proactive agent-user interface.

    Args:
        args: Arguments to pass to the app
        kwargs: Keyword arguments to pass to the app
    """
    super().__init__(*args, **kwargs)

accept_proposal(content='')

User accepts the pending proactive proposal.

Parameters:

Name Type Description Default
content str

The content of the message to send to the agent

''

Returns:

Type Description
str

The message ID that was generated for this message, can be used for tracking

Source code in pare/apps/proactive_aui.py
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@type_check
@user_tool()
@event_registered(operation_type=OperationType.WRITE, event_type=EventType.USER)
def accept_proposal(self, content: str = "") -> str:
    """User accepts the pending proactive proposal.

    Args:
        content: The content of the message to send to the agent

    Returns:
        The message ID that was generated for this message, can be used for tracking
    """
    with disable_events():
        return self.send_message_to_agent(content=content)

reject_proposal(content='')

User rejects the pending proactive proposal.

Parameters:

Name Type Description Default
content str

The content of the message to send to the agent

''

Returns:

Type Description
str

The message ID that was generated for this message, can be used for tracking

Source code in pare/apps/proactive_aui.py
45
46
47
48
49
50
51
52
53
54
55
56
57
58
@type_check
@user_tool()
@event_registered(operation_type=OperationType.WRITE, event_type=EventType.USER)
def reject_proposal(self, content: str = "") -> str:
    """User rejects the pending proactive proposal.

    Args:
        content: The content of the message to send to the agent

    Returns:
        The message ID that was generated for this message, can be used for tracking
    """
    with disable_events():
        return self.send_message_to_agent(content=content)

wait()

Observe and wait without taking action.

Use this when you want to continue monitoring but don't have a specific proposal or message for the user yet.

Returns:

Type Description
str

Confirmation that the agent is in observation mode.

Source code in pare/apps/proactive_aui.py
60
61
62
63
64
65
66
67
68
69
70
71
72
@type_check
@app_tool()
@event_registered(event_type=EventType.AGENT)
def wait(self) -> str:
    """Observe and wait without taking action.

    Use this when you want to continue monitoring but don't have a specific
    proposal or message for the user yet.

    Returns:
        Confirmation that the agent is in observation mode.
    """
    return "Continuing to observe user activity."

StatefulApartmentApp

Bases: StatefulApp, ApartmentListingApp

Apartment app with navigation-aware PARE behavior.

Source code in pare/apps/apartment/app.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
class StatefulApartmentApp(StatefulApp, ApartmentListingApp):
    """Apartment app with navigation-aware PARE behavior."""

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        """Initialize apartment app and load the root navigation state."""
        super().__init__(*args, **kwargs)
        self.load_root_state()

    def create_root_state(self) -> ApartmentHome:
        """Return the root navigation state for the apartment app."""
        return ApartmentHome()

    def handle_state_transition(self, event: CompletedEvent) -> None:
        """Update navigation state after an apartment operation completes."""
        current_state = self.current_state
        fname = event.function_name()

        if current_state is None or fname is None:  # defensive, Email-style
            return

        action = event.action
        args: dict[str, Any] = action.args if action and hasattr(action, "args") else {}

        if isinstance(current_state, ApartmentHome):
            self._handle_home_transition(fname, args)
            return

        if isinstance(current_state, ApartmentSearch):
            self._handle_search_transition(fname, args)
            return

        if isinstance(current_state, ApartmentFavorites):
            self._handle_saved_transition(fname, args)

    def _handle_home_transition(self, fname: str, args: dict[str, Any]) -> None:
        if fname == "view_apartment":
            apt_id = args.get("apartment_id")
            if apt_id:
                self.set_current_state(ApartmentDetail(apartment_id=apt_id))
            return

        if fname == "open_search":
            self.set_current_state(ApartmentSearch())
            return

        if fname == "open_favorites":
            self.set_current_state(ApartmentFavorites())
            return

    def _handle_search_transition(self, fname: str, args: dict[str, Any]) -> None:
        if fname == "view_apartment":
            apt_id = args.get("apartment_id")
            if apt_id:
                self.set_current_state(ApartmentDetail(apartment_id=apt_id))
            return

    def _handle_saved_transition(self, fname: str, args: dict[str, Any]) -> None:
        if fname == "view_apartment":
            apt_id = args.get("apartment_id")
            if apt_id:
                self.set_current_state(ApartmentDetail(apartment_id=apt_id))
            return

__init__(*args, **kwargs)

Initialize apartment app and load the root navigation state.

Source code in pare/apps/apartment/app.py
24
25
26
27
def __init__(self, *args: Any, **kwargs: Any) -> None:
    """Initialize apartment app and load the root navigation state."""
    super().__init__(*args, **kwargs)
    self.load_root_state()

create_root_state()

Return the root navigation state for the apartment app.

Source code in pare/apps/apartment/app.py
29
30
31
def create_root_state(self) -> ApartmentHome:
    """Return the root navigation state for the apartment app."""
    return ApartmentHome()

handle_state_transition(event)

Update navigation state after an apartment operation completes.

Source code in pare/apps/apartment/app.py
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
def handle_state_transition(self, event: CompletedEvent) -> None:
    """Update navigation state after an apartment operation completes."""
    current_state = self.current_state
    fname = event.function_name()

    if current_state is None or fname is None:  # defensive, Email-style
        return

    action = event.action
    args: dict[str, Any] = action.args if action and hasattr(action, "args") else {}

    if isinstance(current_state, ApartmentHome):
        self._handle_home_transition(fname, args)
        return

    if isinstance(current_state, ApartmentSearch):
        self._handle_search_transition(fname, args)
        return

    if isinstance(current_state, ApartmentFavorites):
        self._handle_saved_transition(fname, args)

StatefulApp

Bases: App

Base class for a stateful app.

This class implements the basic functionality needed for a finite state machine based mobile app.

Source code in pare/apps/core.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
class StatefulApp(App):
    """Base class for a stateful app.

    This class implements the basic functionality needed for a finite state machine based mobile app.
    """

    name: str | None
    description: str | None = None

    def __init__(self, name: str | None = None, *args: Any, **kwargs: Any) -> None:
        """Initialize the stateful app.

        Args:
            name: The name of the app.
            args: The arguments to pass to the app.
            kwargs: The keyword arguments to pass to the app.
        """
        desired_name = name
        super().__init__(name, *args, **kwargs)
        # Workaround for Meta-ARE dataclass apps with __post_init__ that call super().__init__(self.name)
        # where self.name is None (dataclass default), causing App.__init__ to use class name as fallback.
        # We restore the intended name after parent initialization completes.
        actual_name = cast("str | None", getattr(self, "name", None))
        if desired_name is not None and actual_name != desired_name:
            self.name = desired_name
        self.current_state: AppState | None = None
        # Navigation stack is used to track the history of the state transitions.
        # The first state is always the initial state of the app.
        self.navigation_stack: list[AppState] = []

    def set_current_state(self, app_state: AppState) -> None:
        """Set the current state of the app.

        This is called by `handle_state_transition` to update the current state of the app. This function will
        1. Binds the state to app
        2. Calls on_exit on the old state
        3. Pushes the old state to the navigation stack (for go_back())
        4. Calls on_enter on the new state (initialization/data loading)
        5. Sets the current state to the new state

        Args:
            app_state: The state to set.
        """
        if app_state.app is None:
            app_state.bind_to_app(self)  # Late binding: app injects itself into state

        if self.current_state is not None:
            self.current_state.on_exit()
            self.navigation_stack.append(self.current_state)

        app_state.on_enter()
        self.current_state = app_state

    @abstractmethod
    def create_root_state(self) -> AppState:
        """Return a freshly constructed root navigation state."""

    def load_root_state(self) -> None:
        """Reset the app to its root navigation state."""
        self.set_current_state(self.create_root_state())
        self.navigation_stack.clear()

    def reset_to_root(self) -> str:
        """Reset to the root navigation state and report the new view."""
        self.load_root_state()
        state_name = type(self.current_state).__name__ if self.current_state else "UnknownState"
        return f"Reset to {state_name}"

    @user_tool()
    @pare_event_registered()
    def go_back(self) -> str:
        """Navigate back to the previous state of the app.

        Returns:
            str: A message indicating the navigation back action.
        """
        if not self.navigation_stack:
            return "Already at the initial state"

        self.current_state = self.navigation_stack.pop()
        return f"Navigated back to the state {self.current_state.__class__.__name__}"

    def get_user_tools(self) -> list[AppTool]:
        """Get user tools from the current state of the app.

        User tools are state dependent and manage context. Each state will only enable
        some of the available actions in the app.

        Returns:
            list[AppTool]: A list of AppTool objects representing the available user tools.
        """
        tools = []
        if self.current_state is not None:
            tools.extend(self.current_state.get_available_actions())
        # Add go_back tool if navigation stack is not empty
        if self.navigation_stack:
            tools.append(build_tool(self, self.go_back))
        return tools

    # ! NOTE: Why do we need to get meta are user tools?
    def get_meta_are_user_tools(self) -> list[Tool]:
        """Return Meta ARE-compatible tool adapters for the current navigation state."""
        from are.simulation.tool_utils import AppToolAdapter  # Use native Meta ARE adapter

        adapters: list[Tool] = []
        if self.current_state is not None:
            for app_tool in self.current_state.get_available_actions():
                adapters.append(AppToolAdapter(app_tool))

        if self.navigation_stack:
            adapters.append(AppToolAdapter(build_tool(self, self.go_back)))
        return adapters

    def get_tools(self) -> list[AppTool]:
        """Get the tools of the app."""
        return super().get_tools()

    def handle_state_transition(self, event: CompletedEvent) -> None:
        """Update the current state of the app based on the tool events.

        This implements the state transition function T(s,a) -> s' for app specific transitions.

        Args:
            event: The completed event.
        """
        raise NotImplementedError("Subclasses must implement handle_state_transition")

    def get_state_graph(self) -> dict[str, list[str]]:
        """Get the state graph of the app.

        TODO: implement after MVP

        Returns:
            dict[str, list[str]]: The state graph of the app.
        """
        raise NotImplementedError("Subclasses must implement get_state_graph")

    def get_reachable_states(self, from_state: AppState) -> list[type[AppState]]:
        """Get the reachable states from the given state.

        TODO: implement after MVP

        Args:
            from_state: The state to get the reachable states from.

        Returns:
            list[type[AppState]]: The reachable states from the given state.
        """
        raise NotImplementedError("Subclasses must implement get_reachable_states")

    # NOTE: Extend Meta-ARE tool discovery to support PARE state tools and event-only tools
    def get_tools_with_attribute(
        self, attribute: ToolAttributeName | None, tool_type: ToolType | None
    ) -> list[AppTool]:
        """Return tools by attribute/tool type, extended for PARE stateful apps.

        - If tool_type/attribute correspond to USER tools, include state-bound user tools.
        - Otherwise, defer to Meta-ARE base implementation.
        - Special case: if both tool_type and attribute are None, return "event-only" tools:
          methods decorated with @event_registered-like decorator but without any of
          @app_tool, @user_tool, @env_tool, @data_tool. Detected via AST across the MRO.
        """
        # Special case: event-only tools discovery via AST (no tool decorator present)
        if tool_type is None and attribute is None:
            return self._get_event_only_tools_via_ast()

        # Include state-bound user tools for USER queries
        if tool_type == ToolType.USER and attribute == ToolAttributeName.USER:
            if self.current_state is None:
                return []
            # AppState already builds AppTool objects with class_instance bound to state
            return list(self.current_state.get_available_actions())

        # Fallback to base Meta-ARE behavior for APP/ENV/DATA (and any other cases)
        return super().get_tools_with_attribute(attribute=attribute, tool_type=tool_type)

    # Internal helpers
    def _get_event_only_tools_via_ast(self) -> list[AppTool]:  # noqa: C901
        """Discover event-registered methods without tool decorators across the class MRO."""
        discovered_tools: list[AppTool] = []
        processed_function_names: set[str] = set()

        # Names of decorators to include/exclude (base name, without module prefixes)
        include_event_names = {"event_registered", "pare_event_registered"}
        exclude_tool_names = {"app_tool", "user_tool", "env_tool", "data_tool"}

        def _decorator_base_name(dec: ast.expr) -> str | None:
            # Extract the base name of a decorator (handles Name, Attribute, Call)
            node = dec
            if isinstance(node, ast.Call):
                node = node.func
            if isinstance(node, ast.Name):
                return node.id
            if isinstance(node, ast.Attribute):
                # Get last attribute part (e.g., tool_utils.user_tool -> user_tool)
                return node.attr
            return None

        # Traverse MRO to include inherited methods (ARE base apps)
        for cls in inspect.getmro(self.__class__):
            # Stop once we hit the framework base App class
            if cls is App or cls is ABC or cls is object:
                continue

            try:
                source_file = inspect.getsourcefile(cls) or inspect.getfile(cls)
                if not source_file:
                    continue
                source_text = inspect.getsource(cls)
            except Exception:  # noqa: S112
                # Skip classes without retrievable source (e.g., C extensions)
                continue

            try:
                # Parse the full module, then isolate the class body where possible
                module_source = None
                try:
                    with open(source_file, encoding="utf-8") as f:
                        module_source = f.read()
                except Exception:
                    module_source = source_text

                tree = ast.parse(module_source or source_text)
            except SyntaxError:
                continue

            # Find the class definition node matching this cls
            class_nodes = [n for n in ast.walk(tree) if isinstance(n, ast.ClassDef) and n.name == cls.__name__]
            if not class_nodes:
                continue

            for class_node in class_nodes:
                for node in class_node.body:
                    if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                        func_name = node.name
                        if func_name in processed_function_names:
                            continue

                        decorator_names = {name for d in node.decorator_list if (name := _decorator_base_name(d))}
                        # Must include event decorator, and must NOT include any tool decorator
                        has_event = any(d in include_event_names for d in decorator_names)
                        has_tool = any(d in exclude_tool_names for d in decorator_names)
                        if not has_event or has_tool:
                            continue

                        # Retrieve the bound method from the instance
                        func_obj = getattr(self, func_name, None)
                        if func_obj is None or not callable(func_obj):
                            continue

                        # Build the AppTool (skip if missing docstring or invalid)
                        try:
                            tool = build_tool(self, func_obj)
                        except Exception:  # noqa: S112
                            # Skip functions that cannot be converted (e.g., missing docstrings)
                            continue

                        discovered_tools.append(tool)
                        processed_function_names.add(func_name)

        return discovered_tools

__init__(name=None, *args, **kwargs)

Initialize the stateful app.

Parameters:

Name Type Description Default
name str | None

The name of the app.

None
args Any

The arguments to pass to the app.

()
kwargs Any

The keyword arguments to pass to the app.

{}
Source code in pare/apps/core.py
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
def __init__(self, name: str | None = None, *args: Any, **kwargs: Any) -> None:
    """Initialize the stateful app.

    Args:
        name: The name of the app.
        args: The arguments to pass to the app.
        kwargs: The keyword arguments to pass to the app.
    """
    desired_name = name
    super().__init__(name, *args, **kwargs)
    # Workaround for Meta-ARE dataclass apps with __post_init__ that call super().__init__(self.name)
    # where self.name is None (dataclass default), causing App.__init__ to use class name as fallback.
    # We restore the intended name after parent initialization completes.
    actual_name = cast("str | None", getattr(self, "name", None))
    if desired_name is not None and actual_name != desired_name:
        self.name = desired_name
    self.current_state: AppState | None = None
    # Navigation stack is used to track the history of the state transitions.
    # The first state is always the initial state of the app.
    self.navigation_stack: list[AppState] = []

create_root_state() abstractmethod

Return a freshly constructed root navigation state.

Source code in pare/apps/core.py
146
147
148
@abstractmethod
def create_root_state(self) -> AppState:
    """Return a freshly constructed root navigation state."""

get_meta_are_user_tools()

Return Meta ARE-compatible tool adapters for the current navigation state.

Source code in pare/apps/core.py
193
194
195
196
197
198
199
200
201
202
203
204
def get_meta_are_user_tools(self) -> list[Tool]:
    """Return Meta ARE-compatible tool adapters for the current navigation state."""
    from are.simulation.tool_utils import AppToolAdapter  # Use native Meta ARE adapter

    adapters: list[Tool] = []
    if self.current_state is not None:
        for app_tool in self.current_state.get_available_actions():
            adapters.append(AppToolAdapter(app_tool))

    if self.navigation_stack:
        adapters.append(AppToolAdapter(build_tool(self, self.go_back)))
    return adapters

get_reachable_states(from_state)

Get the reachable states from the given state.

TODO: implement after MVP

Parameters:

Name Type Description Default
from_state AppState

The state to get the reachable states from.

required

Returns:

Type Description
list[type[AppState]]

list[type[AppState]]: The reachable states from the given state.

Source code in pare/apps/core.py
230
231
232
233
234
235
236
237
238
239
240
241
def get_reachable_states(self, from_state: AppState) -> list[type[AppState]]:
    """Get the reachable states from the given state.

    TODO: implement after MVP

    Args:
        from_state: The state to get the reachable states from.

    Returns:
        list[type[AppState]]: The reachable states from the given state.
    """
    raise NotImplementedError("Subclasses must implement get_reachable_states")

get_state_graph()

Get the state graph of the app.

TODO: implement after MVP

Returns:

Type Description
dict[str, list[str]]

dict[str, list[str]]: The state graph of the app.

Source code in pare/apps/core.py
220
221
222
223
224
225
226
227
228
def get_state_graph(self) -> dict[str, list[str]]:
    """Get the state graph of the app.

    TODO: implement after MVP

    Returns:
        dict[str, list[str]]: The state graph of the app.
    """
    raise NotImplementedError("Subclasses must implement get_state_graph")

get_tools()

Get the tools of the app.

Source code in pare/apps/core.py
206
207
208
def get_tools(self) -> list[AppTool]:
    """Get the tools of the app."""
    return super().get_tools()

get_tools_with_attribute(attribute, tool_type)

Return tools by attribute/tool type, extended for PARE stateful apps.

  • If tool_type/attribute correspond to USER tools, include state-bound user tools.
  • Otherwise, defer to Meta-ARE base implementation.
  • Special case: if both tool_type and attribute are None, return "event-only" tools: methods decorated with @event_registered-like decorator but without any of @app_tool, @user_tool, @env_tool, @data_tool. Detected via AST across the MRO.
Source code in pare/apps/core.py
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
def get_tools_with_attribute(
    self, attribute: ToolAttributeName | None, tool_type: ToolType | None
) -> list[AppTool]:
    """Return tools by attribute/tool type, extended for PARE stateful apps.

    - If tool_type/attribute correspond to USER tools, include state-bound user tools.
    - Otherwise, defer to Meta-ARE base implementation.
    - Special case: if both tool_type and attribute are None, return "event-only" tools:
      methods decorated with @event_registered-like decorator but without any of
      @app_tool, @user_tool, @env_tool, @data_tool. Detected via AST across the MRO.
    """
    # Special case: event-only tools discovery via AST (no tool decorator present)
    if tool_type is None and attribute is None:
        return self._get_event_only_tools_via_ast()

    # Include state-bound user tools for USER queries
    if tool_type == ToolType.USER and attribute == ToolAttributeName.USER:
        if self.current_state is None:
            return []
        # AppState already builds AppTool objects with class_instance bound to state
        return list(self.current_state.get_available_actions())

    # Fallback to base Meta-ARE behavior for APP/ENV/DATA (and any other cases)
    return super().get_tools_with_attribute(attribute=attribute, tool_type=tool_type)

get_user_tools()

Get user tools from the current state of the app.

User tools are state dependent and manage context. Each state will only enable some of the available actions in the app.

Returns:

Type Description
list[AppTool]

list[AppTool]: A list of AppTool objects representing the available user tools.

Source code in pare/apps/core.py
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
def get_user_tools(self) -> list[AppTool]:
    """Get user tools from the current state of the app.

    User tools are state dependent and manage context. Each state will only enable
    some of the available actions in the app.

    Returns:
        list[AppTool]: A list of AppTool objects representing the available user tools.
    """
    tools = []
    if self.current_state is not None:
        tools.extend(self.current_state.get_available_actions())
    # Add go_back tool if navigation stack is not empty
    if self.navigation_stack:
        tools.append(build_tool(self, self.go_back))
    return tools

go_back()

Navigate back to the previous state of the app.

Returns:

Name Type Description
str str

A message indicating the navigation back action.

Source code in pare/apps/core.py
161
162
163
164
165
166
167
168
169
170
171
172
173
@user_tool()
@pare_event_registered()
def go_back(self) -> str:
    """Navigate back to the previous state of the app.

    Returns:
        str: A message indicating the navigation back action.
    """
    if not self.navigation_stack:
        return "Already at the initial state"

    self.current_state = self.navigation_stack.pop()
    return f"Navigated back to the state {self.current_state.__class__.__name__}"

handle_state_transition(event)

Update the current state of the app based on the tool events.

This implements the state transition function T(s,a) -> s' for app specific transitions.

Parameters:

Name Type Description Default
event CompletedEvent

The completed event.

required
Source code in pare/apps/core.py
210
211
212
213
214
215
216
217
218
def handle_state_transition(self, event: CompletedEvent) -> None:
    """Update the current state of the app based on the tool events.

    This implements the state transition function T(s,a) -> s' for app specific transitions.

    Args:
        event: The completed event.
    """
    raise NotImplementedError("Subclasses must implement handle_state_transition")

load_root_state()

Reset the app to its root navigation state.

Source code in pare/apps/core.py
150
151
152
153
def load_root_state(self) -> None:
    """Reset the app to its root navigation state."""
    self.set_current_state(self.create_root_state())
    self.navigation_stack.clear()

reset_to_root()

Reset to the root navigation state and report the new view.

Source code in pare/apps/core.py
155
156
157
158
159
def reset_to_root(self) -> str:
    """Reset to the root navigation state and report the new view."""
    self.load_root_state()
    state_name = type(self.current_state).__name__ if self.current_state else "UnknownState"
    return f"Reset to {state_name}"

set_current_state(app_state)

Set the current state of the app.

This is called by handle_state_transition to update the current state of the app. This function will 1. Binds the state to app 2. Calls on_exit on the old state 3. Pushes the old state to the navigation stack (for go_back()) 4. Calls on_enter on the new state (initialization/data loading) 5. Sets the current state to the new state

Parameters:

Name Type Description Default
app_state AppState

The state to set.

required
Source code in pare/apps/core.py
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
def set_current_state(self, app_state: AppState) -> None:
    """Set the current state of the app.

    This is called by `handle_state_transition` to update the current state of the app. This function will
    1. Binds the state to app
    2. Calls on_exit on the old state
    3. Pushes the old state to the navigation stack (for go_back())
    4. Calls on_enter on the new state (initialization/data loading)
    5. Sets the current state to the new state

    Args:
        app_state: The state to set.
    """
    if app_state.app is None:
        app_state.bind_to_app(self)  # Late binding: app injects itself into state

    if self.current_state is not None:
        self.current_state.on_exit()
        self.navigation_stack.append(self.current_state)

    app_state.on_enter()
    self.current_state = app_state

StatefulCabApp

Bases: StatefulApp, CabApp

Cab client with navigation-aware user tool exposure.

Source code in pare/apps/cab/app.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
class StatefulCabApp(StatefulApp, CabApp):
    """Cab client with navigation-aware user tool exposure."""

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        """Initialise the cab app and load the default home screen."""
        super().__init__(*args, **kwargs)
        self.load_root_state()

    def create_root_state(self) -> CabHome:
        """Return the root navigation state for the cab app."""
        return CabHome()

    def handle_state_transition(self, event: CompletedEvent) -> None:
        """Update navigation state after a cab operation completes."""
        fname = event.function_name()
        if fname is None:
            return

        event_args: dict[str, Any] = {}
        action = getattr(event, "action", None)
        if action is not None and hasattr(action, "args"):
            event_args = cast("dict[str, Any]", getattr(action, "args", {}))

        match fname:
            case "list_rides":
                self._handle_list_rides(event_args)
            case "get_quotation":
                self._handle_get_quotation(event)
            case "order_ride":
                self._handle_order_ride(event)
            case "open_current_ride":
                self._handle_open_current_ride(event)
            case "cancel_ride":
                self._handle_finish()

    def _handle_open_current_ride(self, event: CompletedEvent) -> None:
        """Navigate to the ride detail screen for the current ride."""
        ride_obj = event.metadata.return_value if event.metadata else None
        if ride_obj is not None:
            self.set_current_state(CabRideDetail(ride_obj))

    def _handle_list_rides(self, event_args: dict[str, Any]) -> None:
        """Navigate to service options after listing rides."""
        start = event_args.get("start_location")
        end = event_args.get("end_location")
        ride_time = event_args.get("ride_time")

        if start and end:
            self.set_current_state(CabServiceOptions(start, end, ride_time))

    def _handle_get_quotation(self, event: CompletedEvent) -> None:
        """Navigate to quotation detail after getting a quotation."""
        ride_obj = event.metadata.return_value if event.metadata else None
        if ride_obj:
            self.set_current_state(CabQuotationDetail(ride_obj))

    def _handle_order_ride(self, event: CompletedEvent) -> None:
        """Navigate to ride detail after ordering a ride."""
        ride_obj = event.metadata.return_value if event.metadata else None
        if ride_obj is not None:
            self.set_current_state(CabRideDetail(ride_obj))

    def _handle_finish(self) -> None:
        """Return to home screen after canceling or ending a ride."""
        self.load_root_state()

__init__(*args, **kwargs)

Initialise the cab app and load the default home screen.

Source code in pare/apps/cab/app.py
24
25
26
27
def __init__(self, *args: Any, **kwargs: Any) -> None:
    """Initialise the cab app and load the default home screen."""
    super().__init__(*args, **kwargs)
    self.load_root_state()

create_root_state()

Return the root navigation state for the cab app.

Source code in pare/apps/cab/app.py
29
30
31
def create_root_state(self) -> CabHome:
    """Return the root navigation state for the cab app."""
    return CabHome()

handle_state_transition(event)

Update navigation state after a cab operation completes.

Source code in pare/apps/cab/app.py
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
def handle_state_transition(self, event: CompletedEvent) -> None:
    """Update navigation state after a cab operation completes."""
    fname = event.function_name()
    if fname is None:
        return

    event_args: dict[str, Any] = {}
    action = getattr(event, "action", None)
    if action is not None and hasattr(action, "args"):
        event_args = cast("dict[str, Any]", getattr(action, "args", {}))

    match fname:
        case "list_rides":
            self._handle_list_rides(event_args)
        case "get_quotation":
            self._handle_get_quotation(event)
        case "order_ride":
            self._handle_order_ride(event)
        case "open_current_ride":
            self._handle_open_current_ride(event)
        case "cancel_ride":
            self._handle_finish()

StatefulCalendarApp

Bases: StatefulApp, CalendarV2

Calendar client with navigation-aware user tool exposure.

Source code in pare/apps/calendar/app.py
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
class StatefulCalendarApp(StatefulApp, CalendarV2):
    """Calendar client with navigation-aware user tool exposure."""

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        """Initialise the calendar app and seed the agenda with the current day."""
        super().__init__(*args, **kwargs)
        self.load_root_state()

    def _default_day_range(self) -> tuple[str, str]:
        """Derive the UTC day range surrounding the current simulated time."""
        now = datetime.fromtimestamp(self.time_manager.time(), tz=UTC)
        start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0)
        end_of_day = start_of_day + timedelta(days=1)
        return (start_of_day.strftime(DATETIME_FORMAT), end_of_day.strftime(DATETIME_FORMAT))

    def _resolve_event_id(self, args: dict[str, Any], metadata: object | None) -> str | None:
        event_id = args.get("event_id")
        if isinstance(event_id, str):
            return event_id
        if isinstance(metadata, CalendarEvent):
            return metadata.event_id
        if isinstance(metadata, dict):
            candidate = metadata.get("event")
            if isinstance(candidate, CalendarEvent):
                return candidate.event_id
        return None

    @staticmethod
    def _draft_from_metadata(metadata_value: object | None) -> EditDraft | None:
        if not isinstance(metadata_value, dict):
            return None
        draft_data = metadata_value.get("draft")
        if isinstance(draft_data, EditDraft):
            return draft_data
        if isinstance(draft_data, dict):
            return EditDraft(
                event_id=draft_data.get("event_id"),
                title=draft_data.get("title", "Event"),
                start_datetime=draft_data.get("start_datetime"),
                end_datetime=draft_data.get("end_datetime"),
                tag=draft_data.get("tag"),
                description=draft_data.get("description"),
                location=draft_data.get("location"),
                attendees=list(draft_data.get("attendees", [])),
            )
        return None

    # pylint: disable=too-many-branches
    def handle_state_transition(self, event: CompletedEvent) -> None:
        """Update navigation state in response to user tool completions."""
        current_state = self.current_state
        function_name = event.function_name()

        if current_state is None or function_name is None:  # pragma: no cover - defensive
            return

        action = event.action
        args = action.resolved_args or action.args
        metadata_value = event.metadata.return_value if event.metadata else None

        if isinstance(current_state, AgendaView):
            self._handle_agenda_transition(current_state, function_name, args, metadata_value)
            return

        if isinstance(current_state, EventDetail):
            self._handle_event_detail_transition(function_name, metadata_value)
            return

        if isinstance(current_state, EditEvent):
            self._handle_edit_event_transition(function_name, metadata_value)

    def _handle_agenda_transition(
        self, current_state: AgendaView, function_name: str, args: dict[str, Any], metadata_value: object | None
    ) -> None:
        """Process agenda-specific transitions."""
        if function_name in {"open_event_by_id", "open_event_by_index"}:
            event_id = self._resolve_event_id(args, metadata_value)
            if event_id:
                self.set_current_state(EventDetail(event_id=event_id))
            return

        if function_name == "start_create_event":
            self.set_current_state(EditEvent(draft=EditDraft()))
            return

        if function_name == "filter_by_tag":
            tag = args.get("tag")
            self.set_current_state(
                AgendaView(
                    start_datetime=current_state.start_datetime,
                    end_datetime=current_state.end_datetime,
                    tag_filter=tag,
                    attendee_filter=current_state.attendee_filter,
                )
            )
            return

        if function_name == "filter_by_attendee":
            attendee = args.get("attendee") or args.get("name")
            self.set_current_state(
                AgendaView(
                    start_datetime=current_state.start_datetime,
                    end_datetime=current_state.end_datetime,
                    tag_filter=current_state.tag_filter,
                    attendee_filter=attendee,
                )
            )
            return

        if function_name == "set_day" and isinstance(metadata_value, dict):
            start = metadata_value.get("start_datetime")
            end = metadata_value.get("end_datetime")
            if isinstance(start, str) and isinstance(end, str):
                self.set_current_state(
                    AgendaView(
                        start_datetime=start,
                        end_datetime=end,
                        tag_filter=current_state.tag_filter,
                        attendee_filter=current_state.attendee_filter,
                    )
                )

    def _handle_event_detail_transition(self, function_name: str, metadata_value: object | None) -> None:
        """Process transitions that originate from the event detail view."""
        if function_name == "edit_event":
            draft = self._draft_from_metadata(metadata_value)
            self.set_current_state(EditEvent(draft=draft))
            return

        if function_name in {"delete", "delete_by_attendee"} and self.navigation_stack:
            self.go_back()

    def _handle_edit_event_transition(self, function_name: str, metadata_value: object | None) -> None:
        """Process transitions that originate from the event edit view."""
        if function_name == "save":
            event_id = metadata_value if isinstance(metadata_value, str) else None
            previous = self.navigation_stack[-1] if self.navigation_stack else None
            if isinstance(previous, EventDetail):
                if event_id:
                    previous.event_id = event_id
                previous.refresh()
            if self.navigation_stack:
                self.go_back()
            else:  # pragma: no cover - defensive fallback
                self._reset_to_default_agenda()
            return

        if function_name == "discard":
            if self.navigation_stack:
                self.go_back()
            else:  # pragma: no cover - defensive fallback
                self._reset_to_default_agenda()

    def _reset_to_default_agenda(self) -> None:
        """Return the app to the default day agenda view."""
        self.load_root_state()

    def create_root_state(self) -> AgendaView:
        """Return the root agenda view scoped to the current day."""
        start, end = self._default_day_range()
        return AgendaView(start_datetime=start, end_datetime=end)

    def get_state_graph(self) -> dict[str, list[str]]:
        """Return the navigation graph for the calendar app."""
        raise NotImplementedError

    def get_reachable_states(self, from_state: AppState) -> list[type[AppState]]:  # pragma: no cover - placeholder
        """Return the reachable states from the provided state."""
        raise NotImplementedError

__init__(*args, **kwargs)

Initialise the calendar app and seed the agenda with the current day.

Source code in pare/apps/calendar/app.py
23
24
25
26
def __init__(self, *args: Any, **kwargs: Any) -> None:
    """Initialise the calendar app and seed the agenda with the current day."""
    super().__init__(*args, **kwargs)
    self.load_root_state()

create_root_state()

Return the root agenda view scoped to the current day.

Source code in pare/apps/calendar/app.py
177
178
179
180
def create_root_state(self) -> AgendaView:
    """Return the root agenda view scoped to the current day."""
    start, end = self._default_day_range()
    return AgendaView(start_datetime=start, end_datetime=end)

get_reachable_states(from_state)

Return the reachable states from the provided state.

Source code in pare/apps/calendar/app.py
186
187
188
def get_reachable_states(self, from_state: AppState) -> list[type[AppState]]:  # pragma: no cover - placeholder
    """Return the reachable states from the provided state."""
    raise NotImplementedError

get_state_graph()

Return the navigation graph for the calendar app.

Source code in pare/apps/calendar/app.py
182
183
184
def get_state_graph(self) -> dict[str, list[str]]:
    """Return the navigation graph for the calendar app."""
    raise NotImplementedError

handle_state_transition(event)

Update navigation state in response to user tool completions.

Source code in pare/apps/calendar/app.py
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
def handle_state_transition(self, event: CompletedEvent) -> None:
    """Update navigation state in response to user tool completions."""
    current_state = self.current_state
    function_name = event.function_name()

    if current_state is None or function_name is None:  # pragma: no cover - defensive
        return

    action = event.action
    args = action.resolved_args or action.args
    metadata_value = event.metadata.return_value if event.metadata else None

    if isinstance(current_state, AgendaView):
        self._handle_agenda_transition(current_state, function_name, args, metadata_value)
        return

    if isinstance(current_state, EventDetail):
        self._handle_event_detail_transition(function_name, metadata_value)
        return

    if isinstance(current_state, EditEvent):
        self._handle_edit_event_transition(function_name, metadata_value)

StatefulContactsApp

Bases: StatefulApp, ContactsApp

Contacts application with explicit navigation states.

Source code in pare/apps/contacts/app.py
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
class StatefulContactsApp(StatefulApp, ContactsApp):
    """Contacts application with explicit navigation states."""

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        """Initialise the contacts app and load the list view as the default state."""
        self._pending_transition: tuple[str, str] | None = None
        super().__init__(*args, **kwargs)
        with disable_events():
            self.add_contact(USER_CONTACT)
        self.load_root_state()

    def queue_contact_transition(self, intent: str, contact_id: str) -> None:
        """Record a desired transition that should fire after the next contacts API call."""
        self._pending_transition = (intent, contact_id)

    def clear_contact_transition(self) -> None:
        """Reset any queued contact transition intent."""
        self._pending_transition = None

    def handle_state_transition(self, event: CompletedEvent) -> None:
        """Update navigation state based on completed contact operations."""
        function_name = event.function_name()
        if function_name is None:
            return

        # Extract args safely - event.action may be ConditionCheckAction in other contexts.
        event_args: dict[str, Any] = {}
        action = getattr(event, "action", None)
        if action is not None and hasattr(action, "args"):
            event_args = cast("dict[str, Any]", getattr(action, "args", {}))

        match function_name:
            case "get_contact":
                self._handle_get_contact(event_args)
            case "edit_contact":
                self._handle_edit_contact(event_args)
            case "delete_contact":
                self._handle_delete_contact()
            case "get_contacts":
                self._handle_get_contacts()

    def _handle_get_contact(self, event_args: dict[str, Any]) -> None:
        contact_id = event_args.get("contact_id")
        if contact_id is None:
            return

        if self._pending_transition is not None:
            intent, intent_contact_id = self._pending_transition
            self.clear_contact_transition()

            # Only use queued intent if it matches the event's contact context.
            if intent_contact_id != contact_id:
                intent_contact_id = contact_id

            if intent == "detail" and (
                not isinstance(self.current_state, ContactDetail) or self.current_state.contact_id != intent_contact_id
            ):
                self.set_current_state(ContactDetail(intent_contact_id))
            elif intent == "edit" and (
                not isinstance(self.current_state, ContactEdit) or self.current_state.contact_id != intent_contact_id
            ):
                self.set_current_state(ContactEdit(intent_contact_id))
            return

        # Fallback: if we are still on the list and a contact is accessed directly, open the detail view.
        if isinstance(self.current_state, ContactsList):
            self.set_current_state(ContactDetail(contact_id))

    def _handle_edit_contact(self, event_args: dict[str, Any]) -> None:
        contact_id = event_args.get("contact_id")
        if contact_id is None:
            return

        # After saving edits we should return to the detail view.
        if isinstance(self.current_state, ContactEdit) and self.navigation_stack:
            # go_back returns to the previous detail state on the stack if present.
            self.go_back()

        if isinstance(self.current_state, ContactDetail):
            self.current_state.contact_id = contact_id
            self.clear_contact_transition()
        else:
            self.set_current_state(ContactDetail(contact_id))

    def _handle_delete_contact(self) -> None:
        self.clear_contact_transition()
        # Prefer using the navigation stack to respect user history
        if self.navigation_stack:
            self.go_back()
        else:
            self.set_current_state(ContactsList())

    def _handle_get_contacts(self) -> None:
        if not isinstance(self.current_state, ContactsList):
            self.set_current_state(ContactsList())

    def create_root_state(self) -> ContactsList:
        """Return the root navigation state for the contacts app."""
        return ContactsList()

__init__(*args, **kwargs)

Initialise the contacts app and load the list view as the default state.

Source code in pare/apps/contacts/app.py
35
36
37
38
39
40
41
def __init__(self, *args: Any, **kwargs: Any) -> None:
    """Initialise the contacts app and load the list view as the default state."""
    self._pending_transition: tuple[str, str] | None = None
    super().__init__(*args, **kwargs)
    with disable_events():
        self.add_contact(USER_CONTACT)
    self.load_root_state()

clear_contact_transition()

Reset any queued contact transition intent.

Source code in pare/apps/contacts/app.py
47
48
49
def clear_contact_transition(self) -> None:
    """Reset any queued contact transition intent."""
    self._pending_transition = None

create_root_state()

Return the root navigation state for the contacts app.

Source code in pare/apps/contacts/app.py
128
129
130
def create_root_state(self) -> ContactsList:
    """Return the root navigation state for the contacts app."""
    return ContactsList()

handle_state_transition(event)

Update navigation state based on completed contact operations.

Source code in pare/apps/contacts/app.py
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
def handle_state_transition(self, event: CompletedEvent) -> None:
    """Update navigation state based on completed contact operations."""
    function_name = event.function_name()
    if function_name is None:
        return

    # Extract args safely - event.action may be ConditionCheckAction in other contexts.
    event_args: dict[str, Any] = {}
    action = getattr(event, "action", None)
    if action is not None and hasattr(action, "args"):
        event_args = cast("dict[str, Any]", getattr(action, "args", {}))

    match function_name:
        case "get_contact":
            self._handle_get_contact(event_args)
        case "edit_contact":
            self._handle_edit_contact(event_args)
        case "delete_contact":
            self._handle_delete_contact()
        case "get_contacts":
            self._handle_get_contacts()

queue_contact_transition(intent, contact_id)

Record a desired transition that should fire after the next contacts API call.

Source code in pare/apps/contacts/app.py
43
44
45
def queue_contact_transition(self, intent: str, contact_id: str) -> None:
    """Record a desired transition that should fire after the next contacts API call."""
    self._pending_transition = (intent, contact_id)

StatefulEmailApp

Bases: StatefulApp, EmailClientV2

Email client with navigation state management for user tool filtering.

Source code in pare/apps/email/app.py
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
class StatefulEmailApp(StatefulApp, EmailClientV2):
    """Email client with navigation state management for user tool filtering."""

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        """Initialise the email app with the inbox as the starting state."""
        super().__init__(*args, **kwargs)
        self.user_email = "john@pare.com"
        self.load_root_state()

    def handle_state_transition(self, event: CompletedEvent) -> None:
        """Update navigation state in response to completed tool events."""
        current_state = self.current_state
        function_name = event.function_name()

        if current_state is None or function_name is None:  # pragma: no cover - defensive
            return

        action = event.action
        args = action.resolved_args or action.args
        if isinstance(current_state, MailboxView):
            self._handle_mailbox_transition(current_state, function_name, args, event)
            return

        if isinstance(current_state, EmailDetail):
            self._handle_detail_transition(function_name, event)
            if function_name in {"delete", "move"} and self.navigation_stack:
                self.go_back()
            return

        if isinstance(current_state, ComposeEmail):
            self._handle_compose_transition(function_name)

    def _handle_mailbox_transition(
        self, current_state: MailboxView, function_name: str, args: dict[str, Any], event: CompletedEvent
    ) -> None:
        """Handle transitions triggered from the mailbox view."""
        if function_name in {"open_email_by_id", "open_email_by_index"}:
            folder = self._resolve_folder_from_args(args, current_state.folder)
            email_id = args.get("email_id")
            if email_id is None:
                email_id = self._email_id_from_event(event)
            if email_id is not None:
                self.set_current_state(EmailDetail(email_id=email_id, folder_name=folder))
            return

        if function_name == "switch_folder":
            folder = self._resolve_folder_from_args(args, current_state.folder)
            self.set_current_state(MailboxView(folder=folder))
            return

        if function_name == "start_compose":
            draft = self._compose_draft_from_event(event)
            self.set_current_state(ComposeEmail(draft=draft))

    def _handle_detail_transition(self, function_name: str, event: CompletedEvent) -> None:
        """Handle transitions triggered from the email detail view."""
        if function_name == "start_compose_reply":
            draft = self._compose_draft_from_event(event)
            self.set_current_state(ComposeEmail(draft=draft))

    def _handle_compose_transition(self, function_name: str) -> None:
        """Handle transitions triggered from the compose view."""
        if function_name in {"send_composed_email", "save_draft", "discard_draft"} and self.navigation_stack:
            self.go_back()

    @staticmethod
    def _resolve_folder_from_args(args: dict[str, Any], default_folder: str) -> str:
        folder = args.get("folder_name")
        if isinstance(folder, EmailFolderName):
            return folder.value
        if isinstance(folder, str):
            try:
                return EmailFolderName[folder.upper()].value
            except KeyError:
                return folder.upper()
        return default_folder

    @staticmethod
    def _email_id_from_event(event: CompletedEvent) -> str | None:
        metadata_value = event.metadata.return_value if event.metadata else None
        if isinstance(metadata_value, Email):
            return metadata_value.email_id
        if isinstance(metadata_value, dict):
            return metadata_value.get("email_id")
        return None

    @staticmethod
    def _compose_draft_from_event(event: CompletedEvent) -> ComposeDraft | None:
        metadata_value = event.metadata.return_value if event.metadata else None
        if not isinstance(metadata_value, dict):
            return None
        draft_data = metadata_value.get("draft")
        if isinstance(draft_data, ComposeDraft):
            return draft_data
        if isinstance(draft_data, dict):
            return ComposeDraft(
                recipients=draft_data.get("recipients", []),
                cc=draft_data.get("cc", []),
                subject=draft_data.get("subject", ""),
                body=draft_data.get("body", ""),
                attachments=draft_data.get("attachments", []),
                reply_to=draft_data.get("reply_to"),
                reply_to_folder=draft_data.get("folder_name"),
                default_recipients=list(draft_data.get("recipients", [])),
                default_subject=draft_data.get("subject", ""),
            )
        return None

    def send_reply_from_draft(self, draft: ComposeDraft) -> str:
        """Send a reply using the draft metadata, preserving user edits."""
        if not draft.reply_to:
            raise ValueError("Draft does not reference a reply target")

        attachments = draft.attachments or []
        folder_name = draft.reply_to_folder or EmailFolderName.INBOX.value
        recipients = draft.recipients or draft.default_recipients
        subject = draft.subject or draft.default_subject or ""
        cc = draft.cc

        return self._send_reply_email(
            email_id=draft.reply_to,
            folder_name=folder_name,
            content=draft.body,
            attachment_paths=attachments,
            recipients=recipients,
            subject=subject,
            cc=cc,
            fallback_recipients=draft.default_recipients,
            fallback_subject=draft.default_subject,
        )

    def _send_reply_email(
        self,
        *,
        email_id: str,
        folder_name: str,
        content: str,
        attachment_paths: list[str],
        recipients: list[str],
        subject: str,
        cc: list[str],
        fallback_recipients: list[str],
        fallback_subject: str | None,
    ) -> str:
        folder_enum = EmailFolderName[folder_name.upper()] if isinstance(folder_name, str) else folder_name
        if folder_enum not in self.folders:
            raise ValueError(f"Folder {folder_name} not found")

        replying_to_email = self.folders[folder_enum].get_email_by_id(email_id)

        def get_default_recipient(email: Email) -> str:
            while email.sender == self.user_email and email.parent_id:
                email_found = False
                for folder in self.folders:
                    try:
                        email = self.folders[folder].get_email_by_id(email.parent_id)
                        email_found = True
                        break
                    except (KeyError, ValueError):
                        continue
                if not email_found:
                    raise ValueError(f"Email with id {email.parent_id} not found")
            return email.sender

        resolved_recipients = list(recipients) if recipients else list(fallback_recipients)
        if not resolved_recipients:
            resolved_recipients = [get_default_recipient(replying_to_email)]

        resolved_subject = subject or fallback_subject or f"Re: {replying_to_email.subject}"
        resolved_cc = list(cc) if cc else []

        email = Email(
            email_id=uuid_hex(self.rng),
            sender=self.user_email,
            recipients=resolved_recipients,
            subject=resolved_subject,
            content=content,
            timestamp=self.time_manager.time(),
            cc=resolved_cc,
            parent_id=replying_to_email.email_id,
        )

        for path in attachment_paths:
            self.add_attachment(email=email, attachment_path=path)

        with disable_events():
            self.add_email(email=email, folder_name=EmailFolderName.SENT)

        return email.email_id

    @env_tool()
    @event_registered(operation_type=OperationType.WRITE, event_type=EventType.ENV)
    def send_email_to_user_with_id(
        self,
        email_id: str,
        sender: str,
        subject: str = "",
        content: str = "",
        attachment_paths: list[str] | None = None,
    ) -> str:
        """Create an incoming email with a specified ID (optionally with attachments) and add it to user's INBOX.

        This is a PARE-specific environment tool that allows scenarios to reference
        the email_id in subsequent events (e.g., for replying to the email).

        Args:
            email_id: The ID to assign to this email.
            sender: The sender of the email.
            subject: The subject of the email.
            content: The content of the email.
            attachment_paths: Optional list of attachment paths to add to the email.
                NOTE: Attachments are read from `self.internal_fs` (SandboxLocalFileSystem / VirtualFileSystem)
                and stored as base64 bytes on the email. This is safe to use in scenario event flows (post-init),
                but DO NOT add such emails during PAREScenario init state serialization.

        Returns:
            The email_id that was provided.
        """
        if attachment_paths is None:
            attachment_paths = []

        email = Email(
            email_id=email_id,
            sender=sender,
            recipients=[self.user_email],
            subject=subject,
            content=content,
            timestamp=self.time_manager.time(),
            is_read=False,
        )
        for path in attachment_paths:
            self.add_attachment(email=email, attachment_path=path)

        self.folders[EmailFolderName.INBOX].add_email(email)
        return email_id

    def create_root_state(self) -> MailboxView:
        """Return the mailbox view rooted in the inbox."""
        return MailboxView(folder=EmailFolderName.INBOX.value)

__init__(*args, **kwargs)

Initialise the email app with the inbox as the starting state.

Source code in pare/apps/email/app.py
22
23
24
25
26
def __init__(self, *args: Any, **kwargs: Any) -> None:
    """Initialise the email app with the inbox as the starting state."""
    super().__init__(*args, **kwargs)
    self.user_email = "john@pare.com"
    self.load_root_state()

create_root_state()

Return the mailbox view rooted in the inbox.

Source code in pare/apps/email/app.py
255
256
257
def create_root_state(self) -> MailboxView:
    """Return the mailbox view rooted in the inbox."""
    return MailboxView(folder=EmailFolderName.INBOX.value)

handle_state_transition(event)

Update navigation state in response to completed tool events.

Source code in pare/apps/email/app.py
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
def handle_state_transition(self, event: CompletedEvent) -> None:
    """Update navigation state in response to completed tool events."""
    current_state = self.current_state
    function_name = event.function_name()

    if current_state is None or function_name is None:  # pragma: no cover - defensive
        return

    action = event.action
    args = action.resolved_args or action.args
    if isinstance(current_state, MailboxView):
        self._handle_mailbox_transition(current_state, function_name, args, event)
        return

    if isinstance(current_state, EmailDetail):
        self._handle_detail_transition(function_name, event)
        if function_name in {"delete", "move"} and self.navigation_stack:
            self.go_back()
        return

    if isinstance(current_state, ComposeEmail):
        self._handle_compose_transition(function_name)

send_email_to_user_with_id(email_id, sender, subject='', content='', attachment_paths=None)

Create an incoming email with a specified ID (optionally with attachments) and add it to user's INBOX.

This is a PARE-specific environment tool that allows scenarios to reference the email_id in subsequent events (e.g., for replying to the email).

Parameters:

Name Type Description Default
email_id str

The ID to assign to this email.

required
sender str

The sender of the email.

required
subject str

The subject of the email.

''
content str

The content of the email.

''
attachment_paths list[str] | None

Optional list of attachment paths to add to the email. NOTE: Attachments are read from self.internal_fs (SandboxLocalFileSystem / VirtualFileSystem) and stored as base64 bytes on the email. This is safe to use in scenario event flows (post-init), but DO NOT add such emails during PAREScenario init state serialization.

None

Returns:

Type Description
str

The email_id that was provided.

Source code in pare/apps/email/app.py
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
@env_tool()
@event_registered(operation_type=OperationType.WRITE, event_type=EventType.ENV)
def send_email_to_user_with_id(
    self,
    email_id: str,
    sender: str,
    subject: str = "",
    content: str = "",
    attachment_paths: list[str] | None = None,
) -> str:
    """Create an incoming email with a specified ID (optionally with attachments) and add it to user's INBOX.

    This is a PARE-specific environment tool that allows scenarios to reference
    the email_id in subsequent events (e.g., for replying to the email).

    Args:
        email_id: The ID to assign to this email.
        sender: The sender of the email.
        subject: The subject of the email.
        content: The content of the email.
        attachment_paths: Optional list of attachment paths to add to the email.
            NOTE: Attachments are read from `self.internal_fs` (SandboxLocalFileSystem / VirtualFileSystem)
            and stored as base64 bytes on the email. This is safe to use in scenario event flows (post-init),
            but DO NOT add such emails during PAREScenario init state serialization.

    Returns:
        The email_id that was provided.
    """
    if attachment_paths is None:
        attachment_paths = []

    email = Email(
        email_id=email_id,
        sender=sender,
        recipients=[self.user_email],
        subject=subject,
        content=content,
        timestamp=self.time_manager.time(),
        is_read=False,
    )
    for path in attachment_paths:
        self.add_attachment(email=email, attachment_path=path)

    self.folders[EmailFolderName.INBOX].add_email(email)
    return email_id

send_reply_from_draft(draft)

Send a reply using the draft metadata, preserving user edits.

Source code in pare/apps/email/app.py
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
def send_reply_from_draft(self, draft: ComposeDraft) -> str:
    """Send a reply using the draft metadata, preserving user edits."""
    if not draft.reply_to:
        raise ValueError("Draft does not reference a reply target")

    attachments = draft.attachments or []
    folder_name = draft.reply_to_folder or EmailFolderName.INBOX.value
    recipients = draft.recipients or draft.default_recipients
    subject = draft.subject or draft.default_subject or ""
    cc = draft.cc

    return self._send_reply_email(
        email_id=draft.reply_to,
        folder_name=folder_name,
        content=draft.body,
        attachment_paths=attachments,
        recipients=recipients,
        subject=subject,
        cc=cc,
        fallback_recipients=draft.default_recipients,
        fallback_subject=draft.default_subject,
    )

StatefulMessagingApp

Bases: StatefulApp, MessagingAppV2

Messaging app with navigation state management.

// RL NOTE: This implements a simple 2-state MDP for messaging: // States: ConversationList, ConversationOpened // Transitions: open_conversation, go_back

Source code in pare/apps/messaging/app.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
class StatefulMessagingApp(StatefulApp, MessagingAppV2):
    """Messaging app with navigation state management.

    // RL NOTE: This implements a simple 2-state MDP for messaging:
    // States: ConversationList, ConversationOpened
    // Transitions: open_conversation, go_back
    """

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        """Initialize the stateful messaging app.

        Args:
            *args: Variable length argument list passed to parent classes.
            **kwargs: Arbitrary keyword arguments passed to parent classes.
        """
        super().__init__(*args, **kwargs)
        self.current_user_id = uuid_hex(self.rng)
        self.current_user_name = "John Doe"
        # Register current user in id/name mappings
        self.id_to_name[self.current_user_id] = self.current_user_name
        self.name_to_id[self.current_user_name] = self.current_user_id
        # Set initial state to conversation list
        self.load_root_state()

    def add_users(self, user_names: list[str]) -> None:
        """Add users to the internal name/id maps.

        Args:
            user_names: User display names to ensure exist in the mapping.
        """
        for user_name in user_names:
            if user_name not in self.name_to_id:
                user_id = uuid_hex(self.rng)
                self.name_to_id[user_name] = user_id
                self.id_to_name[user_id] = user_name

    def add_contacts(self, contacts: list[tuple[str, str]]) -> None:
        """Add contacts (name, phone) to the internal name/id maps.

        Args:
            contacts: Pairs of (user_name, phone).
        """
        for user_name, phone in contacts:
            if user_name not in self.name_to_id:
                self.name_to_id[user_name] = phone
                self.id_to_name[phone] = user_name

    def handle_state_transition(self, event: CompletedEvent) -> None:
        """Handle state transitions based on tool events.

        // RL NOTE: This implements T(s,a) -> s' for the messaging MDP.

        Args:
            event: Completed event from tool execution
        """
        current_state = self.current_state
        function_name = event.function_name()

        if current_state is None or function_name is None:
            return

        # Transition: ConversationList -> ConversationOpened
        if isinstance(current_state, ConversationList) and function_name in {"open_conversation", "read_conversation"}:
            args = event.action.resolved_args or event.action.args
            conversation_id = args.get("conversation_id")
            if conversation_id:
                self.set_current_state(ConversationOpened(conversation_id))

        # go_back transitions are handled automatically by StatefulApp.go_back()

    def create_root_state(self) -> ConversationList:
        """Return the conversation list root state."""
        return ConversationList()

__init__(*args, **kwargs)

Initialize the stateful messaging app.

Parameters:

Name Type Description Default
*args Any

Variable length argument list passed to parent classes.

()
**kwargs Any

Arbitrary keyword arguments passed to parent classes.

{}
Source code in pare/apps/messaging/app.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
def __init__(self, *args: Any, **kwargs: Any) -> None:
    """Initialize the stateful messaging app.

    Args:
        *args: Variable length argument list passed to parent classes.
        **kwargs: Arbitrary keyword arguments passed to parent classes.
    """
    super().__init__(*args, **kwargs)
    self.current_user_id = uuid_hex(self.rng)
    self.current_user_name = "John Doe"
    # Register current user in id/name mappings
    self.id_to_name[self.current_user_id] = self.current_user_name
    self.name_to_id[self.current_user_name] = self.current_user_id
    # Set initial state to conversation list
    self.load_root_state()

add_contacts(contacts)

Add contacts (name, phone) to the internal name/id maps.

Parameters:

Name Type Description Default
contacts list[tuple[str, str]]

Pairs of (user_name, phone).

required
Source code in pare/apps/messaging/app.py
51
52
53
54
55
56
57
58
59
60
def add_contacts(self, contacts: list[tuple[str, str]]) -> None:
    """Add contacts (name, phone) to the internal name/id maps.

    Args:
        contacts: Pairs of (user_name, phone).
    """
    for user_name, phone in contacts:
        if user_name not in self.name_to_id:
            self.name_to_id[user_name] = phone
            self.id_to_name[phone] = user_name

add_users(user_names)

Add users to the internal name/id maps.

Parameters:

Name Type Description Default
user_names list[str]

User display names to ensure exist in the mapping.

required
Source code in pare/apps/messaging/app.py
39
40
41
42
43
44
45
46
47
48
49
def add_users(self, user_names: list[str]) -> None:
    """Add users to the internal name/id maps.

    Args:
        user_names: User display names to ensure exist in the mapping.
    """
    for user_name in user_names:
        if user_name not in self.name_to_id:
            user_id = uuid_hex(self.rng)
            self.name_to_id[user_name] = user_id
            self.id_to_name[user_id] = user_name

create_root_state()

Return the conversation list root state.

Source code in pare/apps/messaging/app.py
85
86
87
def create_root_state(self) -> ConversationList:
    """Return the conversation list root state."""
    return ConversationList()

handle_state_transition(event)

Handle state transitions based on tool events.

// RL NOTE: This implements T(s,a) -> s' for the messaging MDP.

Parameters:

Name Type Description Default
event CompletedEvent

Completed event from tool execution

required
Source code in pare/apps/messaging/app.py
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
def handle_state_transition(self, event: CompletedEvent) -> None:
    """Handle state transitions based on tool events.

    // RL NOTE: This implements T(s,a) -> s' for the messaging MDP.

    Args:
        event: Completed event from tool execution
    """
    current_state = self.current_state
    function_name = event.function_name()

    if current_state is None or function_name is None:
        return

    # Transition: ConversationList -> ConversationOpened
    if isinstance(current_state, ConversationList) and function_name in {"open_conversation", "read_conversation"}:
        args = event.action.resolved_args or event.action.args
        conversation_id = args.get("conversation_id")
        if conversation_id:
            self.set_current_state(ConversationOpened(conversation_id))

StatefulNotesApp dataclass

Bases: StatefulApp

A Notes application that manages user's notes and folder organization. This class provides comprehensive functionality for handling notes including creating, updating, deleting, and searching notes.

This app maintains the notes in different folders. Default folders are "Inbox", "Personal", and "Work". New folders can be created by the user.

Key Features: - Note Management: Create, update, move and delete notes - Folder Management: Create, delete, and search folders (Default folders cannot be deleted) - Attachment Management: Handle note attachments (upload and download) - Search Functionality: Search notes across folders with text-based queries - State Management: Save and load application state

Key Components: - Folders: Each NotesFolder instance maintains its own collection of notes - View Limits: Configurable limit for note viewing and pagination - Event Registration: All operations are tracked through event registration

Notes: - Note IDs are automatically generated when creating new notes. - Attachments are handled using base64 encoding. - Search operations are case-insensitive. - All notes operations maintain folder integrity.

Source code in pare/apps/note/app.py
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
@dataclass
class StatefulNotesApp(StatefulApp):
    """A Notes application that manages user's notes and folder organization. This class provides comprehensive functionality for handling notes including creating, updating, deleting, and searching notes.

    This app maintains the notes in different folders. Default folders are "Inbox", "Personal", and "Work". New folders can be created by the user.

    Key Features:
    - Note Management: Create, update, move and delete notes
    - Folder Management: Create, delete, and search folders (Default folders cannot be deleted)
    - Attachment Management: Handle note attachments (upload and download)
    - Search Functionality: Search notes across folders with text-based queries
    - State Management: Save and load application state

    Key Components:
    - Folders: Each NotesFolder instance maintains its own collection of notes
    - View Limits: Configurable limit for note viewing and pagination
    - Event Registration: All operations are tracked through event registration

    Notes:
    - Note IDs are automatically generated when creating new notes.
    - Attachments are handled using base64 encoding.
    - Search operations are case-insensitive.
    - All notes operations maintain folder integrity.
    """

    name: str | None = None
    view_limit: int = 5
    folders: dict[str, NotesFolder] = field(default_factory=dict)
    internal_fs: SandboxLocalFileSystem | VirtualFileSystem | None = None

    def __post_init__(self) -> None:
        """Initialize app with default folders."""
        super().__init__(self.name or "note")

        # Initialize default folders
        self.default_folders = ["Inbox", "Personal", "Work"]
        for folder_name in self.default_folders:
            if folder_name not in self.folders:
                self.folders[folder_name] = NotesFolder(folder_name)

        self.load_root_state()

    def connect_to_protocols(self, protocols: dict[Protocol, Any]) -> None:
        """Connect to the given list of protocols.

        Args:
            protocols (dict[Protocol, Any]): Dictionary of protocols.
        """
        file_system = protocols.get(Protocol.FILE_SYSTEM)
        if isinstance(file_system, (SandboxLocalFileSystem, VirtualFileSystem)):
            self.internal_fs = file_system

    def create_root_state(self) -> NoteList:
        """Return the root navigation state.

        Returns:
            NoteList: Default folder view.
        """
        return NoteList("Inbox")

    def get_state(self) -> dict[str, Any]:
        """Serialize app state.

        Returns:
            dict[str, Any]: Complete app state.
        """
        return {
            "view_limit": self.view_limit,
            "folders": {k: v.get_state() for k, v in self.folders.items()},
        }

    def load_state(self, state_dict: dict[str, Any]) -> None:
        """Deserialize app state.

        Args:
            state_dict (dict[str, Any]): State to restore.
        """
        self.view_limit = state_dict["view_limit"]
        self.folders.clear()
        for folder_name, folder_state in state_dict.get("folders", {}).items():
            folder = NotesFolder(folder_name)
            folder.load_state(folder_state)
            self.folders[folder_name] = folder

    def reset(self) -> None:
        """Reset the app to empty state."""
        super().reset()
        for folder in self.folders:
            self.folders[folder].notes.clear()

    def _get_note_from_any_folder(self, note_id: str) -> tuple[str, Note] | None:
        """Find a note across all folders.

        Args:
            note_id (str): Note ID to find.

        Returns:
            tuple[str, Note] | None: Folder Name and Note object if found, None otherwise.
        """
        for name, folder in self.folders.items():
            note = folder.get_note_by_id(note_id)
            if note is not None:
                return (name, note)
        return None

    def open_folder(self, folder: str) -> list[Note]:
        """Open a folder and return the notes in the folder.

        Args:
            folder (str): Name of the folder to open.

        Returns:
            list[Note]: List of notes in the folder.

        Raises:
            KeyError: If folder does not exist.
            ValueError: If folder name is empty.
        """
        if folder not in self.folders:
            raise KeyError(f"Folder {folder} does not exist")
        if len(folder) == 0:
            raise ValueError("Folder name must be non-empty")
        return list(self.folders[folder].notes.values())

    @type_check
    @app_tool()
    @pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
    def new_folder(self, folder_name: str) -> str:
        """Create a new empty folder with the given name.

        Args:
            folder_name (str): Name of the new folder.

        Returns:
            str: Name of the newly created folder.

        Raises:
            KeyError: If folder already exists.
        """
        if folder_name in self.folders:
            raise KeyError(f"Folder {folder_name} already exists")
        self.folders[folder_name] = NotesFolder(folder_name)
        return folder_name

    @type_check
    @env_tool()
    @app_tool()
    @pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
    def delete_folder(self, folder_name: str) -> str:
        """Delete a folder and all it's notes. Default folders "Inbox", "Personal", and "Work" cannot be deleted.

        Args:
            folder_name (str): Name of the folder to delete.

        Returns:
            str: Name of the deleted folder if successful.

        Raises:
            KeyError: If folder does not exist, or if the folder to be deleted is one of the default folders.
        """
        if folder_name not in self.folders:
            raise KeyError(f"Folder {folder_name} does not exist")
        if folder_name in self.default_folders:
            raise KeyError(f"Cannot delete default folder {folder_name}")

        self.folders[folder_name].notes.clear()
        del self.folders[folder_name]
        logger.debug(f"Deleted folder {folder_name}")
        return folder_name

    @type_check
    @app_tool()
    @pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
    def rename_folder(self, folder: str, new_folder: str) -> str:
        """Rename an already existing folder. Default folders "Inbox", "Personal", and "Work" cannot be renamed.

        Args:
            folder (str): Name of the folder to rename.
            new_folder(str): New name for the folder.

        Returns:
            str: Name of the renamed folder.

        Raises:
            KeyError: If folder_name does not exist, a folder with the new name already exists or if the folder to be renamed is one of the default folders.
        """
        if folder not in self.folders:
            raise KeyError(f"Folder {folder} does not exist")
        if new_folder in self.folders:
            raise KeyError(f"Folder {new_folder} already exists")
        if folder in self.default_folders:
            raise KeyError(f"Cannot rename default folder {folder}")
        self.folders[new_folder] = deepcopy(self.folders[folder])
        self.folders[new_folder].folder_name = new_folder
        del self.folders[folder]
        logger.debug(f"Renamed folder {folder} to {new_folder}")
        return new_folder

    @data_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def create_note_with_time(
        self,
        folder: str = "Inbox",
        title: str = "",
        content: str = "",
        pinned: bool = False,
        created_at: str = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"),
        updated_at: str | None = None,
    ) -> str:
        """Create a new note with title and content at a specific time. If title string is empty, it will be set to the first 50 characters of the content. If specified folder is not found, a new folder will be created.

        Args:
            folder (str): Folder to create the note under.
            title (str): Title of the note.
            content (str): Content of the note.
            pinned (bool): Whether the note should be pinned.
            created_at (str): Time of the note creation. Defaults to the current time.
            updated_at (str): Time of the note update. Defaults to the creation time.

        Returns:
            str: ID of the newly created note.

        Raises:
            ValueError: If creation or update time is invalid, or if updated time is before creation time.
        """
        try:
            creation_time = datetime.strptime(created_at, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC).timestamp()
        except ValueError as e:
            raise ValueError("Invalid datetime format for the creation time. Please use YYYY-MM-DD HH:MM:SS") from e
        if updated_at is not None:
            try:
                update_time = datetime.strptime(updated_at, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC).timestamp()
            except ValueError as e:
                raise ValueError("Invalid datetime format for the update time. Please use YYYY-MM-DD HH:MM:SS") from e
        else:
            update_time = creation_time

        if folder not in self.folders:
            with disable_events():
                self.new_folder(folder)

        if update_time < creation_time:
            raise ValueError(
                "Updated time cannot be before creation time. Creation Time: {creation_time}, Updated Time: {update_time}"
            )
        note_id = uuid_hex(self.rng)
        note = Note(
            note_id=note_id,
            title=title,
            content=content,
            pinned=pinned,
            created_at=creation_time,
            updated_at=update_time,
        )
        self.folders[folder].add_note(note)
        return note.note_id

    @type_check
    @app_tool()
    @pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
    def create_note(self, folder: str = "Inbox", title: str = "", content: str = "", pinned: bool = False) -> str:
        """Create a new note with title and content. If title string is empty, it will be set to the first 50 characters of the content.

        Args:
            folder (str): Folder to create the note under.
            title (str): Title of the note.
            content (str): Content of the note.
            pinned (bool): Whether the note should be pinned.

        Returns:
            str: ID of the newly created note.

        Raises:
            KeyError: If specified folder is not found.
        """
        if folder not in self.folders:
            raise KeyError(f"Folder {folder} does not exist")
        if title is None or len(title.strip()) == 0:
            title = content[:50]
        note_id = uuid_hex(self.rng)
        note = Note(
            note_id=note_id,
            title=title,
            content=content,
            pinned=False,
            created_at=self.time_manager.time(),
            updated_at=self.time_manager.time(),
        )
        self.folders[folder].add_note(note)
        return note.note_id

    @type_check
    @data_tool()
    @app_tool()
    @pare_event_registered(operation_type=OperationType.READ, event_type=EventType.AGENT)
    def get_note_by_id(self, note_id: str) -> Note:
        """Retrieve a note by ID.

        Args:
            note_id (str): Target note ID.

        Returns:
            Note: The retrieved note object.

        Raises:
            KeyError: If note not found.
        """
        if not isinstance(note_id, str):
            raise TypeError(f"Note ID must be a string, got {type(note_id)}.")
        if len(note_id) == 0:
            raise ValueError("Note ID must be non-empty.")
        result = self._get_note_from_any_folder(note_id)
        if result is None:
            raise KeyError(f"Note {note_id} not found")
        return result[1]

    @type_check
    @app_tool()
    @pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
    def update_note(self, note_id: str, title: str | None = None, content: str | None = None) -> str:
        """Update the title or the content of the note. At least one of title or content must be provided.

        Notes:
        - If both title and content are provided, both will be updated.
        - If the note has no title and new title is provided, the title will be set to the new title.
        - If the note has no title and content is provided, the title will be set to the first 50 characters of the content.

        Args:
            note_id (str): Target note ID.
            title (str | None): New title for the note.
            content (str | None): New content for the note.

        Returns:
            str: Note ID of the updated note.

        Raises:
            KeyError: If note not found.
            ValueError: If both title and content are empty.
        """
        result = self._get_note_from_any_folder(note_id)
        if result is None:
            raise KeyError(f"Note {note_id} not found")

        folder, note = result

        if (title is None or len(title.strip()) == 0) and (content is None or len(content.strip()) == 0):
            raise ValueError(
                "Both title and content cannot be empty. At least one of title or content must be provided."
            )

        if title is not None and len(title.strip()) > 0:
            note.title = title

        # Title was not provided, content was provided
        if content is not None and len(content.strip()) > 0:
            if note.title is None or len(note.title.strip()) == 0:
                note.title = content[:50]
            note.content = content

        note.updated_at = self.time_manager.time()
        self.folders[folder].notes[note.note_id] = note

        return note_id

    @type_check
    @app_tool()
    @pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
    def delete_note(self, note_id: str) -> str:
        """Delete a note with the specified ID. Deleted Note ID is returned.

        Args:
            note_id (str): ID of note to delete.

        Returns:
            str: ID of the deleted note.

        Raises:
            TypeError: If note ID is not a string.
            ValueError: If note ID is empty.
            KeyError: If note not found.
        """
        if not isinstance(note_id, str):
            raise TypeError(f"Note ID must be a string, got {type(note_id)}.")
        if len(note_id) == 0:
            raise ValueError("Note ID must be non-empty.")
        result = self._get_note_from_any_folder(note_id)
        if result is None:
            raise KeyError(f"Note {note_id} not found")
        folder, _ = result
        self.folders[folder].remove_note(note_id)
        return note_id

    @type_check
    @app_tool()
    @pare_event_registered(operation_type=OperationType.READ, event_type=EventType.AGENT)
    def list_notes(self, folder: str, offset: int = 0, limit: int = 10) -> ReturnedNotes:
        """List notes in the specific folder with a specified offset.

        Args:
            folder (str): The folder to list notes from.
            offset (int): The offset of the first note to return.
            limit (int): The maximum number of notes to return.

        Returns:
            ReturnedNotes: Notes with additional metadata about the range of notes retrieved and total number of notes
        """
        if folder not in self.folders:
            raise ValueError(f"Folder {folder} not found")

        return self.folders[folder].get_notes(offset, limit)

    @type_check
    @app_tool()
    @pare_event_registered(operation_type=OperationType.READ, event_type=EventType.AGENT)
    def list_folders(self) -> list[str]:
        """List all folder names.

        Returns:
            list[str]: Folder list.
        """
        return list(self.folders.keys())

    @type_check
    @app_tool()
    @pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
    def move_note(self, note_id: str, source_folder_name: str = "Inbox", dest_folder_name: str = "Personal") -> str:
        """Move a note with the specified ID to the specified folder.

        Args:
            note_id (str): The ID of the note to move.
            source_folder_name (str): The folder to move the note from. Defaults to Inbox.
            dest_folder_name (str): The folder to move the note to. Defaults to Personal.

        Returns:
            str: The ID of the moved note

        Raises:
            KeyError: If source or destination folder not found or note not found in source folder.
        """
        if source_folder_name not in self.folders:
            raise KeyError(f"Folder {source_folder_name} not found.")
        if dest_folder_name not in self.folders:
            raise KeyError(f"Folder {dest_folder_name} not found.")
        note = self.folders[source_folder_name].get_note_by_id(note_id)
        if note is None:
            raise KeyError(f"Note {note_id} not found in folder {source_folder_name}.")
        self.folders[dest_folder_name].add_note(note)
        self.folders[source_folder_name].remove_note(note_id)
        return note_id

    @type_check
    @app_tool()
    @pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
    def duplicate_note(self, folder_name: str, note_id: str) -> str:
        """Create a duplicated copy of a note. The new note is added to the same folder as the original note and the title is "Copy of <original title>".

        Args:
            folder_name (str): The folder of the original note. Defaults to Inbox.
            note_id (str): The ID of the note to copy.

        Returns:
            str: The ID of the newly created duplicate.

        Raises:
            KeyError: If folder not found or note not found in folder.
        """
        if folder_name not in self.folders:
            raise KeyError(f"Folder {folder_name} not found.")
        current_note = self.folders[folder_name].get_note_by_id(note_id)
        if current_note is None:
            raise KeyError(f"Note {note_id} not found in folder {folder_name}.")

        new_note_id = uuid_hex(self.rng)
        new_note = Note(
            note_id=new_note_id,
            title=f"Copy of {current_note.title}",
            content=current_note.content,
            pinned=False,
            attachments=deepcopy(current_note.attachments),
        )
        self.folders[folder_name].add_note(new_note)

        return new_note_id

    @type_check
    @app_tool()
    @pare_event_registered(operation_type=OperationType.READ, event_type=EventType.AGENT)
    def search_notes(self, query: str) -> list[Note]:
        """Search for notes across all folders based on a query string. The search looks for partial matches in title, and content.

        Args:
            query (str): The search query string.

        Returns:
            list[Note]: A list of notes that match the query.
        """
        results = []
        for folder in self.folders:
            results.extend(self.folders[folder].search_notes(query))
        return results

    @type_check
    @app_tool()
    @pare_event_registered(operation_type=OperationType.READ, event_type=EventType.AGENT)
    def search_notes_in_folder(self, query: str, folder_name: str) -> list[Note]:
        """Search for notes in a specific folder based on a query string. The search looks for partial matches in title, and content.

        Args:
            query (str): The search query string.
            folder_name (str): The folder to search in. Defaults to Inbox.

        Returns:
            list[Note]: A list of notes that match the query.

        Raises:
            KeyError: If folder not found.
        """
        if folder_name not in self.folders:
            raise KeyError(f"Folder {folder_name} not found.")
        return self.folders[folder_name].search_notes(query)

    def add_attachment(self, note: Note, attachment_path: str) -> Note:
        """Add a file attachment to a note.

        Args:
            note (Note): The note to add the attachment to.
            attachment_path (str): The path to the attachment to add.

        Returns:
            Note: The updated note object.

        Raises:
            ValueError: If file does not exist.
        """
        if self.internal_fs is not None:
            if not self.internal_fs.exists(attachment_path):
                raise ValueError(f"File does not exist: {attachment_path}")
            with disable_events(), self.internal_fs.open(attachment_path, "rb") as f:
                file_content = base64.b64encode(f.read())
                file_name = Path(attachment_path).name
                if not note.attachments:
                    note.attachments = {}
                note.attachments[file_name] = file_content
        else:
            note.add_attachment(attachment_path)

        return note

    @type_check
    @app_tool()
    @pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
    def add_attachment_to_note(self, note_id: str, attachment_path: str) -> str:
        """Add a file attachment to a note.

        Args:
            note_id (str): The ID of the note to add the attachment to.
            attachment_path (str): The path to the attachment to add.

        Returns:
            str: The ID of the note that the attachment was added to.
        """
        result = self._get_note_from_any_folder(note_id)
        if result is None:
            raise KeyError(f"Note {note_id} not found in any folder.")
        folder_name, note = result
        note = self.add_attachment(note, attachment_path)
        note.updated_at = self.time_manager.time()
        self.folders[folder_name].notes[note.note_id] = note
        return note.note_id

    @type_check
    @app_tool()
    @pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
    def remove_attachment(self, note_id: str, attachment: str) -> str:
        """Remove an attachment from a note.

        Args:
            note_id (str): Target note ID.
            attachment (str): Attachment to remove.

        Returns:
            str: The ID of the note that the attachment was removed from.

        Raises:
            KeyError: If note not found in any folder or attachment not found in note.
        """
        result = self._get_note_from_any_folder(note_id)
        if result is None:
            raise KeyError(f"Note {note_id} not found in any folder.")
        folder_name, note = result
        # code path is not reachable
        if note.attachments is None:
            raise KeyError(f"Note {note_id} has no attachments.")

        if attachment not in note.attachments:
            raise KeyError(f"Attachment {attachment} not found in note {note_id}")

        del note.attachments[attachment]
        note.updated_at = self.time_manager.time()
        self.folders[folder_name].notes[note.note_id] = note

        return note.note_id

    @type_check
    @app_tool()
    @pare_event_registered(operation_type=OperationType.READ, event_type=EventType.AGENT)
    def list_attachments(self, note_id: str) -> list[str]:
        """List attachment identifiers for a note.

        Args:
            note_id (str): Target note ID.

        Returns:
            list[str]: Attachment list.

        Raises:
            KeyError: If note not found.
        """
        result = self._get_note_from_any_folder(note_id)
        if result is None:
            raise KeyError(f"Note {note_id} not found")
        _, note = result
        # code path is not reachable
        if note.attachments is None:
            return []

        return list(note.attachments.keys())

    def _resolve_note_id(self, args: dict[str, Any], metadata: object | None) -> str | None:
        """Extract note_id from args or metadata. Assumes that note_id is either in args or return value of the completed event.

        Args:
            args: Function arguments dictionary.
            metadata: Return value from the completed event.

        Returns:
            str | None: Extracted note ID or None.
        """
        note_id = args.get("note_id")
        if isinstance(note_id, str):
            return note_id
        if isinstance(metadata, str):
            return metadata
        return None

    def handle_state_transition(self, event: CompletedEvent) -> None:
        """Core navigation handler mapping backend operations to state transitions."""
        current_state = self.current_state
        fname = event.function_name()

        if current_state is None or fname is None:
            return

        action = event.action
        args = action.resolved_args or action.args

        metadata_value = event.metadata.return_value if event.metadata else None
        if isinstance(current_state, NoteList):
            self._handle_note_list_transition(fname, args, metadata_value)
        elif isinstance(current_state, NoteDetail):
            self._handle_note_detail_transition(fname, args, metadata_value)
        elif isinstance(current_state, EditNote):
            self._handle_edit_note_transition(fname, args, metadata_value)
        elif isinstance(current_state, FolderList):
            self._handle_folder_list_transition(fname, args, metadata_value)

    def _handle_note_list_transition(self, fname: str, args: dict[str, Any], metadata: object | None) -> None:
        """Process transitions from the note list view."""
        if fname == "new_note":
            note_id = self._resolve_note_id(args, metadata)
            if note_id:
                self.set_current_state(EditNote(note_id))
            return

        if fname == "open":
            note_id = self._resolve_note_id(args, metadata)
            if note_id:
                self.set_current_state(NoteDetail(note_id))
            return

        if fname == "list_folders":
            self.set_current_state(FolderList())

    def _handle_note_detail_transition(self, fname: str, args: dict[str, Any], metadata: object | None) -> None:
        """Process transitions from the note detail view."""
        if fname == "edit":
            note_id = self._resolve_note_id(args, metadata)
            if note_id:
                self.set_current_state(EditNote(note_id))
            return

        if fname == "delete" and self.navigation_stack:
            with disable_events():
                self.go_back()
            return

        if fname == "duplicate":
            note_id = self._resolve_note_id(args, metadata)
            if note_id:
                self.set_current_state(NoteDetail(note_id))
            return

        if fname == "move":
            dest = args.get("dest_folder_name")
            if isinstance(dest, str):
                self.set_current_state(NoteList(dest))

    def _handle_edit_note_transition(self, fname: str, args: dict[str, Any], metadata: object | None) -> None:
        """Process transitions from the edit note view."""
        if fname == "update":
            note_id = self._resolve_note_id(args, metadata)
            if note_id:
                self.set_current_state(NoteDetail(note_id))

    def _handle_folder_list_transition(self, fname: str, args: dict[str, Any], metadata: object | None) -> None:
        """Process transitions from the folder list view."""
        if fname == "open":
            folder = args.get("folder")
            if isinstance(folder, str):
                self.set_current_state(NoteList(folder))

__post_init__()

Initialize app with default folders.

Source code in pare/apps/note/app.py
183
184
185
186
187
188
189
190
191
192
193
def __post_init__(self) -> None:
    """Initialize app with default folders."""
    super().__init__(self.name or "note")

    # Initialize default folders
    self.default_folders = ["Inbox", "Personal", "Work"]
    for folder_name in self.default_folders:
        if folder_name not in self.folders:
            self.folders[folder_name] = NotesFolder(folder_name)

    self.load_root_state()

add_attachment(note, attachment_path)

Add a file attachment to a note.

Parameters:

Name Type Description Default
note Note

The note to add the attachment to.

required
attachment_path str

The path to the attachment to add.

required

Returns:

Name Type Description
Note Note

The updated note object.

Raises:

Type Description
ValueError

If file does not exist.

Source code in pare/apps/note/app.py
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
def add_attachment(self, note: Note, attachment_path: str) -> Note:
    """Add a file attachment to a note.

    Args:
        note (Note): The note to add the attachment to.
        attachment_path (str): The path to the attachment to add.

    Returns:
        Note: The updated note object.

    Raises:
        ValueError: If file does not exist.
    """
    if self.internal_fs is not None:
        if not self.internal_fs.exists(attachment_path):
            raise ValueError(f"File does not exist: {attachment_path}")
        with disable_events(), self.internal_fs.open(attachment_path, "rb") as f:
            file_content = base64.b64encode(f.read())
            file_name = Path(attachment_path).name
            if not note.attachments:
                note.attachments = {}
            note.attachments[file_name] = file_content
    else:
        note.add_attachment(attachment_path)

    return note

add_attachment_to_note(note_id, attachment_path)

Add a file attachment to a note.

Parameters:

Name Type Description Default
note_id str

The ID of the note to add the attachment to.

required
attachment_path str

The path to the attachment to add.

required

Returns:

Name Type Description
str str

The ID of the note that the attachment was added to.

Source code in pare/apps/note/app.py
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
@type_check
@app_tool()
@pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
def add_attachment_to_note(self, note_id: str, attachment_path: str) -> str:
    """Add a file attachment to a note.

    Args:
        note_id (str): The ID of the note to add the attachment to.
        attachment_path (str): The path to the attachment to add.

    Returns:
        str: The ID of the note that the attachment was added to.
    """
    result = self._get_note_from_any_folder(note_id)
    if result is None:
        raise KeyError(f"Note {note_id} not found in any folder.")
    folder_name, note = result
    note = self.add_attachment(note, attachment_path)
    note.updated_at = self.time_manager.time()
    self.folders[folder_name].notes[note.note_id] = note
    return note.note_id

connect_to_protocols(protocols)

Connect to the given list of protocols.

Parameters:

Name Type Description Default
protocols dict[Protocol, Any]

Dictionary of protocols.

required
Source code in pare/apps/note/app.py
195
196
197
198
199
200
201
202
203
def connect_to_protocols(self, protocols: dict[Protocol, Any]) -> None:
    """Connect to the given list of protocols.

    Args:
        protocols (dict[Protocol, Any]): Dictionary of protocols.
    """
    file_system = protocols.get(Protocol.FILE_SYSTEM)
    if isinstance(file_system, (SandboxLocalFileSystem, VirtualFileSystem)):
        self.internal_fs = file_system

create_note(folder='Inbox', title='', content='', pinned=False)

Create a new note with title and content. If title string is empty, it will be set to the first 50 characters of the content.

Parameters:

Name Type Description Default
folder str

Folder to create the note under.

'Inbox'
title str

Title of the note.

''
content str

Content of the note.

''
pinned bool

Whether the note should be pinned.

False

Returns:

Name Type Description
str str

ID of the newly created note.

Raises:

Type Description
KeyError

If specified folder is not found.

Source code in pare/apps/note/app.py
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
@type_check
@app_tool()
@pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
def create_note(self, folder: str = "Inbox", title: str = "", content: str = "", pinned: bool = False) -> str:
    """Create a new note with title and content. If title string is empty, it will be set to the first 50 characters of the content.

    Args:
        folder (str): Folder to create the note under.
        title (str): Title of the note.
        content (str): Content of the note.
        pinned (bool): Whether the note should be pinned.

    Returns:
        str: ID of the newly created note.

    Raises:
        KeyError: If specified folder is not found.
    """
    if folder not in self.folders:
        raise KeyError(f"Folder {folder} does not exist")
    if title is None or len(title.strip()) == 0:
        title = content[:50]
    note_id = uuid_hex(self.rng)
    note = Note(
        note_id=note_id,
        title=title,
        content=content,
        pinned=False,
        created_at=self.time_manager.time(),
        updated_at=self.time_manager.time(),
    )
    self.folders[folder].add_note(note)
    return note.note_id

create_note_with_time(folder='Inbox', title='', content='', pinned=False, created_at=datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S'), updated_at=None)

Create a new note with title and content at a specific time. If title string is empty, it will be set to the first 50 characters of the content. If specified folder is not found, a new folder will be created.

Parameters:

Name Type Description Default
folder str

Folder to create the note under.

'Inbox'
title str

Title of the note.

''
content str

Content of the note.

''
pinned bool

Whether the note should be pinned.

False
created_at str

Time of the note creation. Defaults to the current time.

strftime('%Y-%m-%d %H:%M:%S')
updated_at str

Time of the note update. Defaults to the creation time.

None

Returns:

Name Type Description
str str

ID of the newly created note.

Raises:

Type Description
ValueError

If creation or update time is invalid, or if updated time is before creation time.

Source code in pare/apps/note/app.py
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
@data_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def create_note_with_time(
    self,
    folder: str = "Inbox",
    title: str = "",
    content: str = "",
    pinned: bool = False,
    created_at: str = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"),
    updated_at: str | None = None,
) -> str:
    """Create a new note with title and content at a specific time. If title string is empty, it will be set to the first 50 characters of the content. If specified folder is not found, a new folder will be created.

    Args:
        folder (str): Folder to create the note under.
        title (str): Title of the note.
        content (str): Content of the note.
        pinned (bool): Whether the note should be pinned.
        created_at (str): Time of the note creation. Defaults to the current time.
        updated_at (str): Time of the note update. Defaults to the creation time.

    Returns:
        str: ID of the newly created note.

    Raises:
        ValueError: If creation or update time is invalid, or if updated time is before creation time.
    """
    try:
        creation_time = datetime.strptime(created_at, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC).timestamp()
    except ValueError as e:
        raise ValueError("Invalid datetime format for the creation time. Please use YYYY-MM-DD HH:MM:SS") from e
    if updated_at is not None:
        try:
            update_time = datetime.strptime(updated_at, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC).timestamp()
        except ValueError as e:
            raise ValueError("Invalid datetime format for the update time. Please use YYYY-MM-DD HH:MM:SS") from e
    else:
        update_time = creation_time

    if folder not in self.folders:
        with disable_events():
            self.new_folder(folder)

    if update_time < creation_time:
        raise ValueError(
            "Updated time cannot be before creation time. Creation Time: {creation_time}, Updated Time: {update_time}"
        )
    note_id = uuid_hex(self.rng)
    note = Note(
        note_id=note_id,
        title=title,
        content=content,
        pinned=pinned,
        created_at=creation_time,
        updated_at=update_time,
    )
    self.folders[folder].add_note(note)
    return note.note_id

create_root_state()

Return the root navigation state.

Returns:

Name Type Description
NoteList NoteList

Default folder view.

Source code in pare/apps/note/app.py
205
206
207
208
209
210
211
def create_root_state(self) -> NoteList:
    """Return the root navigation state.

    Returns:
        NoteList: Default folder view.
    """
    return NoteList("Inbox")

delete_folder(folder_name)

Delete a folder and all it's notes. Default folders "Inbox", "Personal", and "Work" cannot be deleted.

Parameters:

Name Type Description Default
folder_name str

Name of the folder to delete.

required

Returns:

Name Type Description
str str

Name of the deleted folder if successful.

Raises:

Type Description
KeyError

If folder does not exist, or if the folder to be deleted is one of the default folders.

Source code in pare/apps/note/app.py
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
@type_check
@env_tool()
@app_tool()
@pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
def delete_folder(self, folder_name: str) -> str:
    """Delete a folder and all it's notes. Default folders "Inbox", "Personal", and "Work" cannot be deleted.

    Args:
        folder_name (str): Name of the folder to delete.

    Returns:
        str: Name of the deleted folder if successful.

    Raises:
        KeyError: If folder does not exist, or if the folder to be deleted is one of the default folders.
    """
    if folder_name not in self.folders:
        raise KeyError(f"Folder {folder_name} does not exist")
    if folder_name in self.default_folders:
        raise KeyError(f"Cannot delete default folder {folder_name}")

    self.folders[folder_name].notes.clear()
    del self.folders[folder_name]
    logger.debug(f"Deleted folder {folder_name}")
    return folder_name

delete_note(note_id)

Delete a note with the specified ID. Deleted Note ID is returned.

Parameters:

Name Type Description Default
note_id str

ID of note to delete.

required

Returns:

Name Type Description
str str

ID of the deleted note.

Raises:

Type Description
TypeError

If note ID is not a string.

ValueError

If note ID is empty.

KeyError

If note not found.

Source code in pare/apps/note/app.py
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
@type_check
@app_tool()
@pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
def delete_note(self, note_id: str) -> str:
    """Delete a note with the specified ID. Deleted Note ID is returned.

    Args:
        note_id (str): ID of note to delete.

    Returns:
        str: ID of the deleted note.

    Raises:
        TypeError: If note ID is not a string.
        ValueError: If note ID is empty.
        KeyError: If note not found.
    """
    if not isinstance(note_id, str):
        raise TypeError(f"Note ID must be a string, got {type(note_id)}.")
    if len(note_id) == 0:
        raise ValueError("Note ID must be non-empty.")
    result = self._get_note_from_any_folder(note_id)
    if result is None:
        raise KeyError(f"Note {note_id} not found")
    folder, _ = result
    self.folders[folder].remove_note(note_id)
    return note_id

duplicate_note(folder_name, note_id)

Create a duplicated copy of a note. The new note is added to the same folder as the original note and the title is "Copy of ".

Parameters:

Name Type Description Default
folder_name str

The folder of the original note. Defaults to Inbox.

required
note_id str

The ID of the note to copy.

required

Returns:

Name Type Description
str str

The ID of the newly created duplicate.

Raises:

Type Description
KeyError

If folder not found or note not found in folder.

Source code in pare/apps/note/app.py
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
@type_check
@app_tool()
@pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
def duplicate_note(self, folder_name: str, note_id: str) -> str:
    """Create a duplicated copy of a note. The new note is added to the same folder as the original note and the title is "Copy of <original title>".

    Args:
        folder_name (str): The folder of the original note. Defaults to Inbox.
        note_id (str): The ID of the note to copy.

    Returns:
        str: The ID of the newly created duplicate.

    Raises:
        KeyError: If folder not found or note not found in folder.
    """
    if folder_name not in self.folders:
        raise KeyError(f"Folder {folder_name} not found.")
    current_note = self.folders[folder_name].get_note_by_id(note_id)
    if current_note is None:
        raise KeyError(f"Note {note_id} not found in folder {folder_name}.")

    new_note_id = uuid_hex(self.rng)
    new_note = Note(
        note_id=new_note_id,
        title=f"Copy of {current_note.title}",
        content=current_note.content,
        pinned=False,
        attachments=deepcopy(current_note.attachments),
    )
    self.folders[folder_name].add_note(new_note)

    return new_note_id

get_note_by_id(note_id)

Retrieve a note by ID.

Parameters:

Name Type Description Default
note_id str

Target note ID.

required

Returns:

Name Type Description
Note Note

The retrieved note object.

Raises:

Type Description
KeyError

If note not found.

Source code in pare/apps/note/app.py
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
@type_check
@data_tool()
@app_tool()
@pare_event_registered(operation_type=OperationType.READ, event_type=EventType.AGENT)
def get_note_by_id(self, note_id: str) -> Note:
    """Retrieve a note by ID.

    Args:
        note_id (str): Target note ID.

    Returns:
        Note: The retrieved note object.

    Raises:
        KeyError: If note not found.
    """
    if not isinstance(note_id, str):
        raise TypeError(f"Note ID must be a string, got {type(note_id)}.")
    if len(note_id) == 0:
        raise ValueError("Note ID must be non-empty.")
    result = self._get_note_from_any_folder(note_id)
    if result is None:
        raise KeyError(f"Note {note_id} not found")
    return result[1]

get_state()

Serialize app state.

Returns:

Type Description
dict[str, Any]

dict[str, Any]: Complete app state.

Source code in pare/apps/note/app.py
213
214
215
216
217
218
219
220
221
222
def get_state(self) -> dict[str, Any]:
    """Serialize app state.

    Returns:
        dict[str, Any]: Complete app state.
    """
    return {
        "view_limit": self.view_limit,
        "folders": {k: v.get_state() for k, v in self.folders.items()},
    }

handle_state_transition(event)

Core navigation handler mapping backend operations to state transitions.

Source code in pare/apps/note/app.py
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
def handle_state_transition(self, event: CompletedEvent) -> None:
    """Core navigation handler mapping backend operations to state transitions."""
    current_state = self.current_state
    fname = event.function_name()

    if current_state is None or fname is None:
        return

    action = event.action
    args = action.resolved_args or action.args

    metadata_value = event.metadata.return_value if event.metadata else None
    if isinstance(current_state, NoteList):
        self._handle_note_list_transition(fname, args, metadata_value)
    elif isinstance(current_state, NoteDetail):
        self._handle_note_detail_transition(fname, args, metadata_value)
    elif isinstance(current_state, EditNote):
        self._handle_edit_note_transition(fname, args, metadata_value)
    elif isinstance(current_state, FolderList):
        self._handle_folder_list_transition(fname, args, metadata_value)

list_attachments(note_id)

List attachment identifiers for a note.

Parameters:

Name Type Description Default
note_id str

Target note ID.

required

Returns:

Type Description
list[str]

list[str]: Attachment list.

Raises:

Type Description
KeyError

If note not found.

Source code in pare/apps/note/app.py
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
@type_check
@app_tool()
@pare_event_registered(operation_type=OperationType.READ, event_type=EventType.AGENT)
def list_attachments(self, note_id: str) -> list[str]:
    """List attachment identifiers for a note.

    Args:
        note_id (str): Target note ID.

    Returns:
        list[str]: Attachment list.

    Raises:
        KeyError: If note not found.
    """
    result = self._get_note_from_any_folder(note_id)
    if result is None:
        raise KeyError(f"Note {note_id} not found")
    _, note = result
    # code path is not reachable
    if note.attachments is None:
        return []

    return list(note.attachments.keys())

list_folders()

List all folder names.

Returns:

Type Description
list[str]

list[str]: Folder list.

Source code in pare/apps/note/app.py
564
565
566
567
568
569
570
571
572
573
@type_check
@app_tool()
@pare_event_registered(operation_type=OperationType.READ, event_type=EventType.AGENT)
def list_folders(self) -> list[str]:
    """List all folder names.

    Returns:
        list[str]: Folder list.
    """
    return list(self.folders.keys())

list_notes(folder, offset=0, limit=10)

List notes in the specific folder with a specified offset.

Parameters:

Name Type Description Default
folder str

The folder to list notes from.

required
offset int

The offset of the first note to return.

0
limit int

The maximum number of notes to return.

10

Returns:

Name Type Description
ReturnedNotes ReturnedNotes

Notes with additional metadata about the range of notes retrieved and total number of notes

Source code in pare/apps/note/app.py
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
@type_check
@app_tool()
@pare_event_registered(operation_type=OperationType.READ, event_type=EventType.AGENT)
def list_notes(self, folder: str, offset: int = 0, limit: int = 10) -> ReturnedNotes:
    """List notes in the specific folder with a specified offset.

    Args:
        folder (str): The folder to list notes from.
        offset (int): The offset of the first note to return.
        limit (int): The maximum number of notes to return.

    Returns:
        ReturnedNotes: Notes with additional metadata about the range of notes retrieved and total number of notes
    """
    if folder not in self.folders:
        raise ValueError(f"Folder {folder} not found")

    return self.folders[folder].get_notes(offset, limit)

load_state(state_dict)

Deserialize app state.

Parameters:

Name Type Description Default
state_dict dict[str, Any]

State to restore.

required
Source code in pare/apps/note/app.py
224
225
226
227
228
229
230
231
232
233
234
235
def load_state(self, state_dict: dict[str, Any]) -> None:
    """Deserialize app state.

    Args:
        state_dict (dict[str, Any]): State to restore.
    """
    self.view_limit = state_dict["view_limit"]
    self.folders.clear()
    for folder_name, folder_state in state_dict.get("folders", {}).items():
        folder = NotesFolder(folder_name)
        folder.load_state(folder_state)
        self.folders[folder_name] = folder

move_note(note_id, source_folder_name='Inbox', dest_folder_name='Personal')

Move a note with the specified ID to the specified folder.

Parameters:

Name Type Description Default
note_id str

The ID of the note to move.

required
source_folder_name str

The folder to move the note from. Defaults to Inbox.

'Inbox'
dest_folder_name str

The folder to move the note to. Defaults to Personal.

'Personal'

Returns:

Name Type Description
str str

The ID of the moved note

Raises:

Type Description
KeyError

If source or destination folder not found or note not found in source folder.

Source code in pare/apps/note/app.py
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
@type_check
@app_tool()
@pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
def move_note(self, note_id: str, source_folder_name: str = "Inbox", dest_folder_name: str = "Personal") -> str:
    """Move a note with the specified ID to the specified folder.

    Args:
        note_id (str): The ID of the note to move.
        source_folder_name (str): The folder to move the note from. Defaults to Inbox.
        dest_folder_name (str): The folder to move the note to. Defaults to Personal.

    Returns:
        str: The ID of the moved note

    Raises:
        KeyError: If source or destination folder not found or note not found in source folder.
    """
    if source_folder_name not in self.folders:
        raise KeyError(f"Folder {source_folder_name} not found.")
    if dest_folder_name not in self.folders:
        raise KeyError(f"Folder {dest_folder_name} not found.")
    note = self.folders[source_folder_name].get_note_by_id(note_id)
    if note is None:
        raise KeyError(f"Note {note_id} not found in folder {source_folder_name}.")
    self.folders[dest_folder_name].add_note(note)
    self.folders[source_folder_name].remove_note(note_id)
    return note_id

new_folder(folder_name)

Create a new empty folder with the given name.

Parameters:

Name Type Description Default
folder_name str

Name of the new folder.

required

Returns:

Name Type Description
str str

Name of the newly created folder.

Raises:

Type Description
KeyError

If folder already exists.

Source code in pare/apps/note/app.py
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
@type_check
@app_tool()
@pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
def new_folder(self, folder_name: str) -> str:
    """Create a new empty folder with the given name.

    Args:
        folder_name (str): Name of the new folder.

    Returns:
        str: Name of the newly created folder.

    Raises:
        KeyError: If folder already exists.
    """
    if folder_name in self.folders:
        raise KeyError(f"Folder {folder_name} already exists")
    self.folders[folder_name] = NotesFolder(folder_name)
    return folder_name

open_folder(folder)

Open a folder and return the notes in the folder.

Parameters:

Name Type Description Default
folder str

Name of the folder to open.

required

Returns:

Type Description
list[Note]

list[Note]: List of notes in the folder.

Raises:

Type Description
KeyError

If folder does not exist.

ValueError

If folder name is empty.

Source code in pare/apps/note/app.py
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
def open_folder(self, folder: str) -> list[Note]:
    """Open a folder and return the notes in the folder.

    Args:
        folder (str): Name of the folder to open.

    Returns:
        list[Note]: List of notes in the folder.

    Raises:
        KeyError: If folder does not exist.
        ValueError: If folder name is empty.
    """
    if folder not in self.folders:
        raise KeyError(f"Folder {folder} does not exist")
    if len(folder) == 0:
        raise ValueError("Folder name must be non-empty")
    return list(self.folders[folder].notes.values())

remove_attachment(note_id, attachment)

Remove an attachment from a note.

Parameters:

Name Type Description Default
note_id str

Target note ID.

required
attachment str

Attachment to remove.

required

Returns:

Name Type Description
str str

The ID of the note that the attachment was removed from.

Raises:

Type Description
KeyError

If note not found in any folder or attachment not found in note.

Source code in pare/apps/note/app.py
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
@type_check
@app_tool()
@pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
def remove_attachment(self, note_id: str, attachment: str) -> str:
    """Remove an attachment from a note.

    Args:
        note_id (str): Target note ID.
        attachment (str): Attachment to remove.

    Returns:
        str: The ID of the note that the attachment was removed from.

    Raises:
        KeyError: If note not found in any folder or attachment not found in note.
    """
    result = self._get_note_from_any_folder(note_id)
    if result is None:
        raise KeyError(f"Note {note_id} not found in any folder.")
    folder_name, note = result
    # code path is not reachable
    if note.attachments is None:
        raise KeyError(f"Note {note_id} has no attachments.")

    if attachment not in note.attachments:
        raise KeyError(f"Attachment {attachment} not found in note {note_id}")

    del note.attachments[attachment]
    note.updated_at = self.time_manager.time()
    self.folders[folder_name].notes[note.note_id] = note

    return note.note_id

rename_folder(folder, new_folder)

Rename an already existing folder. Default folders "Inbox", "Personal", and "Work" cannot be renamed.

Parameters:

Name Type Description Default
folder str

Name of the folder to rename.

required
new_folder str

New name for the folder.

required

Returns:

Name Type Description
str str

Name of the renamed folder.

Raises:

Type Description
KeyError

If folder_name does not exist, a folder with the new name already exists or if the folder to be renamed is one of the default folders.

Source code in pare/apps/note/app.py
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
@type_check
@app_tool()
@pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
def rename_folder(self, folder: str, new_folder: str) -> str:
    """Rename an already existing folder. Default folders "Inbox", "Personal", and "Work" cannot be renamed.

    Args:
        folder (str): Name of the folder to rename.
        new_folder(str): New name for the folder.

    Returns:
        str: Name of the renamed folder.

    Raises:
        KeyError: If folder_name does not exist, a folder with the new name already exists or if the folder to be renamed is one of the default folders.
    """
    if folder not in self.folders:
        raise KeyError(f"Folder {folder} does not exist")
    if new_folder in self.folders:
        raise KeyError(f"Folder {new_folder} already exists")
    if folder in self.default_folders:
        raise KeyError(f"Cannot rename default folder {folder}")
    self.folders[new_folder] = deepcopy(self.folders[folder])
    self.folders[new_folder].folder_name = new_folder
    del self.folders[folder]
    logger.debug(f"Renamed folder {folder} to {new_folder}")
    return new_folder

reset()

Reset the app to empty state.

Source code in pare/apps/note/app.py
237
238
239
240
241
def reset(self) -> None:
    """Reset the app to empty state."""
    super().reset()
    for folder in self.folders:
        self.folders[folder].notes.clear()

search_notes(query)

Search for notes across all folders based on a query string. The search looks for partial matches in title, and content.

Parameters:

Name Type Description Default
query str

The search query string.

required

Returns:

Type Description
list[Note]

list[Note]: A list of notes that match the query.

Source code in pare/apps/note/app.py
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
@type_check
@app_tool()
@pare_event_registered(operation_type=OperationType.READ, event_type=EventType.AGENT)
def search_notes(self, query: str) -> list[Note]:
    """Search for notes across all folders based on a query string. The search looks for partial matches in title, and content.

    Args:
        query (str): The search query string.

    Returns:
        list[Note]: A list of notes that match the query.
    """
    results = []
    for folder in self.folders:
        results.extend(self.folders[folder].search_notes(query))
    return results

search_notes_in_folder(query, folder_name)

Search for notes in a specific folder based on a query string. The search looks for partial matches in title, and content.

Parameters:

Name Type Description Default
query str

The search query string.

required
folder_name str

The folder to search in. Defaults to Inbox.

required

Returns:

Type Description
list[Note]

list[Note]: A list of notes that match the query.

Raises:

Type Description
KeyError

If folder not found.

Source code in pare/apps/note/app.py
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
@type_check
@app_tool()
@pare_event_registered(operation_type=OperationType.READ, event_type=EventType.AGENT)
def search_notes_in_folder(self, query: str, folder_name: str) -> list[Note]:
    """Search for notes in a specific folder based on a query string. The search looks for partial matches in title, and content.

    Args:
        query (str): The search query string.
        folder_name (str): The folder to search in. Defaults to Inbox.

    Returns:
        list[Note]: A list of notes that match the query.

    Raises:
        KeyError: If folder not found.
    """
    if folder_name not in self.folders:
        raise KeyError(f"Folder {folder_name} not found.")
    return self.folders[folder_name].search_notes(query)

update_note(note_id, title=None, content=None)

Update the title or the content of the note. At least one of title or content must be provided.

Notes: - If both title and content are provided, both will be updated. - If the note has no title and new title is provided, the title will be set to the new title. - If the note has no title and content is provided, the title will be set to the first 50 characters of the content.

Parameters:

Name Type Description Default
note_id str

Target note ID.

required
title str | None

New title for the note.

None
content str | None

New content for the note.

None

Returns:

Name Type Description
str str

Note ID of the updated note.

Raises:

Type Description
KeyError

If note not found.

ValueError

If both title and content are empty.

Source code in pare/apps/note/app.py
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
@type_check
@app_tool()
@pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
def update_note(self, note_id: str, title: str | None = None, content: str | None = None) -> str:
    """Update the title or the content of the note. At least one of title or content must be provided.

    Notes:
    - If both title and content are provided, both will be updated.
    - If the note has no title and new title is provided, the title will be set to the new title.
    - If the note has no title and content is provided, the title will be set to the first 50 characters of the content.

    Args:
        note_id (str): Target note ID.
        title (str | None): New title for the note.
        content (str | None): New content for the note.

    Returns:
        str: Note ID of the updated note.

    Raises:
        KeyError: If note not found.
        ValueError: If both title and content are empty.
    """
    result = self._get_note_from_any_folder(note_id)
    if result is None:
        raise KeyError(f"Note {note_id} not found")

    folder, note = result

    if (title is None or len(title.strip()) == 0) and (content is None or len(content.strip()) == 0):
        raise ValueError(
            "Both title and content cannot be empty. At least one of title or content must be provided."
        )

    if title is not None and len(title.strip()) > 0:
        note.title = title

    # Title was not provided, content was provided
    if content is not None and len(content.strip()) > 0:
        if note.title is None or len(note.title.strip()) == 0:
            note.title = content[:50]
        note.content = content

    note.updated_at = self.time_manager.time()
    self.folders[folder].notes[note.note_id] = note

    return note_id

StatefulReminderApp

Bases: StatefulApp, ReminderApp

Reminder application with PARE navigation support.

Source code in pare/apps/reminder/app.py
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
class StatefulReminderApp(StatefulApp, ReminderApp):
    """Reminder application with PARE navigation support."""

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        """Initialize the reminder app and load the root navigation state.

        Args:
            *args: Positional arguments passed to ReminderApp.
            **kwargs: Keyword arguments passed to ReminderApp.
        """
        super().__init__(*args, **kwargs)
        self.load_root_state()

    def create_root_state(self) -> ReminderList:
        """Create and return the root navigation state.

        Returns:
            The initial ReminderList state.
        """
        return ReminderList()

    def get_reminder_with_id(self, reminder_id: str) -> Reminder:
        """Retrieve a reminder by its ID.

        Args:
            reminder_id: The ID of the reminder to retrieve.

        Returns:
            The Reminder object corresponding to the given ID.

        Raises:
            KeyError: If the reminder ID does not exist.
        """
        if reminder_id not in self.reminders:
            raise KeyError(f"Reminder {reminder_id} not found.")
        return self.reminders[reminder_id]

    @app_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def update_reminder(
        self,
        reminder_id: str,
        title: str,
        description: str,
        due_datetime: str,
        repetition_unit: str | None,
        repetition_value: int | None,
    ) -> str:
        """Update an existing reminder and regenerate its repetitions.

        Args:
            reminder_id: ID of the reminder to update.
            title: Updated title.
            description: Updated description.
            due_datetime: Updated due datetime in "YYYY-MM-DD HH:MM:SS" format.
            repetition_unit: Repetition unit (e.g., "day", "week"), or None.
            repetition_value: Repetition interval value, or None.

        Returns:
            str: The reminder ID after update.

        Raises:
            ValueError: If the reminder ID does not exist.
        """
        if reminder_id not in self.reminders:
            raise ValueError(f"Reminder {reminder_id} not found.")

        dt = datetime.strptime(due_datetime, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC)

        reminder = self.reminders[reminder_id]
        reminder.title = title
        reminder.description = description
        reminder.due_datetime = dt
        reminder.repetition_unit = repetition_unit
        reminder.repetition_value = repetition_value

        base_id = reminder_id.split("_rep_")[0]
        to_delete = [k for k in self.reminders if k.startswith(f"{base_id}_rep_")]
        for k in to_delete:
            del self.reminders[k]

        next_id = reminder_id
        count = 0
        while repetition_unit and next_id and count < self.max_reminder_repetitions:
            next_id = self.add_reminder_repetition(next_id)
            if next_id:
                count += 1

        return reminder_id

    def handle_state_transition(self, event: CompletedEvent) -> None:
        """Update navigation state after a reminder operation completes.

        Args:
            event: Completed event containing tool invocation information.
        """
        current_state = self.current_state
        fname = event.function_name()

        if current_state is None or fname is None:
            return

        action = event.action
        args: dict[str, Any] = action.args if action and hasattr(action, "args") else {}

        metadata = event.metadata

        if isinstance(current_state, ReminderList):
            self._handle_list_transition(fname, args)
        elif isinstance(current_state, ReminderDetail):
            reminder_id = current_state.reminder_id
            self._handle_detail_transition(fname, reminder_id)
        elif isinstance(current_state, EditReminder):
            # EditReminder state can be reached from both creating a new reminder and editing an existing reminder.
            # If we are editing an existing reminder, we use the current reminder ID to navigate back to the ReminderDetail state.
            # Whereas, if we are creating a new reminder, there is no current reminder ID and we get the ID from the metadata after saving.
            saved_reminder_id = getattr(metadata, "return_value", None) if metadata else None
            original_reminder_id = current_state.reminder_id
            self._handle_edit_transition(
                fname, saved_reminder_id=saved_reminder_id, original_reminder_id=original_reminder_id
            )

    def _handle_list_transition(self, fname: str, args: dict[str, Any]) -> None:
        """Handle transitions from the reminder list state.

        Args:
            fname: Name of the invoked tool.
            args: Tool arguments.
        """
        if fname == "open_reminder":
            reminder_id = args.get("reminder_id")
            if reminder_id:
                self.set_current_state(ReminderDetail(reminder_id))
        elif fname == "create_new":
            self.set_current_state(EditReminder())

    def _handle_detail_transition(self, fname: str, reminder_id: str) -> None:
        """Handle transitions from the reminder detail state.

        Args:
            fname: Name of the invoked tool.
            reminder_id: ID of the current reminder being viewed.
        """
        if fname == "edit":
            self.set_current_state(EditReminder(reminder_id=reminder_id))
        elif fname == "delete":
            self.load_root_state()

    def _handle_edit_transition(
        self, fname: str, saved_reminder_id: str | None, original_reminder_id: str | None
    ) -> None:
        """Handle transitions from the edit reminder state.

        Args:
            fname: Name of the invoked tool.
            saved_reminder_id: ID of the reminder after save, if any.
            original_reminder_id: ID of the reminder being edited, if any.
        """
        if fname == "save":
            if saved_reminder_id is not None:
                self.set_current_state(ReminderDetail(reminder_id=saved_reminder_id))
        elif fname == "cancel":
            if original_reminder_id is not None:
                self.set_current_state(ReminderDetail(reminder_id=original_reminder_id))
            else:
                self.load_root_state()

__init__(*args, **kwargs)

Initialize the reminder app and load the root navigation state.

Parameters:

Name Type Description Default
*args Any

Positional arguments passed to ReminderApp.

()
**kwargs Any

Keyword arguments passed to ReminderApp.

{}
Source code in pare/apps/reminder/app.py
26
27
28
29
30
31
32
33
34
def __init__(self, *args: Any, **kwargs: Any) -> None:
    """Initialize the reminder app and load the root navigation state.

    Args:
        *args: Positional arguments passed to ReminderApp.
        **kwargs: Keyword arguments passed to ReminderApp.
    """
    super().__init__(*args, **kwargs)
    self.load_root_state()

create_root_state()

Create and return the root navigation state.

Returns:

Type Description
ReminderList

The initial ReminderList state.

Source code in pare/apps/reminder/app.py
36
37
38
39
40
41
42
def create_root_state(self) -> ReminderList:
    """Create and return the root navigation state.

    Returns:
        The initial ReminderList state.
    """
    return ReminderList()

get_reminder_with_id(reminder_id)

Retrieve a reminder by its ID.

Parameters:

Name Type Description Default
reminder_id str

The ID of the reminder to retrieve.

required

Returns:

Type Description
Reminder

The Reminder object corresponding to the given ID.

Raises:

Type Description
KeyError

If the reminder ID does not exist.

Source code in pare/apps/reminder/app.py
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
def get_reminder_with_id(self, reminder_id: str) -> Reminder:
    """Retrieve a reminder by its ID.

    Args:
        reminder_id: The ID of the reminder to retrieve.

    Returns:
        The Reminder object corresponding to the given ID.

    Raises:
        KeyError: If the reminder ID does not exist.
    """
    if reminder_id not in self.reminders:
        raise KeyError(f"Reminder {reminder_id} not found.")
    return self.reminders[reminder_id]

handle_state_transition(event)

Update navigation state after a reminder operation completes.

Parameters:

Name Type Description Default
event CompletedEvent

Completed event containing tool invocation information.

required
Source code in pare/apps/reminder/app.py
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
def handle_state_transition(self, event: CompletedEvent) -> None:
    """Update navigation state after a reminder operation completes.

    Args:
        event: Completed event containing tool invocation information.
    """
    current_state = self.current_state
    fname = event.function_name()

    if current_state is None or fname is None:
        return

    action = event.action
    args: dict[str, Any] = action.args if action and hasattr(action, "args") else {}

    metadata = event.metadata

    if isinstance(current_state, ReminderList):
        self._handle_list_transition(fname, args)
    elif isinstance(current_state, ReminderDetail):
        reminder_id = current_state.reminder_id
        self._handle_detail_transition(fname, reminder_id)
    elif isinstance(current_state, EditReminder):
        # EditReminder state can be reached from both creating a new reminder and editing an existing reminder.
        # If we are editing an existing reminder, we use the current reminder ID to navigate back to the ReminderDetail state.
        # Whereas, if we are creating a new reminder, there is no current reminder ID and we get the ID from the metadata after saving.
        saved_reminder_id = getattr(metadata, "return_value", None) if metadata else None
        original_reminder_id = current_state.reminder_id
        self._handle_edit_transition(
            fname, saved_reminder_id=saved_reminder_id, original_reminder_id=original_reminder_id
        )

update_reminder(reminder_id, title, description, due_datetime, repetition_unit, repetition_value)

Update an existing reminder and regenerate its repetitions.

Parameters:

Name Type Description Default
reminder_id str

ID of the reminder to update.

required
title str

Updated title.

required
description str

Updated description.

required
due_datetime str

Updated due datetime in "YYYY-MM-DD HH:MM:SS" format.

required
repetition_unit str | None

Repetition unit (e.g., "day", "week"), or None.

required
repetition_value int | None

Repetition interval value, or None.

required

Returns:

Name Type Description
str str

The reminder ID after update.

Raises:

Type Description
ValueError

If the reminder ID does not exist.

Source code in pare/apps/reminder/app.py
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
@app_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def update_reminder(
    self,
    reminder_id: str,
    title: str,
    description: str,
    due_datetime: str,
    repetition_unit: str | None,
    repetition_value: int | None,
) -> str:
    """Update an existing reminder and regenerate its repetitions.

    Args:
        reminder_id: ID of the reminder to update.
        title: Updated title.
        description: Updated description.
        due_datetime: Updated due datetime in "YYYY-MM-DD HH:MM:SS" format.
        repetition_unit: Repetition unit (e.g., "day", "week"), or None.
        repetition_value: Repetition interval value, or None.

    Returns:
        str: The reminder ID after update.

    Raises:
        ValueError: If the reminder ID does not exist.
    """
    if reminder_id not in self.reminders:
        raise ValueError(f"Reminder {reminder_id} not found.")

    dt = datetime.strptime(due_datetime, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC)

    reminder = self.reminders[reminder_id]
    reminder.title = title
    reminder.description = description
    reminder.due_datetime = dt
    reminder.repetition_unit = repetition_unit
    reminder.repetition_value = repetition_value

    base_id = reminder_id.split("_rep_")[0]
    to_delete = [k for k in self.reminders if k.startswith(f"{base_id}_rep_")]
    for k in to_delete:
        del self.reminders[k]

    next_id = reminder_id
    count = 0
    while repetition_unit and next_id and count < self.max_reminder_repetitions:
        next_id = self.add_reminder_repetition(next_id)
        if next_id:
            count += 1

    return reminder_id

StatefulShoppingApp

Bases: StatefulApp, ShoppingApp

Shopping app with PARE-aware navigation.

Source code in pare/apps/shopping/app.py
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
class StatefulShoppingApp(StatefulApp, ShoppingApp):
    """Shopping app with PARE-aware navigation."""

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        """Initialise shopping app with root state."""
        super().__init__(*args, **kwargs)
        self.load_root_state()

    @type_check
    @data_tool()
    def add_order(
        self,
        order_id: str,
        order_status: str,
        order_date: float,
        order_total: float,
        item_id: str,
        quantity: int,
    ) -> str:
        """Add an order (used for scenario seeding).

        The upstream `are` ShoppingApp currently returns extra keys (e.g. `name`,
        `product_id`) from `_get_item()`, but its `CartItem` dataclass does not
        accept them. We defensively filter to the fields `CartItem` supports.

        Args:
            order_id: The ID of the order.
            order_status: The status of the order.
            order_date: The date of the order as a timestamp.
            order_total: The total amount of the order.
            item_id: The ID of the item to add to the order.
            quantity: The quantity of the item to add to the order.

        Returns:
            str: The ID of the created order.
        """
        item_dict = self._get_item(item_id)
        if not item_dict:
            raise ValueError("Item does not exist")

        cart_item = CartItem(
            item_id=item_dict["item_id"],
            quantity=quantity,
            price=item_dict["price"],
            available=item_dict.get("available", True),
            options=item_dict.get("options", {}),
        )
        self.orders[order_id] = Order(
            order_status=order_status,
            order_date=order_date,
            order_total=order_total,
            order_id=order_id,
            order_items={item_id: cart_item},
        )
        return order_id

    @type_check
    @data_tool()
    def add_order_multiple_items(
        self,
        order_id: str,
        order_status: str,
        order_date: float,
        order_total: float,
        items: dict[str, int],
    ) -> str:
        """Add an order with multiple items (used for scenario seeding).

        The upstream `are` ShoppingApp currently returns extra keys (e.g. `name`,
        `product_id`) from `_get_item()`, but its `CartItem` dataclass does not
        accept them. We defensively filter to the fields `CartItem` supports.

        Args:
            order_id: The ID of the order.
            order_status: The status of the order.
            order_date: The date of the order as a timestamp.
            order_total: The total amount of the order.
            items: A dictionary mapping item IDs to quantities.

        Returns:
            str: The ID of the created order.
        """
        order_items: dict[str, CartItem] = {}
        for item_id, quantity in items.items():
            item_dict = self._get_item(item_id)
            if not item_dict:
                raise ValueError(f"Item {item_id} does not exist")

            cart_item = CartItem(
                item_id=item_dict["item_id"],
                quantity=quantity,
                price=item_dict["price"],
                available=item_dict.get("available", True),
                options=item_dict.get("options", {}),
            )
            order_items[item_id] = cart_item

        self.orders[order_id] = Order(
            order_status=order_status,
            order_date=order_date,
            order_total=order_total,
            order_id=order_id,
            order_items=order_items,
        )
        return order_id

    def handle_state_transition(self, event: CompletedEvent) -> None:
        """Update navigation state based on completed operations."""
        current_state = self.current_state
        function_name = event.function_name()

        if current_state is None or function_name is None:
            return

        action = event.action
        args = action.resolved_args or action.args

        if isinstance(current_state, ShoppingHome):
            self._handle_home_transition(function_name, args, event)
            return

        if isinstance(current_state, ProductDetail):
            self._handle_product_transition(function_name, args, event)
            return

        if isinstance(current_state, VariantDetail):
            self._handle_variant_transition(function_name, args, event)
            return

        if isinstance(current_state, CartView):
            self._handle_cart_transition(function_name, args, event)
            return

        if isinstance(current_state, OrderListView):
            self._handle_order_list_transition(function_name, args, event)
            return

        if isinstance(current_state, OrderDetailView):
            self._handle_order_detail_transition(function_name, args, event)

    def _handle_home_transition(
        self,
        function_name: str,
        args: dict[str, object],
        event: CompletedEvent,
    ) -> None:
        """Handle transitions from ShoppingHome."""
        if function_name in {"view_product", "get_product", "get_product_details"}:
            pid = args.get("product_id")
            if isinstance(pid, str):
                self.set_current_state(ProductDetail(product_id=pid))
            return

        if function_name in {"get_item", "get_item_details", "_get_item"}:
            iid = args.get("item_id")
            if isinstance(iid, str):
                self.set_current_state(VariantDetail(item_id=iid))
            return

        if function_name in {"view_cart", "add_to_cart", "list_cart", "get_cart"}:
            self.set_current_state(CartView())
            return

        if function_name == "list_orders":
            self.set_current_state(OrderListView())

    def _handle_product_transition(
        self,
        function_name: str,
        args: dict[str, object],
        event: CompletedEvent,
    ) -> None:
        """Handle transitions from ProductDetail."""
        if function_name in {"view_variant", "get_item", "get_item_details", "_get_item"}:
            iid = args.get("item_id")
            if isinstance(iid, str):
                self.set_current_state(VariantDetail(item_id=iid))
            return

        if function_name == "add_to_cart":
            self.set_current_state(CartView())

    def _handle_variant_transition(
        self,
        function_name: str,
        args: dict[str, object],
        event: CompletedEvent,
    ) -> None:
        """Handle transitions from VariantDetail."""
        if function_name == "add_to_cart":
            self.set_current_state(CartView())

    def _handle_cart_transition(
        self,
        function_name: str,
        args: dict[str, object],
        event: CompletedEvent,
    ) -> None:
        """Handle transitions from CartView."""
        if function_name == "checkout":
            order_id = self._order_id_from_event(event)
            if isinstance(order_id, str):
                self.set_current_state(OrderDetailView(order_id=order_id))
            return

        if function_name in {"remove_item", "remove_from_cart"}:
            return

    def _handle_order_list_transition(
        self,
        function_name: str,
        args: dict[str, object],
        event: CompletedEvent,
    ) -> None:
        """Handle transitions from OrderListView."""
        if function_name in {"view_order", "get_order_details"}:
            oid = args.get("order_id")
            if isinstance(oid, str):
                self.set_current_state(OrderDetailView(order_id=oid))

    def _handle_order_detail_transition(
        self,
        function_name: str,
        args: dict[str, object],
        event: CompletedEvent,
    ) -> None:
        """Handle transitions from OrderDetailView."""
        return None

    @staticmethod
    def _order_id_from_event(event: CompletedEvent) -> str | None:
        """Extract order_id from event return payload."""
        if hasattr(event, "_return_value") and event._return_value:
            rv = event._return_value
            if isinstance(rv, str):
                return rv
            if isinstance(rv, dict):
                val = rv.get("order_id")
                return val if isinstance(val, str) else None

        meta = event.metadata.return_value if event.metadata else None
        if isinstance(meta, str):
            return meta
        if isinstance(meta, dict):
            val = meta.get("order_id")
            return val if isinstance(val, str) else None

        return None

    def create_root_state(self) -> ShoppingHome:
        """Return root navigation state."""
        return ShoppingHome()

    def get_item(self, item_id: str) -> dict[str, object]:
        """Wrapper for _get_item for compatibility."""
        return self._get_item(item_id)

    def get_cart(self) -> dict[str, object]:
        """Wrapper for list_cart() used by states."""
        return self.list_cart()

__init__(*args, **kwargs)

Initialise shopping app with root state.

Source code in pare/apps/shopping/app.py
28
29
30
31
def __init__(self, *args: Any, **kwargs: Any) -> None:
    """Initialise shopping app with root state."""
    super().__init__(*args, **kwargs)
    self.load_root_state()

add_order(order_id, order_status, order_date, order_total, item_id, quantity)

Add an order (used for scenario seeding).

The upstream are ShoppingApp currently returns extra keys (e.g. name, product_id) from _get_item(), but its CartItem dataclass does not accept them. We defensively filter to the fields CartItem supports.

Parameters:

Name Type Description Default
order_id str

The ID of the order.

required
order_status str

The status of the order.

required
order_date float

The date of the order as a timestamp.

required
order_total float

The total amount of the order.

required
item_id str

The ID of the item to add to the order.

required
quantity int

The quantity of the item to add to the order.

required

Returns:

Name Type Description
str str

The ID of the created order.

Source code in pare/apps/shopping/app.py
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
@type_check
@data_tool()
def add_order(
    self,
    order_id: str,
    order_status: str,
    order_date: float,
    order_total: float,
    item_id: str,
    quantity: int,
) -> str:
    """Add an order (used for scenario seeding).

    The upstream `are` ShoppingApp currently returns extra keys (e.g. `name`,
    `product_id`) from `_get_item()`, but its `CartItem` dataclass does not
    accept them. We defensively filter to the fields `CartItem` supports.

    Args:
        order_id: The ID of the order.
        order_status: The status of the order.
        order_date: The date of the order as a timestamp.
        order_total: The total amount of the order.
        item_id: The ID of the item to add to the order.
        quantity: The quantity of the item to add to the order.

    Returns:
        str: The ID of the created order.
    """
    item_dict = self._get_item(item_id)
    if not item_dict:
        raise ValueError("Item does not exist")

    cart_item = CartItem(
        item_id=item_dict["item_id"],
        quantity=quantity,
        price=item_dict["price"],
        available=item_dict.get("available", True),
        options=item_dict.get("options", {}),
    )
    self.orders[order_id] = Order(
        order_status=order_status,
        order_date=order_date,
        order_total=order_total,
        order_id=order_id,
        order_items={item_id: cart_item},
    )
    return order_id

add_order_multiple_items(order_id, order_status, order_date, order_total, items)

Add an order with multiple items (used for scenario seeding).

The upstream are ShoppingApp currently returns extra keys (e.g. name, product_id) from _get_item(), but its CartItem dataclass does not accept them. We defensively filter to the fields CartItem supports.

Parameters:

Name Type Description Default
order_id str

The ID of the order.

required
order_status str

The status of the order.

required
order_date float

The date of the order as a timestamp.

required
order_total float

The total amount of the order.

required
items dict[str, int]

A dictionary mapping item IDs to quantities.

required

Returns:

Name Type Description
str str

The ID of the created order.

Source code in pare/apps/shopping/app.py
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
@type_check
@data_tool()
def add_order_multiple_items(
    self,
    order_id: str,
    order_status: str,
    order_date: float,
    order_total: float,
    items: dict[str, int],
) -> str:
    """Add an order with multiple items (used for scenario seeding).

    The upstream `are` ShoppingApp currently returns extra keys (e.g. `name`,
    `product_id`) from `_get_item()`, but its `CartItem` dataclass does not
    accept them. We defensively filter to the fields `CartItem` supports.

    Args:
        order_id: The ID of the order.
        order_status: The status of the order.
        order_date: The date of the order as a timestamp.
        order_total: The total amount of the order.
        items: A dictionary mapping item IDs to quantities.

    Returns:
        str: The ID of the created order.
    """
    order_items: dict[str, CartItem] = {}
    for item_id, quantity in items.items():
        item_dict = self._get_item(item_id)
        if not item_dict:
            raise ValueError(f"Item {item_id} does not exist")

        cart_item = CartItem(
            item_id=item_dict["item_id"],
            quantity=quantity,
            price=item_dict["price"],
            available=item_dict.get("available", True),
            options=item_dict.get("options", {}),
        )
        order_items[item_id] = cart_item

    self.orders[order_id] = Order(
        order_status=order_status,
        order_date=order_date,
        order_total=order_total,
        order_id=order_id,
        order_items=order_items,
    )
    return order_id

create_root_state()

Return root navigation state.

Source code in pare/apps/shopping/app.py
274
275
276
def create_root_state(self) -> ShoppingHome:
    """Return root navigation state."""
    return ShoppingHome()

get_cart()

Wrapper for list_cart() used by states.

Source code in pare/apps/shopping/app.py
282
283
284
def get_cart(self) -> dict[str, object]:
    """Wrapper for list_cart() used by states."""
    return self.list_cart()

get_item(item_id)

Wrapper for _get_item for compatibility.

Source code in pare/apps/shopping/app.py
278
279
280
def get_item(self, item_id: str) -> dict[str, object]:
    """Wrapper for _get_item for compatibility."""
    return self._get_item(item_id)

handle_state_transition(event)

Update navigation state based on completed operations.

Source code in pare/apps/shopping/app.py
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
def handle_state_transition(self, event: CompletedEvent) -> None:
    """Update navigation state based on completed operations."""
    current_state = self.current_state
    function_name = event.function_name()

    if current_state is None or function_name is None:
        return

    action = event.action
    args = action.resolved_args or action.args

    if isinstance(current_state, ShoppingHome):
        self._handle_home_transition(function_name, args, event)
        return

    if isinstance(current_state, ProductDetail):
        self._handle_product_transition(function_name, args, event)
        return

    if isinstance(current_state, VariantDetail):
        self._handle_variant_transition(function_name, args, event)
        return

    if isinstance(current_state, CartView):
        self._handle_cart_transition(function_name, args, event)
        return

    if isinstance(current_state, OrderListView):
        self._handle_order_list_transition(function_name, args, event)
        return

    if isinstance(current_state, OrderDetailView):
        self._handle_order_detail_transition(function_name, args, event)

Core Framework

AppState

Bases: ABC

Base class for navigation states.

Each state represents a screen/view of the app on the mobile phone. Navigation states form an MDP where each state has specific available actions.

Note: Different from Meta AREs data state (JSON)

Source code in pare/apps/core.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
class AppState(ABC):
    """Base class for navigation states.

    Each state represents a screen/view of the app on the mobile phone.
    Navigation states form an MDP where each state has specific available actions.

    Note: Different from Meta AREs data state (JSON)
    """

    # ! TODO: We should also add a name here.

    def __init__(self) -> None:
        """Initialize the app tools."""
        self._app: App | None = None
        self._cached_tools: list[AppTool] | None = None

    def bind_to_app(self, app: App) -> None:
        """Bind this state to an app (late binding).

        Called automatically by StatefulApp.set_current_state().

        Args:
            app: The app this state belongs to
        """
        self._app = app

    @property
    def app(self) -> App:
        """Get the app this state is bound to."""
        return self._app

    def get_available_actions(self) -> list[AppTool]:
        """Get user tools (actions) available from this navigation state.

        These are valid actions for the user in this App MDP from this state.

        Returns:
            list[AppTool]: A list of AppTool objects representing the available actions.
        """
        if self._cached_tools is None:
            tools = []
            for _, method in inspect.getmembers(self, predicate=inspect.ismethod):
                if hasattr(method, "_is_user_tool"):  # check for user tool decorator
                    # IMPORTANT: For state-bound methods, extract the unbound function
                    # and explicitly set class_instance to the state instance.
                    # `method` is a bound method, but AppTool expects an unbound function
                    # so it can pass class_instance as the first argument.
                    unbound_func = method.__func__
                    tool = build_tool(self._app, unbound_func)
                    # Override class_instance to be the state instance, not the app
                    tool.class_instance = self
                    tools.append(tool)
            self._cached_tools = tools

        return self._cached_tools

    @abstractmethod
    def on_enter(self) -> None:
        """Called when transitioning into this state.

        Override to handle state initialization, load data, update anything, etc. We don't know if this is useful yet.
        """
        raise NotImplementedError("Subclasses must implement on_enter")

    @abstractmethod
    def on_exit(self) -> None:
        """Called when transitioning out of this state.

        Override to handle state cleanup, save data, etc. We don't know if this is useful yet.
        """
        raise NotImplementedError("Subclasses must implement on_exit")

app property

Get the app this state is bound to.

__init__()

Initialize the app tools.

Source code in pare/apps/core.py
31
32
33
34
def __init__(self) -> None:
    """Initialize the app tools."""
    self._app: App | None = None
    self._cached_tools: list[AppTool] | None = None

bind_to_app(app)

Bind this state to an app (late binding).

Called automatically by StatefulApp.set_current_state().

Parameters:

Name Type Description Default
app App

The app this state belongs to

required
Source code in pare/apps/core.py
36
37
38
39
40
41
42
43
44
def bind_to_app(self, app: App) -> None:
    """Bind this state to an app (late binding).

    Called automatically by StatefulApp.set_current_state().

    Args:
        app: The app this state belongs to
    """
    self._app = app

get_available_actions()

Get user tools (actions) available from this navigation state.

These are valid actions for the user in this App MDP from this state.

Returns:

Type Description
list[AppTool]

list[AppTool]: A list of AppTool objects representing the available actions.

Source code in pare/apps/core.py
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
def get_available_actions(self) -> list[AppTool]:
    """Get user tools (actions) available from this navigation state.

    These are valid actions for the user in this App MDP from this state.

    Returns:
        list[AppTool]: A list of AppTool objects representing the available actions.
    """
    if self._cached_tools is None:
        tools = []
        for _, method in inspect.getmembers(self, predicate=inspect.ismethod):
            if hasattr(method, "_is_user_tool"):  # check for user tool decorator
                # IMPORTANT: For state-bound methods, extract the unbound function
                # and explicitly set class_instance to the state instance.
                # `method` is a bound method, but AppTool expects an unbound function
                # so it can pass class_instance as the first argument.
                unbound_func = method.__func__
                tool = build_tool(self._app, unbound_func)
                # Override class_instance to be the state instance, not the app
                tool.class_instance = self
                tools.append(tool)
        self._cached_tools = tools

    return self._cached_tools

on_enter() abstractmethod

Called when transitioning into this state.

Override to handle state initialization, load data, update anything, etc. We don't know if this is useful yet.

Source code in pare/apps/core.py
76
77
78
79
80
81
82
@abstractmethod
def on_enter(self) -> None:
    """Called when transitioning into this state.

    Override to handle state initialization, load data, update anything, etc. We don't know if this is useful yet.
    """
    raise NotImplementedError("Subclasses must implement on_enter")

on_exit() abstractmethod

Called when transitioning out of this state.

Override to handle state cleanup, save data, etc. We don't know if this is useful yet.

Source code in pare/apps/core.py
84
85
86
87
88
89
90
@abstractmethod
def on_exit(self) -> None:
    """Called when transitioning out of this state.

    Override to handle state cleanup, save data, etc. We don't know if this is useful yet.
    """
    raise NotImplementedError("Subclasses must implement on_exit")

StatefulApp

Bases: App

Base class for a stateful app.

This class implements the basic functionality needed for a finite state machine based mobile app.

Source code in pare/apps/core.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
class StatefulApp(App):
    """Base class for a stateful app.

    This class implements the basic functionality needed for a finite state machine based mobile app.
    """

    name: str | None
    description: str | None = None

    def __init__(self, name: str | None = None, *args: Any, **kwargs: Any) -> None:
        """Initialize the stateful app.

        Args:
            name: The name of the app.
            args: The arguments to pass to the app.
            kwargs: The keyword arguments to pass to the app.
        """
        desired_name = name
        super().__init__(name, *args, **kwargs)
        # Workaround for Meta-ARE dataclass apps with __post_init__ that call super().__init__(self.name)
        # where self.name is None (dataclass default), causing App.__init__ to use class name as fallback.
        # We restore the intended name after parent initialization completes.
        actual_name = cast("str | None", getattr(self, "name", None))
        if desired_name is not None and actual_name != desired_name:
            self.name = desired_name
        self.current_state: AppState | None = None
        # Navigation stack is used to track the history of the state transitions.
        # The first state is always the initial state of the app.
        self.navigation_stack: list[AppState] = []

    def set_current_state(self, app_state: AppState) -> None:
        """Set the current state of the app.

        This is called by `handle_state_transition` to update the current state of the app. This function will
        1. Binds the state to app
        2. Calls on_exit on the old state
        3. Pushes the old state to the navigation stack (for go_back())
        4. Calls on_enter on the new state (initialization/data loading)
        5. Sets the current state to the new state

        Args:
            app_state: The state to set.
        """
        if app_state.app is None:
            app_state.bind_to_app(self)  # Late binding: app injects itself into state

        if self.current_state is not None:
            self.current_state.on_exit()
            self.navigation_stack.append(self.current_state)

        app_state.on_enter()
        self.current_state = app_state

    @abstractmethod
    def create_root_state(self) -> AppState:
        """Return a freshly constructed root navigation state."""

    def load_root_state(self) -> None:
        """Reset the app to its root navigation state."""
        self.set_current_state(self.create_root_state())
        self.navigation_stack.clear()

    def reset_to_root(self) -> str:
        """Reset to the root navigation state and report the new view."""
        self.load_root_state()
        state_name = type(self.current_state).__name__ if self.current_state else "UnknownState"
        return f"Reset to {state_name}"

    @user_tool()
    @pare_event_registered()
    def go_back(self) -> str:
        """Navigate back to the previous state of the app.

        Returns:
            str: A message indicating the navigation back action.
        """
        if not self.navigation_stack:
            return "Already at the initial state"

        self.current_state = self.navigation_stack.pop()
        return f"Navigated back to the state {self.current_state.__class__.__name__}"

    def get_user_tools(self) -> list[AppTool]:
        """Get user tools from the current state of the app.

        User tools are state dependent and manage context. Each state will only enable
        some of the available actions in the app.

        Returns:
            list[AppTool]: A list of AppTool objects representing the available user tools.
        """
        tools = []
        if self.current_state is not None:
            tools.extend(self.current_state.get_available_actions())
        # Add go_back tool if navigation stack is not empty
        if self.navigation_stack:
            tools.append(build_tool(self, self.go_back))
        return tools

    # ! NOTE: Why do we need to get meta are user tools?
    def get_meta_are_user_tools(self) -> list[Tool]:
        """Return Meta ARE-compatible tool adapters for the current navigation state."""
        from are.simulation.tool_utils import AppToolAdapter  # Use native Meta ARE adapter

        adapters: list[Tool] = []
        if self.current_state is not None:
            for app_tool in self.current_state.get_available_actions():
                adapters.append(AppToolAdapter(app_tool))

        if self.navigation_stack:
            adapters.append(AppToolAdapter(build_tool(self, self.go_back)))
        return adapters

    def get_tools(self) -> list[AppTool]:
        """Get the tools of the app."""
        return super().get_tools()

    def handle_state_transition(self, event: CompletedEvent) -> None:
        """Update the current state of the app based on the tool events.

        This implements the state transition function T(s,a) -> s' for app specific transitions.

        Args:
            event: The completed event.
        """
        raise NotImplementedError("Subclasses must implement handle_state_transition")

    def get_state_graph(self) -> dict[str, list[str]]:
        """Get the state graph of the app.

        TODO: implement after MVP

        Returns:
            dict[str, list[str]]: The state graph of the app.
        """
        raise NotImplementedError("Subclasses must implement get_state_graph")

    def get_reachable_states(self, from_state: AppState) -> list[type[AppState]]:
        """Get the reachable states from the given state.

        TODO: implement after MVP

        Args:
            from_state: The state to get the reachable states from.

        Returns:
            list[type[AppState]]: The reachable states from the given state.
        """
        raise NotImplementedError("Subclasses must implement get_reachable_states")

    # NOTE: Extend Meta-ARE tool discovery to support PARE state tools and event-only tools
    def get_tools_with_attribute(
        self, attribute: ToolAttributeName | None, tool_type: ToolType | None
    ) -> list[AppTool]:
        """Return tools by attribute/tool type, extended for PARE stateful apps.

        - If tool_type/attribute correspond to USER tools, include state-bound user tools.
        - Otherwise, defer to Meta-ARE base implementation.
        - Special case: if both tool_type and attribute are None, return "event-only" tools:
          methods decorated with @event_registered-like decorator but without any of
          @app_tool, @user_tool, @env_tool, @data_tool. Detected via AST across the MRO.
        """
        # Special case: event-only tools discovery via AST (no tool decorator present)
        if tool_type is None and attribute is None:
            return self._get_event_only_tools_via_ast()

        # Include state-bound user tools for USER queries
        if tool_type == ToolType.USER and attribute == ToolAttributeName.USER:
            if self.current_state is None:
                return []
            # AppState already builds AppTool objects with class_instance bound to state
            return list(self.current_state.get_available_actions())

        # Fallback to base Meta-ARE behavior for APP/ENV/DATA (and any other cases)
        return super().get_tools_with_attribute(attribute=attribute, tool_type=tool_type)

    # Internal helpers
    def _get_event_only_tools_via_ast(self) -> list[AppTool]:  # noqa: C901
        """Discover event-registered methods without tool decorators across the class MRO."""
        discovered_tools: list[AppTool] = []
        processed_function_names: set[str] = set()

        # Names of decorators to include/exclude (base name, without module prefixes)
        include_event_names = {"event_registered", "pare_event_registered"}
        exclude_tool_names = {"app_tool", "user_tool", "env_tool", "data_tool"}

        def _decorator_base_name(dec: ast.expr) -> str | None:
            # Extract the base name of a decorator (handles Name, Attribute, Call)
            node = dec
            if isinstance(node, ast.Call):
                node = node.func
            if isinstance(node, ast.Name):
                return node.id
            if isinstance(node, ast.Attribute):
                # Get last attribute part (e.g., tool_utils.user_tool -> user_tool)
                return node.attr
            return None

        # Traverse MRO to include inherited methods (ARE base apps)
        for cls in inspect.getmro(self.__class__):
            # Stop once we hit the framework base App class
            if cls is App or cls is ABC or cls is object:
                continue

            try:
                source_file = inspect.getsourcefile(cls) or inspect.getfile(cls)
                if not source_file:
                    continue
                source_text = inspect.getsource(cls)
            except Exception:  # noqa: S112
                # Skip classes without retrievable source (e.g., C extensions)
                continue

            try:
                # Parse the full module, then isolate the class body where possible
                module_source = None
                try:
                    with open(source_file, encoding="utf-8") as f:
                        module_source = f.read()
                except Exception:
                    module_source = source_text

                tree = ast.parse(module_source or source_text)
            except SyntaxError:
                continue

            # Find the class definition node matching this cls
            class_nodes = [n for n in ast.walk(tree) if isinstance(n, ast.ClassDef) and n.name == cls.__name__]
            if not class_nodes:
                continue

            for class_node in class_nodes:
                for node in class_node.body:
                    if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                        func_name = node.name
                        if func_name in processed_function_names:
                            continue

                        decorator_names = {name for d in node.decorator_list if (name := _decorator_base_name(d))}
                        # Must include event decorator, and must NOT include any tool decorator
                        has_event = any(d in include_event_names for d in decorator_names)
                        has_tool = any(d in exclude_tool_names for d in decorator_names)
                        if not has_event or has_tool:
                            continue

                        # Retrieve the bound method from the instance
                        func_obj = getattr(self, func_name, None)
                        if func_obj is None or not callable(func_obj):
                            continue

                        # Build the AppTool (skip if missing docstring or invalid)
                        try:
                            tool = build_tool(self, func_obj)
                        except Exception:  # noqa: S112
                            # Skip functions that cannot be converted (e.g., missing docstrings)
                            continue

                        discovered_tools.append(tool)
                        processed_function_names.add(func_name)

        return discovered_tools

__init__(name=None, *args, **kwargs)

Initialize the stateful app.

Parameters:

Name Type Description Default
name str | None

The name of the app.

None
args Any

The arguments to pass to the app.

()
kwargs Any

The keyword arguments to pass to the app.

{}
Source code in pare/apps/core.py
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
def __init__(self, name: str | None = None, *args: Any, **kwargs: Any) -> None:
    """Initialize the stateful app.

    Args:
        name: The name of the app.
        args: The arguments to pass to the app.
        kwargs: The keyword arguments to pass to the app.
    """
    desired_name = name
    super().__init__(name, *args, **kwargs)
    # Workaround for Meta-ARE dataclass apps with __post_init__ that call super().__init__(self.name)
    # where self.name is None (dataclass default), causing App.__init__ to use class name as fallback.
    # We restore the intended name after parent initialization completes.
    actual_name = cast("str | None", getattr(self, "name", None))
    if desired_name is not None and actual_name != desired_name:
        self.name = desired_name
    self.current_state: AppState | None = None
    # Navigation stack is used to track the history of the state transitions.
    # The first state is always the initial state of the app.
    self.navigation_stack: list[AppState] = []

create_root_state() abstractmethod

Return a freshly constructed root navigation state.

Source code in pare/apps/core.py
146
147
148
@abstractmethod
def create_root_state(self) -> AppState:
    """Return a freshly constructed root navigation state."""

get_meta_are_user_tools()

Return Meta ARE-compatible tool adapters for the current navigation state.

Source code in pare/apps/core.py
193
194
195
196
197
198
199
200
201
202
203
204
def get_meta_are_user_tools(self) -> list[Tool]:
    """Return Meta ARE-compatible tool adapters for the current navigation state."""
    from are.simulation.tool_utils import AppToolAdapter  # Use native Meta ARE adapter

    adapters: list[Tool] = []
    if self.current_state is not None:
        for app_tool in self.current_state.get_available_actions():
            adapters.append(AppToolAdapter(app_tool))

    if self.navigation_stack:
        adapters.append(AppToolAdapter(build_tool(self, self.go_back)))
    return adapters

get_reachable_states(from_state)

Get the reachable states from the given state.

TODO: implement after MVP

Parameters:

Name Type Description Default
from_state AppState

The state to get the reachable states from.

required

Returns:

Type Description
list[type[AppState]]

list[type[AppState]]: The reachable states from the given state.

Source code in pare/apps/core.py
230
231
232
233
234
235
236
237
238
239
240
241
def get_reachable_states(self, from_state: AppState) -> list[type[AppState]]:
    """Get the reachable states from the given state.

    TODO: implement after MVP

    Args:
        from_state: The state to get the reachable states from.

    Returns:
        list[type[AppState]]: The reachable states from the given state.
    """
    raise NotImplementedError("Subclasses must implement get_reachable_states")

get_state_graph()

Get the state graph of the app.

TODO: implement after MVP

Returns:

Type Description
dict[str, list[str]]

dict[str, list[str]]: The state graph of the app.

Source code in pare/apps/core.py
220
221
222
223
224
225
226
227
228
def get_state_graph(self) -> dict[str, list[str]]:
    """Get the state graph of the app.

    TODO: implement after MVP

    Returns:
        dict[str, list[str]]: The state graph of the app.
    """
    raise NotImplementedError("Subclasses must implement get_state_graph")

get_tools()

Get the tools of the app.

Source code in pare/apps/core.py
206
207
208
def get_tools(self) -> list[AppTool]:
    """Get the tools of the app."""
    return super().get_tools()

get_tools_with_attribute(attribute, tool_type)

Return tools by attribute/tool type, extended for PARE stateful apps.

  • If tool_type/attribute correspond to USER tools, include state-bound user tools.
  • Otherwise, defer to Meta-ARE base implementation.
  • Special case: if both tool_type and attribute are None, return "event-only" tools: methods decorated with @event_registered-like decorator but without any of @app_tool, @user_tool, @env_tool, @data_tool. Detected via AST across the MRO.
Source code in pare/apps/core.py
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
def get_tools_with_attribute(
    self, attribute: ToolAttributeName | None, tool_type: ToolType | None
) -> list[AppTool]:
    """Return tools by attribute/tool type, extended for PARE stateful apps.

    - If tool_type/attribute correspond to USER tools, include state-bound user tools.
    - Otherwise, defer to Meta-ARE base implementation.
    - Special case: if both tool_type and attribute are None, return "event-only" tools:
      methods decorated with @event_registered-like decorator but without any of
      @app_tool, @user_tool, @env_tool, @data_tool. Detected via AST across the MRO.
    """
    # Special case: event-only tools discovery via AST (no tool decorator present)
    if tool_type is None and attribute is None:
        return self._get_event_only_tools_via_ast()

    # Include state-bound user tools for USER queries
    if tool_type == ToolType.USER and attribute == ToolAttributeName.USER:
        if self.current_state is None:
            return []
        # AppState already builds AppTool objects with class_instance bound to state
        return list(self.current_state.get_available_actions())

    # Fallback to base Meta-ARE behavior for APP/ENV/DATA (and any other cases)
    return super().get_tools_with_attribute(attribute=attribute, tool_type=tool_type)

get_user_tools()

Get user tools from the current state of the app.

User tools are state dependent and manage context. Each state will only enable some of the available actions in the app.

Returns:

Type Description
list[AppTool]

list[AppTool]: A list of AppTool objects representing the available user tools.

Source code in pare/apps/core.py
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
def get_user_tools(self) -> list[AppTool]:
    """Get user tools from the current state of the app.

    User tools are state dependent and manage context. Each state will only enable
    some of the available actions in the app.

    Returns:
        list[AppTool]: A list of AppTool objects representing the available user tools.
    """
    tools = []
    if self.current_state is not None:
        tools.extend(self.current_state.get_available_actions())
    # Add go_back tool if navigation stack is not empty
    if self.navigation_stack:
        tools.append(build_tool(self, self.go_back))
    return tools

go_back()

Navigate back to the previous state of the app.

Returns:

Name Type Description
str str

A message indicating the navigation back action.

Source code in pare/apps/core.py
161
162
163
164
165
166
167
168
169
170
171
172
173
@user_tool()
@pare_event_registered()
def go_back(self) -> str:
    """Navigate back to the previous state of the app.

    Returns:
        str: A message indicating the navigation back action.
    """
    if not self.navigation_stack:
        return "Already at the initial state"

    self.current_state = self.navigation_stack.pop()
    return f"Navigated back to the state {self.current_state.__class__.__name__}"

handle_state_transition(event)

Update the current state of the app based on the tool events.

This implements the state transition function T(s,a) -> s' for app specific transitions.

Parameters:

Name Type Description Default
event CompletedEvent

The completed event.

required
Source code in pare/apps/core.py
210
211
212
213
214
215
216
217
218
def handle_state_transition(self, event: CompletedEvent) -> None:
    """Update the current state of the app based on the tool events.

    This implements the state transition function T(s,a) -> s' for app specific transitions.

    Args:
        event: The completed event.
    """
    raise NotImplementedError("Subclasses must implement handle_state_transition")

load_root_state()

Reset the app to its root navigation state.

Source code in pare/apps/core.py
150
151
152
153
def load_root_state(self) -> None:
    """Reset the app to its root navigation state."""
    self.set_current_state(self.create_root_state())
    self.navigation_stack.clear()

reset_to_root()

Reset to the root navigation state and report the new view.

Source code in pare/apps/core.py
155
156
157
158
159
def reset_to_root(self) -> str:
    """Reset to the root navigation state and report the new view."""
    self.load_root_state()
    state_name = type(self.current_state).__name__ if self.current_state else "UnknownState"
    return f"Reset to {state_name}"

set_current_state(app_state)

Set the current state of the app.

This is called by handle_state_transition to update the current state of the app. This function will 1. Binds the state to app 2. Calls on_exit on the old state 3. Pushes the old state to the navigation stack (for go_back()) 4. Calls on_enter on the new state (initialization/data loading) 5. Sets the current state to the new state

Parameters:

Name Type Description Default
app_state AppState

The state to set.

required
Source code in pare/apps/core.py
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
def set_current_state(self, app_state: AppState) -> None:
    """Set the current state of the app.

    This is called by `handle_state_transition` to update the current state of the app. This function will
    1. Binds the state to app
    2. Calls on_exit on the old state
    3. Pushes the old state to the navigation stack (for go_back())
    4. Calls on_enter on the new state (initialization/data loading)
    5. Sets the current state to the new state

    Args:
        app_state: The state to set.
    """
    if app_state.app is None:
        app_state.bind_to_app(self)  # Late binding: app injects itself into state

    if self.current_state is not None:
        self.current_state.on_exit()
        self.navigation_stack.append(self.current_state)

    app_state.on_enter()
    self.current_state = app_state

PARE-specific tool decorators that extend Meta ARE functionality.

pare_event_registered(operation_type=OperationType.READ, event_type=EventType.USER)

PARE-specific event registration decorator that handles AppState instances.

This is an adaptation of Meta ARE's native @event_registered decorator to support PARE's AppState pattern where methods are defined on state classes that don't have direct access to self.name or self.time_manager, but instead access them via self.app.name and self.app.time_manager.

The decorator follows Meta ARE's event registration pattern but adapts it for: - AppState instances (which have self.app.name and self.app.time_manager) - StatefulApp instances (which have self.name and self.time_manager)

Parameters:

Name Type Description Default
operation_type OperationType

Whether this is a READ or WRITE operation

READ
event_type EventType

The type of event to generate (default: EventType.AGENT)

USER
Example

@user_tool() @pare_event_registered(operation_type=OperationType.WRITE) def forward(self, recipients: list[str]) -> str: with disable_events(): return self.app.forward_email(...)

Source code in pare/apps/tool_decorators.py
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
def pare_event_registered(
    operation_type: OperationType = OperationType.READ, event_type: EventType = EventType.USER
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
    """PARE-specific event registration decorator that handles AppState instances.

    This is an adaptation of Meta ARE's native @event_registered decorator to support
    PARE's AppState pattern where methods are defined on state classes that don't have
    direct access to `self.name` or `self.time_manager`, but instead access them via
    `self.app.name` and `self.app.time_manager`.

    The decorator follows Meta ARE's event registration pattern but adapts it for:
    - AppState instances (which have self.app.name and self.app.time_manager)
    - StatefulApp instances (which have self.name and self.time_manager)

    Args:
        operation_type: Whether this is a READ or WRITE operation
        event_type: The type of event to generate (default: EventType.AGENT)

    Example:
        @user_tool()
        @pare_event_registered(operation_type=OperationType.WRITE)
        def forward(self, recipients: list[str]) -> str:
            with disable_events():
                return self.app.forward_email(...)
    """

    def with_event(func: Callable[..., Any]) -> Callable[..., Any]:
        func.__event_registered__ = True  # type: ignore[attr-defined]
        func.__operation_type__ = operation_type  # type: ignore[attr-defined]

        @wraps(func)
        def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:  # noqa: ANN401
            # Only apply event registration if EventRegisterer is active
            if not EventRegisterer.is_active():
                return func(self, *args, **kwargs)

            # Get the app instance - handle both App (self) and AppState (self.app)
            app = self.app if hasattr(self, "app") else self

            # Create action ID using app name
            action_id = f"{app.name}.{func.__name__}-{uuid.uuid4()}"

            # Bind arguments
            bound_arguments = inspect.signature(func).bind(self, *args, **kwargs)
            bound_arguments.apply_defaults()
            func_args = bound_arguments.arguments

            # Create Action object
            action = Action(app=app, function=func, args=func_args, action_id=action_id, operation_type=operation_type)

            # Check if we're in capture mode
            if EventRegisterer.is_capture_mode():
                # Import here to avoid circular dependencies
                from are.simulation.types import Event

                # In capture mode, return an Event without executing
                return Event(event_id=f"{EventType.ENV.value}-{action_id}", event_type=EventType.ENV, action=action)
            else:
                # Import here to avoid circular dependencies
                from are.simulation.types import CompletedEvent, EventMetadata

                # Execute the function and capture result/exception
                event_metadata = EventMetadata()
                event_time = app.time_manager.time()

                try:
                    result = func(self, *args, **kwargs)
                    event_metadata.return_value = result
                except Exception as e:
                    event_metadata.exception = str(e)
                    event_metadata.exception_stack_trace = traceback.format_exc()
                    raise
                finally:
                    # Create and register the completed event
                    event = CompletedEvent(
                        event_id=f"{event_type.value}-{action_id}",
                        event_type=event_type,
                        action=action,
                        metadata=event_metadata,
                        event_time=event_time,
                    )
                    app.add_event(event)

                return result

        # Propagate AppTool metadata between original function and wrapper
        apptool = getattr(func, APPTOOL_ATTR_NAME, None)
        if apptool is not None:
            setattr(wrapper, APPTOOL_ATTR_NAME, apptool)

        # Add function to set AppTool metadata on both wrapper and original
        def set_apptool(app_tool_instance: Any) -> None:  # noqa: ANN401
            setattr(wrapper, APPTOOL_ATTR_NAME, app_tool_instance)
            setattr(func, APPTOOL_ATTR_NAME, app_tool_instance)

        wrapper.set_apptool = set_apptool  # type: ignore[attr-defined]

        return wrapper

    return with_event

System and Agent UI Apps

PARE extensions around the Meta-ARE system app.

HomeScreenSystemApp

Bases: SystemApp

System app that exposes user tools for switching contexts.

Source code in pare/apps/system.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
class HomeScreenSystemApp(SystemApp):
    """System app that exposes user tools for switching contexts."""

    def __init__(self, *args: object, **kwargs: object) -> None:
        """Initialise the system app (callbacks will be set by environment)."""
        super().__init__(*args, **kwargs)
        self._switch_app: Callable[[str], str] | None = None
        self._open_app: Callable[[str], str] | None = None
        self._go_home: Callable[[], str] | None = None

    def set_callbacks(
        self,
        switch_app_callback: Callable[[str], str],
        open_app_callback: Callable[[str], str],
        go_home_callback: Callable[[], str],
    ) -> None:
        """Set the navigation callbacks (called by environment after initialization)."""
        self._switch_app = switch_app_callback
        self._open_app = open_app_callback
        self._go_home = go_home_callback

    @user_tool()
    @pare_event_registered()
    def go_home(self) -> str:
        """Return to the home screen. This will allow the user to open a new app.

        Returns:
            str: A message indicating the home screen action.
        """
        if self._go_home is None:
            raise RuntimeError("Callbacks not set - environment must call set_callbacks() first")
        return self._go_home()

    @user_tool()
    @pare_event_registered()
    def open_app(self, app_name: str) -> str:
        """Open the requested app. If the app is already open and it is in background, then the phone will switch to it. If the app is not open, then a it is opened to the home page of that app.

        Args:
            app_name: The name of the app to open (case-sensitive). The app must be availabe in the environment.

        Returns:
            str: A message indicating the open app action.
        """
        if self._open_app is None:
            raise RuntimeError("Callbacks not set - environment must call set_callbacks() first")
        return self._open_app(app_name)

    @user_tool()
    @pare_event_registered()
    def switch_app(self, app_name: str) -> str:
        """Switch to the requested app and preserve the current app state.

        Args:
            app_name: The name of the app to switch to (case-sensitive). The app must be open and availabe in the environment.

        Returns:
            str: A message indicating the switch app action.
        """
        if self._switch_app is None:
            raise RuntimeError("Callbacks not set - environment must call set_callbacks() first")
        return self._switch_app(app_name)

__init__(*args, **kwargs)

Initialise the system app (callbacks will be set by environment).

Source code in pare/apps/system.py
18
19
20
21
22
23
def __init__(self, *args: object, **kwargs: object) -> None:
    """Initialise the system app (callbacks will be set by environment)."""
    super().__init__(*args, **kwargs)
    self._switch_app: Callable[[str], str] | None = None
    self._open_app: Callable[[str], str] | None = None
    self._go_home: Callable[[], str] | None = None

go_home()

Return to the home screen. This will allow the user to open a new app.

Returns:

Name Type Description
str str

A message indicating the home screen action.

Source code in pare/apps/system.py
36
37
38
39
40
41
42
43
44
45
46
@user_tool()
@pare_event_registered()
def go_home(self) -> str:
    """Return to the home screen. This will allow the user to open a new app.

    Returns:
        str: A message indicating the home screen action.
    """
    if self._go_home is None:
        raise RuntimeError("Callbacks not set - environment must call set_callbacks() first")
    return self._go_home()

open_app(app_name)

Open the requested app. If the app is already open and it is in background, then the phone will switch to it. If the app is not open, then a it is opened to the home page of that app.

Parameters:

Name Type Description Default
app_name str

The name of the app to open (case-sensitive). The app must be availabe in the environment.

required

Returns:

Name Type Description
str str

A message indicating the open app action.

Source code in pare/apps/system.py
48
49
50
51
52
53
54
55
56
57
58
59
60
61
@user_tool()
@pare_event_registered()
def open_app(self, app_name: str) -> str:
    """Open the requested app. If the app is already open and it is in background, then the phone will switch to it. If the app is not open, then a it is opened to the home page of that app.

    Args:
        app_name: The name of the app to open (case-sensitive). The app must be availabe in the environment.

    Returns:
        str: A message indicating the open app action.
    """
    if self._open_app is None:
        raise RuntimeError("Callbacks not set - environment must call set_callbacks() first")
    return self._open_app(app_name)

set_callbacks(switch_app_callback, open_app_callback, go_home_callback)

Set the navigation callbacks (called by environment after initialization).

Source code in pare/apps/system.py
25
26
27
28
29
30
31
32
33
34
def set_callbacks(
    self,
    switch_app_callback: Callable[[str], str],
    open_app_callback: Callable[[str], str],
    go_home_callback: Callable[[], str],
) -> None:
    """Set the navigation callbacks (called by environment after initialization)."""
    self._switch_app = switch_app_callback
    self._open_app = open_app_callback
    self._go_home = go_home_callback

switch_app(app_name)

Switch to the requested app and preserve the current app state.

Parameters:

Name Type Description Default
app_name str

The name of the app to switch to (case-sensitive). The app must be open and availabe in the environment.

required

Returns:

Name Type Description
str str

A message indicating the switch app action.

Source code in pare/apps/system.py
63
64
65
66
67
68
69
70
71
72
73
74
75
76
@user_tool()
@pare_event_registered()
def switch_app(self, app_name: str) -> str:
    """Switch to the requested app and preserve the current app state.

    Args:
        app_name: The name of the app to switch to (case-sensitive). The app must be open and availabe in the environment.

    Returns:
        str: A message indicating the switch app action.
    """
    if self._switch_app is None:
        raise RuntimeError("Callbacks not set - environment must call set_callbacks() first")
    return self._switch_app(app_name)

Proactive Agent User Interface with proposal management.

PAREAgentUserInterface

Bases: AgentUserInterface

Agent-user interface extended with proactive proposal acceptance and rejection support.

Adds tools which the user agent uses to accept or reject the proactive agent's proposal.

Source code in pare/apps/proactive_aui.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
class PAREAgentUserInterface(AgentUserInterface):
    """Agent-user interface extended with proactive proposal acceptance and rejection support.

    Adds tools which the user agent uses to accept or reject the proactive agent's proposal.
    """

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        """Initialize the proactive agent-user interface.

        Args:
            args: Arguments to pass to the app
            kwargs: Keyword arguments to pass to the app
        """
        super().__init__(*args, **kwargs)

    @type_check
    @user_tool()
    @event_registered(operation_type=OperationType.WRITE, event_type=EventType.USER)
    def accept_proposal(self, content: str = "") -> str:
        """User accepts the pending proactive proposal.

        Args:
            content: The content of the message to send to the agent

        Returns:
            The message ID that was generated for this message, can be used for tracking
        """
        with disable_events():
            return self.send_message_to_agent(content=content)

    @type_check
    @user_tool()
    @event_registered(operation_type=OperationType.WRITE, event_type=EventType.USER)
    def reject_proposal(self, content: str = "") -> str:
        """User rejects the pending proactive proposal.

        Args:
            content: The content of the message to send to the agent

        Returns:
            The message ID that was generated for this message, can be used for tracking
        """
        with disable_events():
            return self.send_message_to_agent(content=content)

    @type_check
    @app_tool()
    @event_registered(event_type=EventType.AGENT)
    def wait(self) -> str:
        """Observe and wait without taking action.

        Use this when you want to continue monitoring but don't have a specific
        proposal or message for the user yet.

        Returns:
            Confirmation that the agent is in observation mode.
        """
        return "Continuing to observe user activity."

__init__(*args, **kwargs)

Initialize the proactive agent-user interface.

Parameters:

Name Type Description Default
args Any

Arguments to pass to the app

()
kwargs Any

Keyword arguments to pass to the app

{}
Source code in pare/apps/proactive_aui.py
21
22
23
24
25
26
27
28
def __init__(self, *args: Any, **kwargs: Any) -> None:
    """Initialize the proactive agent-user interface.

    Args:
        args: Arguments to pass to the app
        kwargs: Keyword arguments to pass to the app
    """
    super().__init__(*args, **kwargs)

accept_proposal(content='')

User accepts the pending proactive proposal.

Parameters:

Name Type Description Default
content str

The content of the message to send to the agent

''

Returns:

Type Description
str

The message ID that was generated for this message, can be used for tracking

Source code in pare/apps/proactive_aui.py
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@type_check
@user_tool()
@event_registered(operation_type=OperationType.WRITE, event_type=EventType.USER)
def accept_proposal(self, content: str = "") -> str:
    """User accepts the pending proactive proposal.

    Args:
        content: The content of the message to send to the agent

    Returns:
        The message ID that was generated for this message, can be used for tracking
    """
    with disable_events():
        return self.send_message_to_agent(content=content)

reject_proposal(content='')

User rejects the pending proactive proposal.

Parameters:

Name Type Description Default
content str

The content of the message to send to the agent

''

Returns:

Type Description
str

The message ID that was generated for this message, can be used for tracking

Source code in pare/apps/proactive_aui.py
45
46
47
48
49
50
51
52
53
54
55
56
57
58
@type_check
@user_tool()
@event_registered(operation_type=OperationType.WRITE, event_type=EventType.USER)
def reject_proposal(self, content: str = "") -> str:
    """User rejects the pending proactive proposal.

    Args:
        content: The content of the message to send to the agent

    Returns:
        The message ID that was generated for this message, can be used for tracking
    """
    with disable_events():
        return self.send_message_to_agent(content=content)

wait()

Observe and wait without taking action.

Use this when you want to continue monitoring but don't have a specific proposal or message for the user yet.

Returns:

Type Description
str

Confirmation that the agent is in observation mode.

Source code in pare/apps/proactive_aui.py
60
61
62
63
64
65
66
67
68
69
70
71
72
@type_check
@app_tool()
@event_registered(event_type=EventType.AGENT)
def wait(self) -> str:
    """Observe and wait without taking action.

    Use this when you want to continue monitoring but don't have a specific
    proposal or message for the user yet.

    Returns:
        Confirmation that the agent is in observation mode.
    """
    return "Continuing to observe user activity."

Contacts App

Stateful contacts app built on top of the Meta-ARE ContactsApp.

StatefulContactsApp

Bases: StatefulApp, ContactsApp

Contacts application with explicit navigation states.

Source code in pare/apps/contacts/app.py
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
class StatefulContactsApp(StatefulApp, ContactsApp):
    """Contacts application with explicit navigation states."""

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        """Initialise the contacts app and load the list view as the default state."""
        self._pending_transition: tuple[str, str] | None = None
        super().__init__(*args, **kwargs)
        with disable_events():
            self.add_contact(USER_CONTACT)
        self.load_root_state()

    def queue_contact_transition(self, intent: str, contact_id: str) -> None:
        """Record a desired transition that should fire after the next contacts API call."""
        self._pending_transition = (intent, contact_id)

    def clear_contact_transition(self) -> None:
        """Reset any queued contact transition intent."""
        self._pending_transition = None

    def handle_state_transition(self, event: CompletedEvent) -> None:
        """Update navigation state based on completed contact operations."""
        function_name = event.function_name()
        if function_name is None:
            return

        # Extract args safely - event.action may be ConditionCheckAction in other contexts.
        event_args: dict[str, Any] = {}
        action = getattr(event, "action", None)
        if action is not None and hasattr(action, "args"):
            event_args = cast("dict[str, Any]", getattr(action, "args", {}))

        match function_name:
            case "get_contact":
                self._handle_get_contact(event_args)
            case "edit_contact":
                self._handle_edit_contact(event_args)
            case "delete_contact":
                self._handle_delete_contact()
            case "get_contacts":
                self._handle_get_contacts()

    def _handle_get_contact(self, event_args: dict[str, Any]) -> None:
        contact_id = event_args.get("contact_id")
        if contact_id is None:
            return

        if self._pending_transition is not None:
            intent, intent_contact_id = self._pending_transition
            self.clear_contact_transition()

            # Only use queued intent if it matches the event's contact context.
            if intent_contact_id != contact_id:
                intent_contact_id = contact_id

            if intent == "detail" and (
                not isinstance(self.current_state, ContactDetail) or self.current_state.contact_id != intent_contact_id
            ):
                self.set_current_state(ContactDetail(intent_contact_id))
            elif intent == "edit" and (
                not isinstance(self.current_state, ContactEdit) or self.current_state.contact_id != intent_contact_id
            ):
                self.set_current_state(ContactEdit(intent_contact_id))
            return

        # Fallback: if we are still on the list and a contact is accessed directly, open the detail view.
        if isinstance(self.current_state, ContactsList):
            self.set_current_state(ContactDetail(contact_id))

    def _handle_edit_contact(self, event_args: dict[str, Any]) -> None:
        contact_id = event_args.get("contact_id")
        if contact_id is None:
            return

        # After saving edits we should return to the detail view.
        if isinstance(self.current_state, ContactEdit) and self.navigation_stack:
            # go_back returns to the previous detail state on the stack if present.
            self.go_back()

        if isinstance(self.current_state, ContactDetail):
            self.current_state.contact_id = contact_id
            self.clear_contact_transition()
        else:
            self.set_current_state(ContactDetail(contact_id))

    def _handle_delete_contact(self) -> None:
        self.clear_contact_transition()
        # Prefer using the navigation stack to respect user history
        if self.navigation_stack:
            self.go_back()
        else:
            self.set_current_state(ContactsList())

    def _handle_get_contacts(self) -> None:
        if not isinstance(self.current_state, ContactsList):
            self.set_current_state(ContactsList())

    def create_root_state(self) -> ContactsList:
        """Return the root navigation state for the contacts app."""
        return ContactsList()

__init__(*args, **kwargs)

Initialise the contacts app and load the list view as the default state.

Source code in pare/apps/contacts/app.py
35
36
37
38
39
40
41
def __init__(self, *args: Any, **kwargs: Any) -> None:
    """Initialise the contacts app and load the list view as the default state."""
    self._pending_transition: tuple[str, str] | None = None
    super().__init__(*args, **kwargs)
    with disable_events():
        self.add_contact(USER_CONTACT)
    self.load_root_state()

clear_contact_transition()

Reset any queued contact transition intent.

Source code in pare/apps/contacts/app.py
47
48
49
def clear_contact_transition(self) -> None:
    """Reset any queued contact transition intent."""
    self._pending_transition = None

create_root_state()

Return the root navigation state for the contacts app.

Source code in pare/apps/contacts/app.py
128
129
130
def create_root_state(self) -> ContactsList:
    """Return the root navigation state for the contacts app."""
    return ContactsList()

handle_state_transition(event)

Update navigation state based on completed contact operations.

Source code in pare/apps/contacts/app.py
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
def handle_state_transition(self, event: CompletedEvent) -> None:
    """Update navigation state based on completed contact operations."""
    function_name = event.function_name()
    if function_name is None:
        return

    # Extract args safely - event.action may be ConditionCheckAction in other contexts.
    event_args: dict[str, Any] = {}
    action = getattr(event, "action", None)
    if action is not None and hasattr(action, "args"):
        event_args = cast("dict[str, Any]", getattr(action, "args", {}))

    match function_name:
        case "get_contact":
            self._handle_get_contact(event_args)
        case "edit_contact":
            self._handle_edit_contact(event_args)
        case "delete_contact":
            self._handle_delete_contact()
        case "get_contacts":
            self._handle_get_contacts()

queue_contact_transition(intent, contact_id)

Record a desired transition that should fire after the next contacts API call.

Source code in pare/apps/contacts/app.py
43
44
45
def queue_contact_transition(self, intent: str, contact_id: str) -> None:
    """Record a desired transition that should fire after the next contacts API call."""
    self._pending_transition = (intent, contact_id)

Navigation states for the stateful contacts app.

ContactDetail

Bases: AppState

State for viewing a specific contact's details.

Source code in pare/apps/contacts/states.py
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
class ContactDetail(AppState):
    """State for viewing a specific contact's details."""

    def __init__(self, contact_id: str) -> None:
        """Bind the detail view to the supplied contact identifier."""
        super().__init__()
        self.contact_id = contact_id

    def on_enter(self) -> None:
        """No-op hook for detail entry; data retrieval happens via user tools."""

    def on_exit(self) -> None:
        """Clear any queued edit intents when leaving detail view."""
        app = cast("StatefulContactsApp", self.app)
        app.clear_contact_transition()

    @user_tool()
    @pare_event_registered()
    def view_contact(self) -> Contact:
        """Retrieve the currently opened contact."""
        app = cast("StatefulContactsApp", self.app)
        return app.get_contact(contact_id=self.contact_id)

    @user_tool()
    @pare_event_registered()
    def start_edit_contact(self) -> Contact:
        """Queue an edit transition and return the latest contact data."""
        app = cast("StatefulContactsApp", self.app)
        app.queue_contact_transition("edit", self.contact_id)
        return app.get_contact(contact_id=self.contact_id)

    @user_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def delete_contact(self) -> str:
        """Delete the currently opened contact."""
        app = cast("StatefulContactsApp", self.app)
        with disable_events():
            return app.delete_contact(contact_id=self.contact_id)

__init__(contact_id)

Bind the detail view to the supplied contact identifier.

Source code in pare/apps/contacts/states.py
136
137
138
139
def __init__(self, contact_id: str) -> None:
    """Bind the detail view to the supplied contact identifier."""
    super().__init__()
    self.contact_id = contact_id

delete_contact()

Delete the currently opened contact.

Source code in pare/apps/contacts/states.py
164
165
166
167
168
169
170
@user_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def delete_contact(self) -> str:
    """Delete the currently opened contact."""
    app = cast("StatefulContactsApp", self.app)
    with disable_events():
        return app.delete_contact(contact_id=self.contact_id)

on_enter()

No-op hook for detail entry; data retrieval happens via user tools.

Source code in pare/apps/contacts/states.py
141
142
def on_enter(self) -> None:
    """No-op hook for detail entry; data retrieval happens via user tools."""

on_exit()

Clear any queued edit intents when leaving detail view.

Source code in pare/apps/contacts/states.py
144
145
146
147
def on_exit(self) -> None:
    """Clear any queued edit intents when leaving detail view."""
    app = cast("StatefulContactsApp", self.app)
    app.clear_contact_transition()

start_edit_contact()

Queue an edit transition and return the latest contact data.

Source code in pare/apps/contacts/states.py
156
157
158
159
160
161
162
@user_tool()
@pare_event_registered()
def start_edit_contact(self) -> Contact:
    """Queue an edit transition and return the latest contact data."""
    app = cast("StatefulContactsApp", self.app)
    app.queue_contact_transition("edit", self.contact_id)
    return app.get_contact(contact_id=self.contact_id)

view_contact()

Retrieve the currently opened contact.

Source code in pare/apps/contacts/states.py
149
150
151
152
153
154
@user_tool()
@pare_event_registered()
def view_contact(self) -> Contact:
    """Retrieve the currently opened contact."""
    app = cast("StatefulContactsApp", self.app)
    return app.get_contact(contact_id=self.contact_id)

ContactEdit

Bases: AppState

State representing the contact edit surface.

Source code in pare/apps/contacts/states.py
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
class ContactEdit(AppState):
    """State representing the contact edit surface."""

    def __init__(self, contact_id: str) -> None:
        """Initialise the edit state for a particular contact."""
        super().__init__()
        self.contact_id = contact_id

    def on_enter(self) -> None:
        """No special entry behaviour for the edit form."""

    def on_exit(self) -> None:
        """Clear edit-specific transition intent when leaving the edit view."""
        app = cast("StatefulContactsApp", self.app)
        app.clear_contact_transition()

    @user_tool()
    @pare_event_registered()
    def view_contact(self) -> Contact:
        """Read the contact being edited without leaving edit mode."""
        app = cast("StatefulContactsApp", self.app)
        return app.get_contact(contact_id=self.contact_id)

    @user_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def update_contact(self, updates: dict[str, object]) -> str | None:
        """Persist updates to the contact and stay in edit mode until a transition occurs."""
        app = cast("StatefulContactsApp", self.app)
        return app.edit_contact(contact_id=self.contact_id, updates=updates)

__init__(contact_id)

Initialise the edit state for a particular contact.

Source code in pare/apps/contacts/states.py
176
177
178
179
def __init__(self, contact_id: str) -> None:
    """Initialise the edit state for a particular contact."""
    super().__init__()
    self.contact_id = contact_id

on_enter()

No special entry behaviour for the edit form.

Source code in pare/apps/contacts/states.py
181
182
def on_enter(self) -> None:
    """No special entry behaviour for the edit form."""

on_exit()

Clear edit-specific transition intent when leaving the edit view.

Source code in pare/apps/contacts/states.py
184
185
186
187
def on_exit(self) -> None:
    """Clear edit-specific transition intent when leaving the edit view."""
    app = cast("StatefulContactsApp", self.app)
    app.clear_contact_transition()

update_contact(updates)

Persist updates to the contact and stay in edit mode until a transition occurs.

Source code in pare/apps/contacts/states.py
196
197
198
199
200
201
@user_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def update_contact(self, updates: dict[str, object]) -> str | None:
    """Persist updates to the contact and stay in edit mode until a transition occurs."""
    app = cast("StatefulContactsApp", self.app)
    return app.edit_contact(contact_id=self.contact_id, updates=updates)

view_contact()

Read the contact being edited without leaving edit mode.

Source code in pare/apps/contacts/states.py
189
190
191
192
193
194
@user_tool()
@pare_event_registered()
def view_contact(self) -> Contact:
    """Read the contact being edited without leaving edit mode."""
    app = cast("StatefulContactsApp", self.app)
    return app.get_contact(contact_id=self.contact_id)

ContactsList

Bases: AppState

Initial navigation state showing the list of contacts.

Source code in pare/apps/contacts/states.py
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
class ContactsList(AppState):
    """Initial navigation state showing the list of contacts."""

    def __init__(self) -> None:
        """Initialise the list state."""
        super().__init__()

    def on_enter(self) -> None:
        """No-op hook for entering the contacts list."""

    def on_exit(self) -> None:
        """No-op hook for exiting the contacts list."""

    @user_tool()
    @pare_event_registered()
    def list_contacts(self, offset: int = 0) -> dict[str, object]:
        """List contacts using the native paginated API."""
        app = cast("StatefulContactsApp", self.app)
        return app.get_contacts(offset=offset)

    @user_tool()
    @pare_event_registered()
    def search_contacts(self, query: str) -> list[Contact]:
        """Search contacts by name, phone or email."""
        app = cast("StatefulContactsApp", self.app)
        return app.search_contacts(query=query)

    @user_tool()
    @pare_event_registered()
    def open_contact(self, contact_id: str) -> Contact:
        """Open a contact from the list, queuing a transition to the detail view."""
        app = cast("StatefulContactsApp", self.app)
        app.queue_contact_transition("detail", contact_id)
        return app.get_contact(contact_id=contact_id)

    @user_tool()
    @pare_event_registered()
    def view_current_user(self) -> Contact:
        """View the contact card for the current user persona."""
        app = cast("StatefulContactsApp", self.app)
        return app.get_current_user_details()

    def get_available_actions(self) -> list[AppTool]:
        """Annotate tool argument descriptions for the current state."""
        actions = super().get_available_actions()
        for tool in actions:
            mapping = CONTACT_TOOL_ARG_DESCRIPTIONS.get(tool.function.__name__)
            if mapping is None:
                continue
            for arg in tool.args:
                description = mapping.get(arg.name)
                if description is not None:
                    arg.description = description
        return actions

    @user_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def create_contact(
        self,
        first_name: str,
        last_name: str,
        gender: str | None = None,
        age: int | None = None,
        nationality: str | None = None,
        city_living: str | None = None,
        country: str | None = None,
        status: str | None = None,
        job: str | None = None,
        description: str | None = None,
        phone: str | None = None,
        email: str | None = None,
        address: str | None = None,
    ) -> str:
        """Create a new contact and return its identifier."""
        app = cast("StatefulContactsApp", self.app)
        return app.add_new_contact(
            first_name=first_name,
            last_name=last_name,
            gender=gender,
            age=age,
            nationality=nationality,
            city_living=city_living,
            country=country,
            status=status,
            job=job,
            description=description,
            phone=phone,
            email=email,
            address=address,
        )

__init__()

Initialise the list state.

Source code in pare/apps/contacts/states.py
44
45
46
def __init__(self) -> None:
    """Initialise the list state."""
    super().__init__()

create_contact(first_name, last_name, gender=None, age=None, nationality=None, city_living=None, country=None, status=None, job=None, description=None, phone=None, email=None, address=None)

Create a new contact and return its identifier.

Source code in pare/apps/contacts/states.py
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
@user_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def create_contact(
    self,
    first_name: str,
    last_name: str,
    gender: str | None = None,
    age: int | None = None,
    nationality: str | None = None,
    city_living: str | None = None,
    country: str | None = None,
    status: str | None = None,
    job: str | None = None,
    description: str | None = None,
    phone: str | None = None,
    email: str | None = None,
    address: str | None = None,
) -> str:
    """Create a new contact and return its identifier."""
    app = cast("StatefulContactsApp", self.app)
    return app.add_new_contact(
        first_name=first_name,
        last_name=last_name,
        gender=gender,
        age=age,
        nationality=nationality,
        city_living=city_living,
        country=country,
        status=status,
        job=job,
        description=description,
        phone=phone,
        email=email,
        address=address,
    )

get_available_actions()

Annotate tool argument descriptions for the current state.

Source code in pare/apps/contacts/states.py
83
84
85
86
87
88
89
90
91
92
93
94
def get_available_actions(self) -> list[AppTool]:
    """Annotate tool argument descriptions for the current state."""
    actions = super().get_available_actions()
    for tool in actions:
        mapping = CONTACT_TOOL_ARG_DESCRIPTIONS.get(tool.function.__name__)
        if mapping is None:
            continue
        for arg in tool.args:
            description = mapping.get(arg.name)
            if description is not None:
                arg.description = description
    return actions

list_contacts(offset=0)

List contacts using the native paginated API.

Source code in pare/apps/contacts/states.py
54
55
56
57
58
59
@user_tool()
@pare_event_registered()
def list_contacts(self, offset: int = 0) -> dict[str, object]:
    """List contacts using the native paginated API."""
    app = cast("StatefulContactsApp", self.app)
    return app.get_contacts(offset=offset)

on_enter()

No-op hook for entering the contacts list.

Source code in pare/apps/contacts/states.py
48
49
def on_enter(self) -> None:
    """No-op hook for entering the contacts list."""

on_exit()

No-op hook for exiting the contacts list.

Source code in pare/apps/contacts/states.py
51
52
def on_exit(self) -> None:
    """No-op hook for exiting the contacts list."""

open_contact(contact_id)

Open a contact from the list, queuing a transition to the detail view.

Source code in pare/apps/contacts/states.py
68
69
70
71
72
73
74
@user_tool()
@pare_event_registered()
def open_contact(self, contact_id: str) -> Contact:
    """Open a contact from the list, queuing a transition to the detail view."""
    app = cast("StatefulContactsApp", self.app)
    app.queue_contact_transition("detail", contact_id)
    return app.get_contact(contact_id=contact_id)

search_contacts(query)

Search contacts by name, phone or email.

Source code in pare/apps/contacts/states.py
61
62
63
64
65
66
@user_tool()
@pare_event_registered()
def search_contacts(self, query: str) -> list[Contact]:
    """Search contacts by name, phone or email."""
    app = cast("StatefulContactsApp", self.app)
    return app.search_contacts(query=query)

view_current_user()

View the contact card for the current user persona.

Source code in pare/apps/contacts/states.py
76
77
78
79
80
81
@user_tool()
@pare_event_registered()
def view_current_user(self) -> Contact:
    """View the contact card for the current user persona."""
    app = cast("StatefulContactsApp", self.app)
    return app.get_current_user_details()

Messaging App

StatefulMessagingApp

Bases: StatefulApp, MessagingAppV2

Messaging app with navigation state management.

// RL NOTE: This implements a simple 2-state MDP for messaging: // States: ConversationList, ConversationOpened // Transitions: open_conversation, go_back

Source code in pare/apps/messaging/app.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
class StatefulMessagingApp(StatefulApp, MessagingAppV2):
    """Messaging app with navigation state management.

    // RL NOTE: This implements a simple 2-state MDP for messaging:
    // States: ConversationList, ConversationOpened
    // Transitions: open_conversation, go_back
    """

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        """Initialize the stateful messaging app.

        Args:
            *args: Variable length argument list passed to parent classes.
            **kwargs: Arbitrary keyword arguments passed to parent classes.
        """
        super().__init__(*args, **kwargs)
        self.current_user_id = uuid_hex(self.rng)
        self.current_user_name = "John Doe"
        # Register current user in id/name mappings
        self.id_to_name[self.current_user_id] = self.current_user_name
        self.name_to_id[self.current_user_name] = self.current_user_id
        # Set initial state to conversation list
        self.load_root_state()

    def add_users(self, user_names: list[str]) -> None:
        """Add users to the internal name/id maps.

        Args:
            user_names: User display names to ensure exist in the mapping.
        """
        for user_name in user_names:
            if user_name not in self.name_to_id:
                user_id = uuid_hex(self.rng)
                self.name_to_id[user_name] = user_id
                self.id_to_name[user_id] = user_name

    def add_contacts(self, contacts: list[tuple[str, str]]) -> None:
        """Add contacts (name, phone) to the internal name/id maps.

        Args:
            contacts: Pairs of (user_name, phone).
        """
        for user_name, phone in contacts:
            if user_name not in self.name_to_id:
                self.name_to_id[user_name] = phone
                self.id_to_name[phone] = user_name

    def handle_state_transition(self, event: CompletedEvent) -> None:
        """Handle state transitions based on tool events.

        // RL NOTE: This implements T(s,a) -> s' for the messaging MDP.

        Args:
            event: Completed event from tool execution
        """
        current_state = self.current_state
        function_name = event.function_name()

        if current_state is None or function_name is None:
            return

        # Transition: ConversationList -> ConversationOpened
        if isinstance(current_state, ConversationList) and function_name in {"open_conversation", "read_conversation"}:
            args = event.action.resolved_args or event.action.args
            conversation_id = args.get("conversation_id")
            if conversation_id:
                self.set_current_state(ConversationOpened(conversation_id))

        # go_back transitions are handled automatically by StatefulApp.go_back()

    def create_root_state(self) -> ConversationList:
        """Return the conversation list root state."""
        return ConversationList()

__init__(*args, **kwargs)

Initialize the stateful messaging app.

Parameters:

Name Type Description Default
*args Any

Variable length argument list passed to parent classes.

()
**kwargs Any

Arbitrary keyword arguments passed to parent classes.

{}
Source code in pare/apps/messaging/app.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
def __init__(self, *args: Any, **kwargs: Any) -> None:
    """Initialize the stateful messaging app.

    Args:
        *args: Variable length argument list passed to parent classes.
        **kwargs: Arbitrary keyword arguments passed to parent classes.
    """
    super().__init__(*args, **kwargs)
    self.current_user_id = uuid_hex(self.rng)
    self.current_user_name = "John Doe"
    # Register current user in id/name mappings
    self.id_to_name[self.current_user_id] = self.current_user_name
    self.name_to_id[self.current_user_name] = self.current_user_id
    # Set initial state to conversation list
    self.load_root_state()

add_contacts(contacts)

Add contacts (name, phone) to the internal name/id maps.

Parameters:

Name Type Description Default
contacts list[tuple[str, str]]

Pairs of (user_name, phone).

required
Source code in pare/apps/messaging/app.py
51
52
53
54
55
56
57
58
59
60
def add_contacts(self, contacts: list[tuple[str, str]]) -> None:
    """Add contacts (name, phone) to the internal name/id maps.

    Args:
        contacts: Pairs of (user_name, phone).
    """
    for user_name, phone in contacts:
        if user_name not in self.name_to_id:
            self.name_to_id[user_name] = phone
            self.id_to_name[phone] = user_name

add_users(user_names)

Add users to the internal name/id maps.

Parameters:

Name Type Description Default
user_names list[str]

User display names to ensure exist in the mapping.

required
Source code in pare/apps/messaging/app.py
39
40
41
42
43
44
45
46
47
48
49
def add_users(self, user_names: list[str]) -> None:
    """Add users to the internal name/id maps.

    Args:
        user_names: User display names to ensure exist in the mapping.
    """
    for user_name in user_names:
        if user_name not in self.name_to_id:
            user_id = uuid_hex(self.rng)
            self.name_to_id[user_name] = user_id
            self.id_to_name[user_id] = user_name

create_root_state()

Return the conversation list root state.

Source code in pare/apps/messaging/app.py
85
86
87
def create_root_state(self) -> ConversationList:
    """Return the conversation list root state."""
    return ConversationList()

handle_state_transition(event)

Handle state transitions based on tool events.

// RL NOTE: This implements T(s,a) -> s' for the messaging MDP.

Parameters:

Name Type Description Default
event CompletedEvent

Completed event from tool execution

required
Source code in pare/apps/messaging/app.py
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
def handle_state_transition(self, event: CompletedEvent) -> None:
    """Handle state transitions based on tool events.

    // RL NOTE: This implements T(s,a) -> s' for the messaging MDP.

    Args:
        event: Completed event from tool execution
    """
    current_state = self.current_state
    function_name = event.function_name()

    if current_state is None or function_name is None:
        return

    # Transition: ConversationList -> ConversationOpened
    if isinstance(current_state, ConversationList) and function_name in {"open_conversation", "read_conversation"}:
        args = event.action.resolved_args or event.action.args
        conversation_id = args.get("conversation_id")
        if conversation_id:
            self.set_current_state(ConversationOpened(conversation_id))

ConversationList

Bases: AppState

Navigation state representing the conversations list view.

// RL NOTE: This is typically an initial/hub state in the messaging app navigation graph.

Source code in pare/apps/messaging/states.py
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
class ConversationList(AppState):
    """Navigation state representing the conversations list view.

    // RL NOTE: This is typically an initial/hub state in the messaging app navigation graph.
    """

    def __init__(self) -> None:
        """Create conversation list state.

        Note: No app parameter - uses late binding pattern.
        """
        super().__init__()

    def on_enter(self) -> None:
        """Called when entering conversation list state."""
        pass

    def on_exit(self) -> None:
        """Called when exiting conversation list state."""
        pass

    @user_tool()
    @pare_event_registered()
    def list_recent_conversations(
        self,
        offset: int = 0,
        limit: int = 5,
        offset_recent_messages_per_conversation: int = 0,
        limit_recent_messages_per_conversation: int = 10,
    ) -> list[ConversationV2]:
        """List recent conversations ordered by most recent modification.

        Args:
            offset: Starting index from which to list conversations
            limit: Number of conversations to list
            offset_recent_messages_per_conversation: Starting index for messages per conversation
            limit_recent_messages_per_conversation: Number of messages to list per conversation

        Returns:
            List of conversation details
        """
        return self.app.list_recent_conversations(
            offset=offset,
            limit=limit,
            offset_recent_messages_per_conversation=offset_recent_messages_per_conversation,
            limit_recent_messages_per_conversation=limit_recent_messages_per_conversation,
        )

    @user_tool()
    @pare_event_registered()
    def search_conversations(
        self, query: str, min_date: str | None = None, max_date: str | None = None, limit: int = 10
    ) -> list[str]:
        """Search conversations by query string.

        Args:
            query: Search query
            min_date: Minimum date (YYYY-MM-DD %H:%M:%S format)
            max_date: Maximum date (YYYY-MM-DD %H:%M:%S format)
            limit: Maximum number of results

        Returns:
            List of matching conversations
        """
        results = self.app.search(query=query, min_date=min_date, max_date=max_date)
        if limit < len(results):
            return results[:limit]
        return results

    @user_tool()
    @pare_event_registered()
    def open_conversation(self, conversation_id: str, offset: int = 0, limit: int = 20) -> dict[str, object]:
        """Open specific conversation (triggers state transition).

        Returns conversation data as the observation when opening.

        Args:
            conversation_id: The conversation to open
            offset: Message offset
            limit: Number of messages to load

        Returns:
            Conversation data with messages
        """
        return self.app.read_conversation(conversation_id=conversation_id, offset=offset, limit=limit)

__init__()

Create conversation list state.

Note: No app parameter - uses late binding pattern.

Source code in pare/apps/messaging/states.py
84
85
86
87
88
89
def __init__(self) -> None:
    """Create conversation list state.

    Note: No app parameter - uses late binding pattern.
    """
    super().__init__()

list_recent_conversations(offset=0, limit=5, offset_recent_messages_per_conversation=0, limit_recent_messages_per_conversation=10)

List recent conversations ordered by most recent modification.

Parameters:

Name Type Description Default
offset int

Starting index from which to list conversations

0
limit int

Number of conversations to list

5
offset_recent_messages_per_conversation int

Starting index for messages per conversation

0
limit_recent_messages_per_conversation int

Number of messages to list per conversation

10

Returns:

Type Description
list[ConversationV2]

List of conversation details

Source code in pare/apps/messaging/states.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
@user_tool()
@pare_event_registered()
def list_recent_conversations(
    self,
    offset: int = 0,
    limit: int = 5,
    offset_recent_messages_per_conversation: int = 0,
    limit_recent_messages_per_conversation: int = 10,
) -> list[ConversationV2]:
    """List recent conversations ordered by most recent modification.

    Args:
        offset: Starting index from which to list conversations
        limit: Number of conversations to list
        offset_recent_messages_per_conversation: Starting index for messages per conversation
        limit_recent_messages_per_conversation: Number of messages to list per conversation

    Returns:
        List of conversation details
    """
    return self.app.list_recent_conversations(
        offset=offset,
        limit=limit,
        offset_recent_messages_per_conversation=offset_recent_messages_per_conversation,
        limit_recent_messages_per_conversation=limit_recent_messages_per_conversation,
    )

on_enter()

Called when entering conversation list state.

Source code in pare/apps/messaging/states.py
91
92
93
def on_enter(self) -> None:
    """Called when entering conversation list state."""
    pass

on_exit()

Called when exiting conversation list state.

Source code in pare/apps/messaging/states.py
95
96
97
def on_exit(self) -> None:
    """Called when exiting conversation list state."""
    pass

open_conversation(conversation_id, offset=0, limit=20)

Open specific conversation (triggers state transition).

Returns conversation data as the observation when opening.

Parameters:

Name Type Description Default
conversation_id str

The conversation to open

required
offset int

Message offset

0
limit int

Number of messages to load

20

Returns:

Type Description
dict[str, object]

Conversation data with messages

Source code in pare/apps/messaging/states.py
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
@user_tool()
@pare_event_registered()
def open_conversation(self, conversation_id: str, offset: int = 0, limit: int = 20) -> dict[str, object]:
    """Open specific conversation (triggers state transition).

    Returns conversation data as the observation when opening.

    Args:
        conversation_id: The conversation to open
        offset: Message offset
        limit: Number of messages to load

    Returns:
        Conversation data with messages
    """
    return self.app.read_conversation(conversation_id=conversation_id, offset=offset, limit=limit)

search_conversations(query, min_date=None, max_date=None, limit=10)

Search conversations by query string.

Parameters:

Name Type Description Default
query str

Search query

required
min_date str | None

Minimum date (YYYY-MM-DD %H:%M:%S format)

None
max_date str | None

Maximum date (YYYY-MM-DD %H:%M:%S format)

None
limit int

Maximum number of results

10

Returns:

Type Description
list[str]

List of matching conversations

Source code in pare/apps/messaging/states.py
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
@user_tool()
@pare_event_registered()
def search_conversations(
    self, query: str, min_date: str | None = None, max_date: str | None = None, limit: int = 10
) -> list[str]:
    """Search conversations by query string.

    Args:
        query: Search query
        min_date: Minimum date (YYYY-MM-DD %H:%M:%S format)
        max_date: Maximum date (YYYY-MM-DD %H:%M:%S format)
        limit: Maximum number of results

    Returns:
        List of matching conversations
    """
    results = self.app.search(query=query, min_date=min_date, max_date=max_date)
    if limit < len(results):
        return results[:limit]
    return results

ConversationOpened

Bases: AppState

Navigation state representing an open conversation view.

// RL NOTE: This is a conversation-specific state in the navigation MDP. // Context (conversation_id) is part of the state representation.

Source code in pare/apps/messaging/states.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
class ConversationOpened(AppState):
    """Navigation state representing an open conversation view.

    // RL NOTE: This is a conversation-specific state in the navigation MDP.
    // Context (conversation_id) is part of the state representation.
    """

    def __init__(self, conversation_id: str) -> None:
        """Initialize conversation state with the id of the conversation.

        Args:
            conversation_id: The conversation context
        """
        super().__init__()
        self.conversation_id = conversation_id

    def on_enter(self) -> None:
        """Called when entering the conversation state."""
        # TODO: Could log state entry here for any use case in future.
        pass

    def on_exit(self) -> None:
        """Called when exiting the conversation state."""
        # TODO: Could log state exit here for any use case in future.
        pass

    @user_tool()
    @pare_event_registered()
    def send_message(self, content: str, attachment_path: str | None = None) -> str:
        """Send message in current conversation (context-aware).

        Args:
            content: The message content
            attachment_path: The path to the attachment file

        Returns:
            The id of the conversation the message was sent to
        """
        return self.app.send_message_to_group_conversation(
            conversation_id=self.conversation_id, content=content, attachment_path=attachment_path
        )

    @user_tool()
    @pare_event_registered()
    def read_messages(
        self, offset: int = 0, limit: int = 10, min_date: str | None = None, max_date: str | None = None
    ) -> dict[str, object]:
        """Read the conversation with the given conversation_id.

        Shows the last 'limit' messages after offset. Which means messages between
        offset and offset + limit will be shown. Messages are sorted by timestamp,
        most recent first.

        Args:
            offset: Offset to shift the view window
            limit: Number of messages to show
            min_date: Minimum date (YYYY-MM-DD %H:%M:%S format). Default is None,
                which means no minimum date.
            max_date: Maximum date (YYYY-MM-DD %H:%M:%S format). Default is None,
                which means no maximum date.

        Returns:
            Dict with messages and additional info
        """
        return self.app.read_conversation(
            conversation_id=self.conversation_id, offset=offset, limit=limit, min_date=min_date, max_date=max_date
        )

__init__(conversation_id)

Initialize conversation state with the id of the conversation.

Parameters:

Name Type Description Default
conversation_id str

The conversation context

required
Source code in pare/apps/messaging/states.py
16
17
18
19
20
21
22
23
def __init__(self, conversation_id: str) -> None:
    """Initialize conversation state with the id of the conversation.

    Args:
        conversation_id: The conversation context
    """
    super().__init__()
    self.conversation_id = conversation_id

on_enter()

Called when entering the conversation state.

Source code in pare/apps/messaging/states.py
25
26
27
28
def on_enter(self) -> None:
    """Called when entering the conversation state."""
    # TODO: Could log state entry here for any use case in future.
    pass

on_exit()

Called when exiting the conversation state.

Source code in pare/apps/messaging/states.py
30
31
32
33
def on_exit(self) -> None:
    """Called when exiting the conversation state."""
    # TODO: Could log state exit here for any use case in future.
    pass

read_messages(offset=0, limit=10, min_date=None, max_date=None)

Read the conversation with the given conversation_id.

Shows the last 'limit' messages after offset. Which means messages between offset and offset + limit will be shown. Messages are sorted by timestamp, most recent first.

Parameters:

Name Type Description Default
offset int

Offset to shift the view window

0
limit int

Number of messages to show

10
min_date str | None

Minimum date (YYYY-MM-DD %H:%M:%S format). Default is None, which means no minimum date.

None
max_date str | None

Maximum date (YYYY-MM-DD %H:%M:%S format). Default is None, which means no maximum date.

None

Returns:

Type Description
dict[str, object]

Dict with messages and additional info

Source code in pare/apps/messaging/states.py
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
@user_tool()
@pare_event_registered()
def read_messages(
    self, offset: int = 0, limit: int = 10, min_date: str | None = None, max_date: str | None = None
) -> dict[str, object]:
    """Read the conversation with the given conversation_id.

    Shows the last 'limit' messages after offset. Which means messages between
    offset and offset + limit will be shown. Messages are sorted by timestamp,
    most recent first.

    Args:
        offset: Offset to shift the view window
        limit: Number of messages to show
        min_date: Minimum date (YYYY-MM-DD %H:%M:%S format). Default is None,
            which means no minimum date.
        max_date: Maximum date (YYYY-MM-DD %H:%M:%S format). Default is None,
            which means no maximum date.

    Returns:
        Dict with messages and additional info
    """
    return self.app.read_conversation(
        conversation_id=self.conversation_id, offset=offset, limit=limit, min_date=min_date, max_date=max_date
    )

send_message(content, attachment_path=None)

Send message in current conversation (context-aware).

Parameters:

Name Type Description Default
content str

The message content

required
attachment_path str | None

The path to the attachment file

None

Returns:

Type Description
str

The id of the conversation the message was sent to

Source code in pare/apps/messaging/states.py
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
@user_tool()
@pare_event_registered()
def send_message(self, content: str, attachment_path: str | None = None) -> str:
    """Send message in current conversation (context-aware).

    Args:
        content: The message content
        attachment_path: The path to the attachment file

    Returns:
        The id of the conversation the message was sent to
    """
    return self.app.send_message_to_group_conversation(
        conversation_id=self.conversation_id, content=content, attachment_path=attachment_path
    )

Email App

Stateful email app combining Meta-ARE's email backend with PARE navigation.

StatefulEmailApp

Bases: StatefulApp, EmailClientV2

Email client with navigation state management for user tool filtering.

Source code in pare/apps/email/app.py
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
class StatefulEmailApp(StatefulApp, EmailClientV2):
    """Email client with navigation state management for user tool filtering."""

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        """Initialise the email app with the inbox as the starting state."""
        super().__init__(*args, **kwargs)
        self.user_email = "john@pare.com"
        self.load_root_state()

    def handle_state_transition(self, event: CompletedEvent) -> None:
        """Update navigation state in response to completed tool events."""
        current_state = self.current_state
        function_name = event.function_name()

        if current_state is None or function_name is None:  # pragma: no cover - defensive
            return

        action = event.action
        args = action.resolved_args or action.args
        if isinstance(current_state, MailboxView):
            self._handle_mailbox_transition(current_state, function_name, args, event)
            return

        if isinstance(current_state, EmailDetail):
            self._handle_detail_transition(function_name, event)
            if function_name in {"delete", "move"} and self.navigation_stack:
                self.go_back()
            return

        if isinstance(current_state, ComposeEmail):
            self._handle_compose_transition(function_name)

    def _handle_mailbox_transition(
        self, current_state: MailboxView, function_name: str, args: dict[str, Any], event: CompletedEvent
    ) -> None:
        """Handle transitions triggered from the mailbox view."""
        if function_name in {"open_email_by_id", "open_email_by_index"}:
            folder = self._resolve_folder_from_args(args, current_state.folder)
            email_id = args.get("email_id")
            if email_id is None:
                email_id = self._email_id_from_event(event)
            if email_id is not None:
                self.set_current_state(EmailDetail(email_id=email_id, folder_name=folder))
            return

        if function_name == "switch_folder":
            folder = self._resolve_folder_from_args(args, current_state.folder)
            self.set_current_state(MailboxView(folder=folder))
            return

        if function_name == "start_compose":
            draft = self._compose_draft_from_event(event)
            self.set_current_state(ComposeEmail(draft=draft))

    def _handle_detail_transition(self, function_name: str, event: CompletedEvent) -> None:
        """Handle transitions triggered from the email detail view."""
        if function_name == "start_compose_reply":
            draft = self._compose_draft_from_event(event)
            self.set_current_state(ComposeEmail(draft=draft))

    def _handle_compose_transition(self, function_name: str) -> None:
        """Handle transitions triggered from the compose view."""
        if function_name in {"send_composed_email", "save_draft", "discard_draft"} and self.navigation_stack:
            self.go_back()

    @staticmethod
    def _resolve_folder_from_args(args: dict[str, Any], default_folder: str) -> str:
        folder = args.get("folder_name")
        if isinstance(folder, EmailFolderName):
            return folder.value
        if isinstance(folder, str):
            try:
                return EmailFolderName[folder.upper()].value
            except KeyError:
                return folder.upper()
        return default_folder

    @staticmethod
    def _email_id_from_event(event: CompletedEvent) -> str | None:
        metadata_value = event.metadata.return_value if event.metadata else None
        if isinstance(metadata_value, Email):
            return metadata_value.email_id
        if isinstance(metadata_value, dict):
            return metadata_value.get("email_id")
        return None

    @staticmethod
    def _compose_draft_from_event(event: CompletedEvent) -> ComposeDraft | None:
        metadata_value = event.metadata.return_value if event.metadata else None
        if not isinstance(metadata_value, dict):
            return None
        draft_data = metadata_value.get("draft")
        if isinstance(draft_data, ComposeDraft):
            return draft_data
        if isinstance(draft_data, dict):
            return ComposeDraft(
                recipients=draft_data.get("recipients", []),
                cc=draft_data.get("cc", []),
                subject=draft_data.get("subject", ""),
                body=draft_data.get("body", ""),
                attachments=draft_data.get("attachments", []),
                reply_to=draft_data.get("reply_to"),
                reply_to_folder=draft_data.get("folder_name"),
                default_recipients=list(draft_data.get("recipients", [])),
                default_subject=draft_data.get("subject", ""),
            )
        return None

    def send_reply_from_draft(self, draft: ComposeDraft) -> str:
        """Send a reply using the draft metadata, preserving user edits."""
        if not draft.reply_to:
            raise ValueError("Draft does not reference a reply target")

        attachments = draft.attachments or []
        folder_name = draft.reply_to_folder or EmailFolderName.INBOX.value
        recipients = draft.recipients or draft.default_recipients
        subject = draft.subject or draft.default_subject or ""
        cc = draft.cc

        return self._send_reply_email(
            email_id=draft.reply_to,
            folder_name=folder_name,
            content=draft.body,
            attachment_paths=attachments,
            recipients=recipients,
            subject=subject,
            cc=cc,
            fallback_recipients=draft.default_recipients,
            fallback_subject=draft.default_subject,
        )

    def _send_reply_email(
        self,
        *,
        email_id: str,
        folder_name: str,
        content: str,
        attachment_paths: list[str],
        recipients: list[str],
        subject: str,
        cc: list[str],
        fallback_recipients: list[str],
        fallback_subject: str | None,
    ) -> str:
        folder_enum = EmailFolderName[folder_name.upper()] if isinstance(folder_name, str) else folder_name
        if folder_enum not in self.folders:
            raise ValueError(f"Folder {folder_name} not found")

        replying_to_email = self.folders[folder_enum].get_email_by_id(email_id)

        def get_default_recipient(email: Email) -> str:
            while email.sender == self.user_email and email.parent_id:
                email_found = False
                for folder in self.folders:
                    try:
                        email = self.folders[folder].get_email_by_id(email.parent_id)
                        email_found = True
                        break
                    except (KeyError, ValueError):
                        continue
                if not email_found:
                    raise ValueError(f"Email with id {email.parent_id} not found")
            return email.sender

        resolved_recipients = list(recipients) if recipients else list(fallback_recipients)
        if not resolved_recipients:
            resolved_recipients = [get_default_recipient(replying_to_email)]

        resolved_subject = subject or fallback_subject or f"Re: {replying_to_email.subject}"
        resolved_cc = list(cc) if cc else []

        email = Email(
            email_id=uuid_hex(self.rng),
            sender=self.user_email,
            recipients=resolved_recipients,
            subject=resolved_subject,
            content=content,
            timestamp=self.time_manager.time(),
            cc=resolved_cc,
            parent_id=replying_to_email.email_id,
        )

        for path in attachment_paths:
            self.add_attachment(email=email, attachment_path=path)

        with disable_events():
            self.add_email(email=email, folder_name=EmailFolderName.SENT)

        return email.email_id

    @env_tool()
    @event_registered(operation_type=OperationType.WRITE, event_type=EventType.ENV)
    def send_email_to_user_with_id(
        self,
        email_id: str,
        sender: str,
        subject: str = "",
        content: str = "",
        attachment_paths: list[str] | None = None,
    ) -> str:
        """Create an incoming email with a specified ID (optionally with attachments) and add it to user's INBOX.

        This is a PARE-specific environment tool that allows scenarios to reference
        the email_id in subsequent events (e.g., for replying to the email).

        Args:
            email_id: The ID to assign to this email.
            sender: The sender of the email.
            subject: The subject of the email.
            content: The content of the email.
            attachment_paths: Optional list of attachment paths to add to the email.
                NOTE: Attachments are read from `self.internal_fs` (SandboxLocalFileSystem / VirtualFileSystem)
                and stored as base64 bytes on the email. This is safe to use in scenario event flows (post-init),
                but DO NOT add such emails during PAREScenario init state serialization.

        Returns:
            The email_id that was provided.
        """
        if attachment_paths is None:
            attachment_paths = []

        email = Email(
            email_id=email_id,
            sender=sender,
            recipients=[self.user_email],
            subject=subject,
            content=content,
            timestamp=self.time_manager.time(),
            is_read=False,
        )
        for path in attachment_paths:
            self.add_attachment(email=email, attachment_path=path)

        self.folders[EmailFolderName.INBOX].add_email(email)
        return email_id

    def create_root_state(self) -> MailboxView:
        """Return the mailbox view rooted in the inbox."""
        return MailboxView(folder=EmailFolderName.INBOX.value)

__init__(*args, **kwargs)

Initialise the email app with the inbox as the starting state.

Source code in pare/apps/email/app.py
22
23
24
25
26
def __init__(self, *args: Any, **kwargs: Any) -> None:
    """Initialise the email app with the inbox as the starting state."""
    super().__init__(*args, **kwargs)
    self.user_email = "john@pare.com"
    self.load_root_state()

create_root_state()

Return the mailbox view rooted in the inbox.

Source code in pare/apps/email/app.py
255
256
257
def create_root_state(self) -> MailboxView:
    """Return the mailbox view rooted in the inbox."""
    return MailboxView(folder=EmailFolderName.INBOX.value)

handle_state_transition(event)

Update navigation state in response to completed tool events.

Source code in pare/apps/email/app.py
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
def handle_state_transition(self, event: CompletedEvent) -> None:
    """Update navigation state in response to completed tool events."""
    current_state = self.current_state
    function_name = event.function_name()

    if current_state is None or function_name is None:  # pragma: no cover - defensive
        return

    action = event.action
    args = action.resolved_args or action.args
    if isinstance(current_state, MailboxView):
        self._handle_mailbox_transition(current_state, function_name, args, event)
        return

    if isinstance(current_state, EmailDetail):
        self._handle_detail_transition(function_name, event)
        if function_name in {"delete", "move"} and self.navigation_stack:
            self.go_back()
        return

    if isinstance(current_state, ComposeEmail):
        self._handle_compose_transition(function_name)

send_email_to_user_with_id(email_id, sender, subject='', content='', attachment_paths=None)

Create an incoming email with a specified ID (optionally with attachments) and add it to user's INBOX.

This is a PARE-specific environment tool that allows scenarios to reference the email_id in subsequent events (e.g., for replying to the email).

Parameters:

Name Type Description Default
email_id str

The ID to assign to this email.

required
sender str

The sender of the email.

required
subject str

The subject of the email.

''
content str

The content of the email.

''
attachment_paths list[str] | None

Optional list of attachment paths to add to the email. NOTE: Attachments are read from self.internal_fs (SandboxLocalFileSystem / VirtualFileSystem) and stored as base64 bytes on the email. This is safe to use in scenario event flows (post-init), but DO NOT add such emails during PAREScenario init state serialization.

None

Returns:

Type Description
str

The email_id that was provided.

Source code in pare/apps/email/app.py
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
@env_tool()
@event_registered(operation_type=OperationType.WRITE, event_type=EventType.ENV)
def send_email_to_user_with_id(
    self,
    email_id: str,
    sender: str,
    subject: str = "",
    content: str = "",
    attachment_paths: list[str] | None = None,
) -> str:
    """Create an incoming email with a specified ID (optionally with attachments) and add it to user's INBOX.

    This is a PARE-specific environment tool that allows scenarios to reference
    the email_id in subsequent events (e.g., for replying to the email).

    Args:
        email_id: The ID to assign to this email.
        sender: The sender of the email.
        subject: The subject of the email.
        content: The content of the email.
        attachment_paths: Optional list of attachment paths to add to the email.
            NOTE: Attachments are read from `self.internal_fs` (SandboxLocalFileSystem / VirtualFileSystem)
            and stored as base64 bytes on the email. This is safe to use in scenario event flows (post-init),
            but DO NOT add such emails during PAREScenario init state serialization.

    Returns:
        The email_id that was provided.
    """
    if attachment_paths is None:
        attachment_paths = []

    email = Email(
        email_id=email_id,
        sender=sender,
        recipients=[self.user_email],
        subject=subject,
        content=content,
        timestamp=self.time_manager.time(),
        is_read=False,
    )
    for path in attachment_paths:
        self.add_attachment(email=email, attachment_path=path)

    self.folders[EmailFolderName.INBOX].add_email(email)
    return email_id

send_reply_from_draft(draft)

Send a reply using the draft metadata, preserving user edits.

Source code in pare/apps/email/app.py
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
def send_reply_from_draft(self, draft: ComposeDraft) -> str:
    """Send a reply using the draft metadata, preserving user edits."""
    if not draft.reply_to:
        raise ValueError("Draft does not reference a reply target")

    attachments = draft.attachments or []
    folder_name = draft.reply_to_folder or EmailFolderName.INBOX.value
    recipients = draft.recipients or draft.default_recipients
    subject = draft.subject or draft.default_subject or ""
    cc = draft.cc

    return self._send_reply_email(
        email_id=draft.reply_to,
        folder_name=folder_name,
        content=draft.body,
        attachment_paths=attachments,
        recipients=recipients,
        subject=subject,
        cc=cc,
        fallback_recipients=draft.default_recipients,
        fallback_subject=draft.default_subject,
    )

Navigation state implementations for the stateful email app.

ComposeDraft dataclass

In-memory representation of an email draft during composition.

Source code in pare/apps/email/states.py
28
29
30
31
32
33
34
35
36
37
38
39
40
@dataclass
class ComposeDraft:
    """In-memory representation of an email draft during composition."""

    recipients: list[str] = field(default_factory=list)
    cc: list[str] = field(default_factory=list)
    subject: str = ""
    body: str = ""
    attachments: list[str] = field(default_factory=list)
    reply_to: str | None = None
    reply_to_folder: str | None = None
    default_recipients: list[str] = field(default_factory=list)
    default_subject: str | None = None

ComposeEmail

Bases: AppState

Compose state exposing draft editing and submission tools.

Source code in pare/apps/email/states.py
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
class ComposeEmail(AppState):
    """Compose state exposing draft editing and submission tools."""

    def __init__(self, draft: ComposeDraft | None = None) -> None:
        """Initialise the compose state with an optional existing draft."""
        super().__init__()
        self.draft = draft or ComposeDraft()
        if not self.draft.default_recipients:
            self.draft.default_recipients = list(self.draft.recipients)
        if self.draft.default_subject is None:
            self.draft.default_subject = self.draft.subject

    def on_enter(self) -> None:
        """Reset cached tools to ensure latest draft mutations are reflected."""
        self._cached_tools = None

    def on_exit(self) -> None:
        """Clear cached tools and reset draft state."""
        self._cached_tools = None

    def _mark_dirty(self) -> None:
        """Utility to invalidate cached tools after draft mutation."""
        self._cached_tools = None

    @user_tool()
    @pare_event_registered()
    def set_recipients(self, recipients: list[str]) -> dict[str, object]:
        """Replace the draft recipients list."""
        self.draft.recipients = recipients
        self._mark_dirty()
        return {"recipients": self.draft.recipients}

    @user_tool()
    @pare_event_registered()
    def add_recipient(self, recipient: str) -> dict[str, object]:
        """Append a single recipient to the draft."""
        self.draft.recipients.append(recipient)
        self._mark_dirty()
        return {"recipients": self.draft.recipients}

    @user_tool()
    @pare_event_registered()
    def set_cc(self, cc: list[str]) -> dict[str, object]:
        """Replace the CC list for the draft."""
        self.draft.cc = cc
        self._mark_dirty()
        return {"cc": self.draft.cc}

    @user_tool()
    @pare_event_registered()
    def set_subject(self, subject: str) -> dict[str, object]:
        """Update the subject line for the draft."""
        self.draft.subject = subject
        return {"subject": self.draft.subject}

    @user_tool()
    @pare_event_registered()
    def set_body(self, body: str) -> dict[str, object]:
        """Update the body content for the draft."""
        self.draft.body = body
        return {"body": self.draft.body}

    @user_tool()
    @pare_event_registered()
    def attach_file(self, attachment_path: str) -> dict[str, object]:
        """Attach a file path to the draft."""
        self.draft.attachments.append(attachment_path)
        return {"attachments": list(self.draft.attachments)}

    @user_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def send_composed_email(self) -> str:
        """Send the draft using the underlying email client."""
        attachments = self.draft.attachments or None

        if self.draft.reply_to:
            return self.app.send_reply_from_draft(self.draft)

        return self.app.send_email(
            recipients=self.draft.recipients,
            subject=self.draft.subject,
            content=self.draft.body,
            cc=self.draft.cc,
            attachment_paths=attachments,
        )

    @user_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def save_draft(self) -> str:
        """Persist the draft into the DRAFT folder."""
        return self.app.create_and_add_email(
            sender=self.app.user_email,
            recipients=self.draft.recipients,
            subject=self.draft.subject,
            content=self.draft.body,
            folder_name=EmailFolderName.DRAFT.value,
        )

    @user_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def discard_draft(self) -> str:
        """Discard the current draft without sending."""
        self.draft = ComposeDraft()
        return "draft_discarded"

__init__(draft=None)

Initialise the compose state with an optional existing draft.

Source code in pare/apps/email/states.py
229
230
231
232
233
234
235
236
def __init__(self, draft: ComposeDraft | None = None) -> None:
    """Initialise the compose state with an optional existing draft."""
    super().__init__()
    self.draft = draft or ComposeDraft()
    if not self.draft.default_recipients:
        self.draft.default_recipients = list(self.draft.recipients)
    if self.draft.default_subject is None:
        self.draft.default_subject = self.draft.subject

add_recipient(recipient)

Append a single recipient to the draft.

Source code in pare/apps/email/states.py
258
259
260
261
262
263
264
@user_tool()
@pare_event_registered()
def add_recipient(self, recipient: str) -> dict[str, object]:
    """Append a single recipient to the draft."""
    self.draft.recipients.append(recipient)
    self._mark_dirty()
    return {"recipients": self.draft.recipients}

attach_file(attachment_path)

Attach a file path to the draft.

Source code in pare/apps/email/states.py
288
289
290
291
292
293
@user_tool()
@pare_event_registered()
def attach_file(self, attachment_path: str) -> dict[str, object]:
    """Attach a file path to the draft."""
    self.draft.attachments.append(attachment_path)
    return {"attachments": list(self.draft.attachments)}

discard_draft()

Discard the current draft without sending.

Source code in pare/apps/email/states.py
324
325
326
327
328
329
@user_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def discard_draft(self) -> str:
    """Discard the current draft without sending."""
    self.draft = ComposeDraft()
    return "draft_discarded"

on_enter()

Reset cached tools to ensure latest draft mutations are reflected.

Source code in pare/apps/email/states.py
238
239
240
def on_enter(self) -> None:
    """Reset cached tools to ensure latest draft mutations are reflected."""
    self._cached_tools = None

on_exit()

Clear cached tools and reset draft state.

Source code in pare/apps/email/states.py
242
243
244
def on_exit(self) -> None:
    """Clear cached tools and reset draft state."""
    self._cached_tools = None

save_draft()

Persist the draft into the DRAFT folder.

Source code in pare/apps/email/states.py
312
313
314
315
316
317
318
319
320
321
322
@user_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def save_draft(self) -> str:
    """Persist the draft into the DRAFT folder."""
    return self.app.create_and_add_email(
        sender=self.app.user_email,
        recipients=self.draft.recipients,
        subject=self.draft.subject,
        content=self.draft.body,
        folder_name=EmailFolderName.DRAFT.value,
    )

send_composed_email()

Send the draft using the underlying email client.

Source code in pare/apps/email/states.py
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
@user_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def send_composed_email(self) -> str:
    """Send the draft using the underlying email client."""
    attachments = self.draft.attachments or None

    if self.draft.reply_to:
        return self.app.send_reply_from_draft(self.draft)

    return self.app.send_email(
        recipients=self.draft.recipients,
        subject=self.draft.subject,
        content=self.draft.body,
        cc=self.draft.cc,
        attachment_paths=attachments,
    )

set_body(body)

Update the body content for the draft.

Source code in pare/apps/email/states.py
281
282
283
284
285
286
@user_tool()
@pare_event_registered()
def set_body(self, body: str) -> dict[str, object]:
    """Update the body content for the draft."""
    self.draft.body = body
    return {"body": self.draft.body}

set_cc(cc)

Replace the CC list for the draft.

Source code in pare/apps/email/states.py
266
267
268
269
270
271
272
@user_tool()
@pare_event_registered()
def set_cc(self, cc: list[str]) -> dict[str, object]:
    """Replace the CC list for the draft."""
    self.draft.cc = cc
    self._mark_dirty()
    return {"cc": self.draft.cc}

set_recipients(recipients)

Replace the draft recipients list.

Source code in pare/apps/email/states.py
250
251
252
253
254
255
256
@user_tool()
@pare_event_registered()
def set_recipients(self, recipients: list[str]) -> dict[str, object]:
    """Replace the draft recipients list."""
    self.draft.recipients = recipients
    self._mark_dirty()
    return {"recipients": self.draft.recipients}

set_subject(subject)

Update the subject line for the draft.

Source code in pare/apps/email/states.py
274
275
276
277
278
279
@user_tool()
@pare_event_registered()
def set_subject(self, subject: str) -> dict[str, object]:
    """Update the subject line for the draft."""
    self.draft.subject = subject
    return {"subject": self.draft.subject}

EmailDetail

Bases: AppState

Email detail state allowing follow-up actions on a single email.

Source code in pare/apps/email/states.py
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
class EmailDetail(AppState):
    """Email detail state allowing follow-up actions on a single email."""

    def __init__(self, email_id: str, folder_name: str = EmailFolderName.INBOX.value) -> None:
        """Bind the detail view to a specific email and folder."""
        super().__init__()
        self.email_id = email_id
        self.folder_name = _normalise_folder(folder_name)
        self._email: Email | None = None

    def on_enter(self) -> None:
        """Attempt to refresh cached email details on entry."""
        self._email = self.app.get_email_by_id(email_id=self.email_id, folder_name=self.folder_name)

    def on_exit(self) -> None:
        """Clear cached email data when leaving the detail view."""
        # Keep cached email so that go_back restores the state without requiring
        # a fresh fetch. The cache is refreshed on demand via refresh().

    @property
    def email(self) -> Email | None:
        """Return the cached email if available."""
        return self._email

    @user_tool()
    @pare_event_registered()
    def refresh(self) -> Email:
        """Fetch the latest version of the current email."""
        self._email = self.app.get_email_by_id(email_id=self.email_id, folder_name=self.folder_name)
        return self._email

    @user_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def reply(self, content: str = "", attachment_paths: list[str] | None = None) -> str:
        """Send a reply to the current email."""
        with disable_events():
            return self.app.reply_to_email(
                email_id=self.email_id, folder_name=self.folder_name, content=content, attachment_paths=attachment_paths
            )

    @user_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def forward(self, recipients: list[str]) -> str:
        """Forward the current email to new recipients."""
        with disable_events():
            return self.app.forward_email(email_id=self.email_id, recipients=recipients, folder_name=self.folder_name)

    @user_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def move(self, destination_folder: str) -> str:
        """Move the current email to a different folder."""
        with disable_events():
            return self.app.move_email(
                email_id=self.email_id,
                source_folder_name=self.folder_name,
                dest_folder_name=_normalise_folder(destination_folder),
            )

    @user_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def delete(self) -> str:
        """Delete the current email (moves it to trash)."""
        with disable_events():
            return self.app.delete_email(email_id=self.email_id, folder_name=self.folder_name)

    @user_tool()
    @pare_event_registered()
    def download_attachments(self, path_to_save: str) -> list[str]:
        """Download attachments from the current email to a path."""
        with disable_events():
            return self.app.download_attachments(
                email_id=self.email_id, folder_name=self.folder_name, path_to_save=path_to_save
            )

    @user_tool()
    @pare_event_registered()
    def start_compose_reply(self) -> dict[str, object]:
        """Return metadata required to seed a reply draft in compose view."""
        email = self.email
        if email is None:
            return {"draft": None}

        return {
            "draft": {
                "recipients": [email.sender],
                "subject": f"Re: {email.subject}",
                "body": "",
                "reply_to": email.email_id,
                "folder_name": self.folder_name,
            }
        }

email property

Return the cached email if available.

__init__(email_id, folder_name=EmailFolderName.INBOX.value)

Bind the detail view to a specific email and folder.

Source code in pare/apps/email/states.py
136
137
138
139
140
141
def __init__(self, email_id: str, folder_name: str = EmailFolderName.INBOX.value) -> None:
    """Bind the detail view to a specific email and folder."""
    super().__init__()
    self.email_id = email_id
    self.folder_name = _normalise_folder(folder_name)
    self._email: Email | None = None

delete()

Delete the current email (moves it to trash).

Source code in pare/apps/email/states.py
191
192
193
194
195
196
@user_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def delete(self) -> str:
    """Delete the current email (moves it to trash)."""
    with disable_events():
        return self.app.delete_email(email_id=self.email_id, folder_name=self.folder_name)

download_attachments(path_to_save)

Download attachments from the current email to a path.

Source code in pare/apps/email/states.py
198
199
200
201
202
203
204
205
@user_tool()
@pare_event_registered()
def download_attachments(self, path_to_save: str) -> list[str]:
    """Download attachments from the current email to a path."""
    with disable_events():
        return self.app.download_attachments(
            email_id=self.email_id, folder_name=self.folder_name, path_to_save=path_to_save
        )

forward(recipients)

Forward the current email to new recipients.

Source code in pare/apps/email/states.py
173
174
175
176
177
178
@user_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def forward(self, recipients: list[str]) -> str:
    """Forward the current email to new recipients."""
    with disable_events():
        return self.app.forward_email(email_id=self.email_id, recipients=recipients, folder_name=self.folder_name)

move(destination_folder)

Move the current email to a different folder.

Source code in pare/apps/email/states.py
180
181
182
183
184
185
186
187
188
189
@user_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def move(self, destination_folder: str) -> str:
    """Move the current email to a different folder."""
    with disable_events():
        return self.app.move_email(
            email_id=self.email_id,
            source_folder_name=self.folder_name,
            dest_folder_name=_normalise_folder(destination_folder),
        )

on_enter()

Attempt to refresh cached email details on entry.

Source code in pare/apps/email/states.py
143
144
145
def on_enter(self) -> None:
    """Attempt to refresh cached email details on entry."""
    self._email = self.app.get_email_by_id(email_id=self.email_id, folder_name=self.folder_name)

on_exit()

Clear cached email data when leaving the detail view.

Source code in pare/apps/email/states.py
147
148
def on_exit(self) -> None:
    """Clear cached email data when leaving the detail view."""

refresh()

Fetch the latest version of the current email.

Source code in pare/apps/email/states.py
157
158
159
160
161
162
@user_tool()
@pare_event_registered()
def refresh(self) -> Email:
    """Fetch the latest version of the current email."""
    self._email = self.app.get_email_by_id(email_id=self.email_id, folder_name=self.folder_name)
    return self._email

reply(content='', attachment_paths=None)

Send a reply to the current email.

Source code in pare/apps/email/states.py
164
165
166
167
168
169
170
171
@user_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def reply(self, content: str = "", attachment_paths: list[str] | None = None) -> str:
    """Send a reply to the current email."""
    with disable_events():
        return self.app.reply_to_email(
            email_id=self.email_id, folder_name=self.folder_name, content=content, attachment_paths=attachment_paths
        )

start_compose_reply()

Return metadata required to seed a reply draft in compose view.

Source code in pare/apps/email/states.py
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
@user_tool()
@pare_event_registered()
def start_compose_reply(self) -> dict[str, object]:
    """Return metadata required to seed a reply draft in compose view."""
    email = self.email
    if email is None:
        return {"draft": None}

    return {
        "draft": {
            "recipients": [email.sender],
            "subject": f"Re: {email.subject}",
            "body": "",
            "reply_to": email.email_id,
            "folder_name": self.folder_name,
        }
    }

MailboxView

Bases: AppState

Mailbox listing state exposing folder-scoped navigation actions.

Source code in pare/apps/email/states.py
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
class MailboxView(AppState):
    """Mailbox listing state exposing folder-scoped navigation actions."""

    def __init__(self, folder: str = EmailFolderName.INBOX.value) -> None:
        """Initialise the mailbox view with the provided folder."""
        super().__init__()
        self.folder = _normalise_folder(folder)

    def on_enter(self) -> None:
        """No-op hook; future implementations may pre-fetch folder metadata."""

    def on_exit(self) -> None:
        """No-op hook for symmetry with on_enter."""

    @user_tool()
    @pare_event_registered()
    def list_emails(self, offset: int = 0, limit: int = 10) -> ReturnedEmails:
        """List emails in the current folder with pagination support."""
        with disable_events():
            emails = self.app.list_emails(folder_name=self.folder, offset=offset, limit=limit)

        logger.debug(f"Listed emails: {emails.emails}")

        return emails

    @user_tool()
    @pare_event_registered()
    def search_emails(
        self, query: str, min_date: str | None = None, max_date: str | None = None, limit: int | None = 10
    ) -> list[Email]:
        """Search for emails within the current folder.

        min/max filters are applied client-side because the backend API does not
        expose them. Invalid date strings are ignored.
        """
        results = self.app.search_emails(query=query, folder_name=self.folder)

        def to_timestamp(date_str: str | None) -> float | None:
            if not date_str:
                return None
            try:
                return datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC).timestamp()
            except ValueError:
                return None

        min_ts = to_timestamp(min_date)
        max_ts = to_timestamp(max_date)

        filtered: list[Email] = []
        for email in results:
            if min_ts is not None and email.timestamp < min_ts:
                continue
            if max_ts is not None and email.timestamp > max_ts:
                continue
            filtered.append(email)

        if limit is not None and limit >= 0:
            return filtered[:limit]

        return filtered

    @user_tool()
    @pare_event_registered()
    def open_email_by_id(self, email_id: str) -> Email:
        """Open a specific email by id within the current folder."""
        with disable_events():
            return self.app.get_email_by_id(email_id=email_id, folder_name=self.folder)

    @user_tool()
    @pare_event_registered()
    def open_email_by_index(self, index: int) -> Email:
        """Open a specific email by index within the current folder."""
        with disable_events():
            return self.app.get_email_by_index(idx=index, folder_name=self.folder)

    @user_tool()
    @pare_event_registered()
    def switch_folder(self, folder_name: str) -> ReturnedEmails:
        """Switch to a different folder and return its contents."""
        target_folder = _normalise_folder(folder_name)
        with disable_events():
            return self.app.list_emails(folder_name=target_folder)

    @user_tool()
    @pare_event_registered()
    def start_compose(self) -> str:
        """Begin a new compose flow originating from the mailbox view."""
        return "compose_started"

__init__(folder=EmailFolderName.INBOX.value)

Initialise the mailbox view with the provided folder.

Source code in pare/apps/email/states.py
46
47
48
49
def __init__(self, folder: str = EmailFolderName.INBOX.value) -> None:
    """Initialise the mailbox view with the provided folder."""
    super().__init__()
    self.folder = _normalise_folder(folder)

list_emails(offset=0, limit=10)

List emails in the current folder with pagination support.

Source code in pare/apps/email/states.py
57
58
59
60
61
62
63
64
65
66
@user_tool()
@pare_event_registered()
def list_emails(self, offset: int = 0, limit: int = 10) -> ReturnedEmails:
    """List emails in the current folder with pagination support."""
    with disable_events():
        emails = self.app.list_emails(folder_name=self.folder, offset=offset, limit=limit)

    logger.debug(f"Listed emails: {emails.emails}")

    return emails

on_enter()

No-op hook; future implementations may pre-fetch folder metadata.

Source code in pare/apps/email/states.py
51
52
def on_enter(self) -> None:
    """No-op hook; future implementations may pre-fetch folder metadata."""

on_exit()

No-op hook for symmetry with on_enter.

Source code in pare/apps/email/states.py
54
55
def on_exit(self) -> None:
    """No-op hook for symmetry with on_enter."""

open_email_by_id(email_id)

Open a specific email by id within the current folder.

Source code in pare/apps/email/states.py
104
105
106
107
108
109
@user_tool()
@pare_event_registered()
def open_email_by_id(self, email_id: str) -> Email:
    """Open a specific email by id within the current folder."""
    with disable_events():
        return self.app.get_email_by_id(email_id=email_id, folder_name=self.folder)

open_email_by_index(index)

Open a specific email by index within the current folder.

Source code in pare/apps/email/states.py
111
112
113
114
115
116
@user_tool()
@pare_event_registered()
def open_email_by_index(self, index: int) -> Email:
    """Open a specific email by index within the current folder."""
    with disable_events():
        return self.app.get_email_by_index(idx=index, folder_name=self.folder)

search_emails(query, min_date=None, max_date=None, limit=10)

Search for emails within the current folder.

min/max filters are applied client-side because the backend API does not expose them. Invalid date strings are ignored.

Source code in pare/apps/email/states.py
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
@user_tool()
@pare_event_registered()
def search_emails(
    self, query: str, min_date: str | None = None, max_date: str | None = None, limit: int | None = 10
) -> list[Email]:
    """Search for emails within the current folder.

    min/max filters are applied client-side because the backend API does not
    expose them. Invalid date strings are ignored.
    """
    results = self.app.search_emails(query=query, folder_name=self.folder)

    def to_timestamp(date_str: str | None) -> float | None:
        if not date_str:
            return None
        try:
            return datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC).timestamp()
        except ValueError:
            return None

    min_ts = to_timestamp(min_date)
    max_ts = to_timestamp(max_date)

    filtered: list[Email] = []
    for email in results:
        if min_ts is not None and email.timestamp < min_ts:
            continue
        if max_ts is not None and email.timestamp > max_ts:
            continue
        filtered.append(email)

    if limit is not None and limit >= 0:
        return filtered[:limit]

    return filtered

start_compose()

Begin a new compose flow originating from the mailbox view.

Source code in pare/apps/email/states.py
126
127
128
129
130
@user_tool()
@pare_event_registered()
def start_compose(self) -> str:
    """Begin a new compose flow originating from the mailbox view."""
    return "compose_started"

switch_folder(folder_name)

Switch to a different folder and return its contents.

Source code in pare/apps/email/states.py
118
119
120
121
122
123
124
@user_tool()
@pare_event_registered()
def switch_folder(self, folder_name: str) -> ReturnedEmails:
    """Switch to a different folder and return its contents."""
    target_folder = _normalise_folder(folder_name)
    with disable_events():
        return self.app.list_emails(folder_name=target_folder)

Calendar App

Stateful calendar app combining Meta-ARE calendar backend with PARE navigation.

StatefulCalendarApp

Bases: StatefulApp, CalendarV2

Calendar client with navigation-aware user tool exposure.

Source code in pare/apps/calendar/app.py
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
class StatefulCalendarApp(StatefulApp, CalendarV2):
    """Calendar client with navigation-aware user tool exposure."""

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        """Initialise the calendar app and seed the agenda with the current day."""
        super().__init__(*args, **kwargs)
        self.load_root_state()

    def _default_day_range(self) -> tuple[str, str]:
        """Derive the UTC day range surrounding the current simulated time."""
        now = datetime.fromtimestamp(self.time_manager.time(), tz=UTC)
        start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0)
        end_of_day = start_of_day + timedelta(days=1)
        return (start_of_day.strftime(DATETIME_FORMAT), end_of_day.strftime(DATETIME_FORMAT))

    def _resolve_event_id(self, args: dict[str, Any], metadata: object | None) -> str | None:
        event_id = args.get("event_id")
        if isinstance(event_id, str):
            return event_id
        if isinstance(metadata, CalendarEvent):
            return metadata.event_id
        if isinstance(metadata, dict):
            candidate = metadata.get("event")
            if isinstance(candidate, CalendarEvent):
                return candidate.event_id
        return None

    @staticmethod
    def _draft_from_metadata(metadata_value: object | None) -> EditDraft | None:
        if not isinstance(metadata_value, dict):
            return None
        draft_data = metadata_value.get("draft")
        if isinstance(draft_data, EditDraft):
            return draft_data
        if isinstance(draft_data, dict):
            return EditDraft(
                event_id=draft_data.get("event_id"),
                title=draft_data.get("title", "Event"),
                start_datetime=draft_data.get("start_datetime"),
                end_datetime=draft_data.get("end_datetime"),
                tag=draft_data.get("tag"),
                description=draft_data.get("description"),
                location=draft_data.get("location"),
                attendees=list(draft_data.get("attendees", [])),
            )
        return None

    # pylint: disable=too-many-branches
    def handle_state_transition(self, event: CompletedEvent) -> None:
        """Update navigation state in response to user tool completions."""
        current_state = self.current_state
        function_name = event.function_name()

        if current_state is None or function_name is None:  # pragma: no cover - defensive
            return

        action = event.action
        args = action.resolved_args or action.args
        metadata_value = event.metadata.return_value if event.metadata else None

        if isinstance(current_state, AgendaView):
            self._handle_agenda_transition(current_state, function_name, args, metadata_value)
            return

        if isinstance(current_state, EventDetail):
            self._handle_event_detail_transition(function_name, metadata_value)
            return

        if isinstance(current_state, EditEvent):
            self._handle_edit_event_transition(function_name, metadata_value)

    def _handle_agenda_transition(
        self, current_state: AgendaView, function_name: str, args: dict[str, Any], metadata_value: object | None
    ) -> None:
        """Process agenda-specific transitions."""
        if function_name in {"open_event_by_id", "open_event_by_index"}:
            event_id = self._resolve_event_id(args, metadata_value)
            if event_id:
                self.set_current_state(EventDetail(event_id=event_id))
            return

        if function_name == "start_create_event":
            self.set_current_state(EditEvent(draft=EditDraft()))
            return

        if function_name == "filter_by_tag":
            tag = args.get("tag")
            self.set_current_state(
                AgendaView(
                    start_datetime=current_state.start_datetime,
                    end_datetime=current_state.end_datetime,
                    tag_filter=tag,
                    attendee_filter=current_state.attendee_filter,
                )
            )
            return

        if function_name == "filter_by_attendee":
            attendee = args.get("attendee") or args.get("name")
            self.set_current_state(
                AgendaView(
                    start_datetime=current_state.start_datetime,
                    end_datetime=current_state.end_datetime,
                    tag_filter=current_state.tag_filter,
                    attendee_filter=attendee,
                )
            )
            return

        if function_name == "set_day" and isinstance(metadata_value, dict):
            start = metadata_value.get("start_datetime")
            end = metadata_value.get("end_datetime")
            if isinstance(start, str) and isinstance(end, str):
                self.set_current_state(
                    AgendaView(
                        start_datetime=start,
                        end_datetime=end,
                        tag_filter=current_state.tag_filter,
                        attendee_filter=current_state.attendee_filter,
                    )
                )

    def _handle_event_detail_transition(self, function_name: str, metadata_value: object | None) -> None:
        """Process transitions that originate from the event detail view."""
        if function_name == "edit_event":
            draft = self._draft_from_metadata(metadata_value)
            self.set_current_state(EditEvent(draft=draft))
            return

        if function_name in {"delete", "delete_by_attendee"} and self.navigation_stack:
            self.go_back()

    def _handle_edit_event_transition(self, function_name: str, metadata_value: object | None) -> None:
        """Process transitions that originate from the event edit view."""
        if function_name == "save":
            event_id = metadata_value if isinstance(metadata_value, str) else None
            previous = self.navigation_stack[-1] if self.navigation_stack else None
            if isinstance(previous, EventDetail):
                if event_id:
                    previous.event_id = event_id
                previous.refresh()
            if self.navigation_stack:
                self.go_back()
            else:  # pragma: no cover - defensive fallback
                self._reset_to_default_agenda()
            return

        if function_name == "discard":
            if self.navigation_stack:
                self.go_back()
            else:  # pragma: no cover - defensive fallback
                self._reset_to_default_agenda()

    def _reset_to_default_agenda(self) -> None:
        """Return the app to the default day agenda view."""
        self.load_root_state()

    def create_root_state(self) -> AgendaView:
        """Return the root agenda view scoped to the current day."""
        start, end = self._default_day_range()
        return AgendaView(start_datetime=start, end_datetime=end)

    def get_state_graph(self) -> dict[str, list[str]]:
        """Return the navigation graph for the calendar app."""
        raise NotImplementedError

    def get_reachable_states(self, from_state: AppState) -> list[type[AppState]]:  # pragma: no cover - placeholder
        """Return the reachable states from the provided state."""
        raise NotImplementedError

__init__(*args, **kwargs)

Initialise the calendar app and seed the agenda with the current day.

Source code in pare/apps/calendar/app.py
23
24
25
26
def __init__(self, *args: Any, **kwargs: Any) -> None:
    """Initialise the calendar app and seed the agenda with the current day."""
    super().__init__(*args, **kwargs)
    self.load_root_state()

create_root_state()

Return the root agenda view scoped to the current day.

Source code in pare/apps/calendar/app.py
177
178
179
180
def create_root_state(self) -> AgendaView:
    """Return the root agenda view scoped to the current day."""
    start, end = self._default_day_range()
    return AgendaView(start_datetime=start, end_datetime=end)

get_reachable_states(from_state)

Return the reachable states from the provided state.

Source code in pare/apps/calendar/app.py
186
187
188
def get_reachable_states(self, from_state: AppState) -> list[type[AppState]]:  # pragma: no cover - placeholder
    """Return the reachable states from the provided state."""
    raise NotImplementedError

get_state_graph()

Return the navigation graph for the calendar app.

Source code in pare/apps/calendar/app.py
182
183
184
def get_state_graph(self) -> dict[str, list[str]]:
    """Return the navigation graph for the calendar app."""
    raise NotImplementedError

handle_state_transition(event)

Update navigation state in response to user tool completions.

Source code in pare/apps/calendar/app.py
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
def handle_state_transition(self, event: CompletedEvent) -> None:
    """Update navigation state in response to user tool completions."""
    current_state = self.current_state
    function_name = event.function_name()

    if current_state is None or function_name is None:  # pragma: no cover - defensive
        return

    action = event.action
    args = action.resolved_args or action.args
    metadata_value = event.metadata.return_value if event.metadata else None

    if isinstance(current_state, AgendaView):
        self._handle_agenda_transition(current_state, function_name, args, metadata_value)
        return

    if isinstance(current_state, EventDetail):
        self._handle_event_detail_transition(function_name, metadata_value)
        return

    if isinstance(current_state, EditEvent):
        self._handle_edit_event_transition(function_name, metadata_value)

Navigation state implementations for the stateful calendar app.

AgendaView

Bases: AppState

Calendar listing view for a specific time window and optional filters.

Source code in pare/apps/calendar/states.py
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
class AgendaView(AppState):
    """Calendar listing view for a specific time window and optional filters."""

    def __init__(
        self,
        *,
        start_datetime: str,
        end_datetime: str,
        tag_filter: str | None = None,
        attendee_filter: str | None = None,
    ) -> None:
        """Create an agenda view scoped to a datetime window and optional filters."""
        super().__init__()
        self.start_datetime = start_datetime
        self.end_datetime = end_datetime
        self.tag_filter = tag_filter
        self.attendee_filter = attendee_filter

    def on_enter(self) -> None:
        """No-op hook reserved for future caching."""

    def on_exit(self) -> None:
        """No cleanup required when leaving the agenda view."""

    def _events_in_window(self) -> list[CalendarEvent]:
        result = self.app.get_calendar_events_from_to(self.start_datetime, self.end_datetime)
        events: list[CalendarEvent] = result["events"] if isinstance(result, dict) else []
        if self.tag_filter:
            events = [event for event in events if event.tag == self.tag_filter]
        if self.attendee_filter:
            events = [
                event for event in events if any(att.lower() == self.attendee_filter.lower() for att in event.attendees)
            ]
        return events

    @user_tool()
    @pare_event_registered()
    def list_events(self, offset: int = 0, limit: int = 10) -> dict[str, object]:
        """List events within the active time window respecting current filters."""
        result = self.app.get_calendar_events_from_to(
            self.start_datetime, self.end_datetime, offset=offset, limit=limit
        )
        events = cast("list[CalendarEvent]", result.get("events", []))
        filtered = events
        if self.tag_filter:
            filtered = [event for event in filtered if event.tag == self.tag_filter]
        if self.attendee_filter:
            filtered = [
                event
                for event in filtered
                if any(att.lower() == self.attendee_filter.lower() for att in event.attendees)
            ]
        return {"events": filtered, "range": result.get("range"), "total": len(filtered)}

    @user_tool()
    @pare_event_registered()
    def search_events(self, query: str) -> list[CalendarEvent]:
        """Search events, applying local filters and window constraints."""
        matches = self.app.search_events(query=query)
        start = _utc_datetime_from_str(self.start_datetime)
        end = _utc_datetime_from_str(self.end_datetime)
        filtered: list[CalendarEvent] = []
        for event in matches:
            if start and event.end_datetime < start.timestamp():
                continue
            if end and event.start_datetime > end.timestamp():
                continue
            if self.tag_filter and event.tag != self.tag_filter:
                continue
            if self.attendee_filter and not any(att.lower() == self.attendee_filter.lower() for att in event.attendees):
                continue
            filtered.append(event)
        return filtered

    @user_tool()
    @pare_event_registered()
    def open_event_by_id(self, event_id: str) -> CalendarEvent:
        """Open an event by identifier within the current window."""
        return self.app.get_calendar_event(event_id=event_id)

    @user_tool()
    @pare_event_registered()
    def open_event_by_index(self, index: int) -> CalendarEvent:
        """Open the n-th event in the current window according to ordering."""
        events = self._events_in_window()
        if index < 0 or index >= len(events):
            raise IndexError("Event index out of range")
        return events[index]

    @user_tool()
    @pare_event_registered()
    def filter_by_tag(self, tag: str) -> list[CalendarEvent]:
        """Preview events with a specific tag."""
        return [event for event in self._events_in_window() if event.tag == tag]

    @user_tool()
    @pare_event_registered()
    def filter_by_attendee(self, attendee: str) -> list[CalendarEvent]:
        """Preview events containing a specific attendee."""
        attendee_lower = attendee.lower()
        return [
            event for event in self._events_in_window() if any(att.lower() == attendee_lower for att in event.attendees)
        ]

    @user_tool()
    @pare_event_registered()
    def add_calendar_event_by_attendee(
        self,
        who_add: str,
        title: str = "Event",
        start_datetime: str | None = None,
        end_datetime: str | None = None,
        tag: str | None = None,
        description: str | None = None,
        location: str | None = None,
        attendees: list[str] | None = None,
    ) -> str:
        """Create an event on behalf of a specific attendee."""
        return self.app.add_calendar_event_by_attendee(
            who_add=who_add,
            title=title,
            start_datetime=start_datetime,
            end_datetime=end_datetime,
            tag=tag,
            description=description,
            location=location,
            attendees=attendees,
        )

    @user_tool()
    @pare_event_registered()
    def read_today_calendar_events(self) -> dict[str, object]:
        """Return today's events via the backend helper."""
        return self.app.read_today_calendar_events()

    @user_tool()
    @pare_event_registered()
    def get_all_tags(self) -> list[str]:
        """List all tags present in the calendar."""
        return self.app.get_all_tags()

    @user_tool()
    @pare_event_registered()
    def get_calendar_events_by_tag(self, tag: str) -> list[CalendarEvent]:
        """Fetch events associated with a specific tag directly from the backend."""
        return self.app.get_calendar_events_by_tag(tag=tag)

    @user_tool()
    @pare_event_registered()
    def set_day(self, date: str) -> dict[str, str]:
        """Switch the agenda view to the supplied UTC date string (YYYY-MM-DD)."""
        day = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=UTC)
        start, end = _normalise_range(day, day + timedelta(days=1))
        return {"start_datetime": start, "end_datetime": end}

    @user_tool()
    @pare_event_registered()
    def start_create_event(self) -> str:
        """Begin a new event creation flow."""
        return "draft_started"

__init__(*, start_datetime, end_datetime, tag_filter=None, attendee_filter=None)

Create an agenda view scoped to a datetime window and optional filters.

Source code in pare/apps/calendar/states.py
50
51
52
53
54
55
56
57
58
59
60
61
62
63
def __init__(
    self,
    *,
    start_datetime: str,
    end_datetime: str,
    tag_filter: str | None = None,
    attendee_filter: str | None = None,
) -> None:
    """Create an agenda view scoped to a datetime window and optional filters."""
    super().__init__()
    self.start_datetime = start_datetime
    self.end_datetime = end_datetime
    self.tag_filter = tag_filter
    self.attendee_filter = attendee_filter

add_calendar_event_by_attendee(who_add, title='Event', start_datetime=None, end_datetime=None, tag=None, description=None, location=None, attendees=None)

Create an event on behalf of a specific attendee.

Source code in pare/apps/calendar/states.py
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
@user_tool()
@pare_event_registered()
def add_calendar_event_by_attendee(
    self,
    who_add: str,
    title: str = "Event",
    start_datetime: str | None = None,
    end_datetime: str | None = None,
    tag: str | None = None,
    description: str | None = None,
    location: str | None = None,
    attendees: list[str] | None = None,
) -> str:
    """Create an event on behalf of a specific attendee."""
    return self.app.add_calendar_event_by_attendee(
        who_add=who_add,
        title=title,
        start_datetime=start_datetime,
        end_datetime=end_datetime,
        tag=tag,
        description=description,
        location=location,
        attendees=attendees,
    )

filter_by_attendee(attendee)

Preview events containing a specific attendee.

Source code in pare/apps/calendar/states.py
142
143
144
145
146
147
148
149
@user_tool()
@pare_event_registered()
def filter_by_attendee(self, attendee: str) -> list[CalendarEvent]:
    """Preview events containing a specific attendee."""
    attendee_lower = attendee.lower()
    return [
        event for event in self._events_in_window() if any(att.lower() == attendee_lower for att in event.attendees)
    ]

filter_by_tag(tag)

Preview events with a specific tag.

Source code in pare/apps/calendar/states.py
136
137
138
139
140
@user_tool()
@pare_event_registered()
def filter_by_tag(self, tag: str) -> list[CalendarEvent]:
    """Preview events with a specific tag."""
    return [event for event in self._events_in_window() if event.tag == tag]

get_all_tags()

List all tags present in the calendar.

Source code in pare/apps/calendar/states.py
182
183
184
185
186
@user_tool()
@pare_event_registered()
def get_all_tags(self) -> list[str]:
    """List all tags present in the calendar."""
    return self.app.get_all_tags()

get_calendar_events_by_tag(tag)

Fetch events associated with a specific tag directly from the backend.

Source code in pare/apps/calendar/states.py
188
189
190
191
192
@user_tool()
@pare_event_registered()
def get_calendar_events_by_tag(self, tag: str) -> list[CalendarEvent]:
    """Fetch events associated with a specific tag directly from the backend."""
    return self.app.get_calendar_events_by_tag(tag=tag)

list_events(offset=0, limit=10)

List events within the active time window respecting current filters.

Source code in pare/apps/calendar/states.py
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
@user_tool()
@pare_event_registered()
def list_events(self, offset: int = 0, limit: int = 10) -> dict[str, object]:
    """List events within the active time window respecting current filters."""
    result = self.app.get_calendar_events_from_to(
        self.start_datetime, self.end_datetime, offset=offset, limit=limit
    )
    events = cast("list[CalendarEvent]", result.get("events", []))
    filtered = events
    if self.tag_filter:
        filtered = [event for event in filtered if event.tag == self.tag_filter]
    if self.attendee_filter:
        filtered = [
            event
            for event in filtered
            if any(att.lower() == self.attendee_filter.lower() for att in event.attendees)
        ]
    return {"events": filtered, "range": result.get("range"), "total": len(filtered)}

on_enter()

No-op hook reserved for future caching.

Source code in pare/apps/calendar/states.py
65
66
def on_enter(self) -> None:
    """No-op hook reserved for future caching."""

on_exit()

No cleanup required when leaving the agenda view.

Source code in pare/apps/calendar/states.py
68
69
def on_exit(self) -> None:
    """No cleanup required when leaving the agenda view."""

open_event_by_id(event_id)

Open an event by identifier within the current window.

Source code in pare/apps/calendar/states.py
121
122
123
124
125
@user_tool()
@pare_event_registered()
def open_event_by_id(self, event_id: str) -> CalendarEvent:
    """Open an event by identifier within the current window."""
    return self.app.get_calendar_event(event_id=event_id)

open_event_by_index(index)

Open the n-th event in the current window according to ordering.

Source code in pare/apps/calendar/states.py
127
128
129
130
131
132
133
134
@user_tool()
@pare_event_registered()
def open_event_by_index(self, index: int) -> CalendarEvent:
    """Open the n-th event in the current window according to ordering."""
    events = self._events_in_window()
    if index < 0 or index >= len(events):
        raise IndexError("Event index out of range")
    return events[index]

read_today_calendar_events()

Return today's events via the backend helper.

Source code in pare/apps/calendar/states.py
176
177
178
179
180
@user_tool()
@pare_event_registered()
def read_today_calendar_events(self) -> dict[str, object]:
    """Return today's events via the backend helper."""
    return self.app.read_today_calendar_events()

search_events(query)

Search events, applying local filters and window constraints.

Source code in pare/apps/calendar/states.py
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
@user_tool()
@pare_event_registered()
def search_events(self, query: str) -> list[CalendarEvent]:
    """Search events, applying local filters and window constraints."""
    matches = self.app.search_events(query=query)
    start = _utc_datetime_from_str(self.start_datetime)
    end = _utc_datetime_from_str(self.end_datetime)
    filtered: list[CalendarEvent] = []
    for event in matches:
        if start and event.end_datetime < start.timestamp():
            continue
        if end and event.start_datetime > end.timestamp():
            continue
        if self.tag_filter and event.tag != self.tag_filter:
            continue
        if self.attendee_filter and not any(att.lower() == self.attendee_filter.lower() for att in event.attendees):
            continue
        filtered.append(event)
    return filtered

set_day(date)

Switch the agenda view to the supplied UTC date string (YYYY-MM-DD).

Source code in pare/apps/calendar/states.py
194
195
196
197
198
199
200
@user_tool()
@pare_event_registered()
def set_day(self, date: str) -> dict[str, str]:
    """Switch the agenda view to the supplied UTC date string (YYYY-MM-DD)."""
    day = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=UTC)
    start, end = _normalise_range(day, day + timedelta(days=1))
    return {"start_datetime": start, "end_datetime": end}

start_create_event()

Begin a new event creation flow.

Source code in pare/apps/calendar/states.py
202
203
204
205
206
@user_tool()
@pare_event_registered()
def start_create_event(self) -> str:
    """Begin a new event creation flow."""
    return "draft_started"

EditDraft dataclass

Mutable draft representation for creating or editing events.

Source code in pare/apps/calendar/states.py
33
34
35
36
37
38
39
40
41
42
43
44
@dataclass
class EditDraft:
    """Mutable draft representation for creating or editing events."""

    title: str = "Event"
    start_datetime: str | None = None
    end_datetime: str | None = None
    tag: str | None = None
    description: str | None = None
    location: str | None = None
    attendees: list[str] = field(default_factory=list)
    event_id: str | None = None

EditEvent

Bases: AppState

Compose/edit state for calendar events.

Source code in pare/apps/calendar/states.py
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
class EditEvent(AppState):
    """Compose/edit state for calendar events."""

    def __init__(self, draft: EditDraft | None = None) -> None:
        """Initialise the edit state, optionally seeding it with an existing draft."""
        super().__init__()
        if draft is None:
            self.draft = EditDraft()
        else:
            self.draft = replace(draft)
            self.draft.attendees = list(draft.attendees)
        original = replace(self.draft)
        original.attendees = list(self.draft.attendees)
        self._original = original

    def on_enter(self) -> None:
        """Invalidate cached tools to pick up latest draft mutations."""
        self._cached_tools = None

    def on_exit(self) -> None:
        """Reset tool cache on exit."""
        self._cached_tools = None

    def _mark_dirty(self) -> None:
        self._cached_tools = None

    @user_tool()
    @pare_event_registered()
    def set_title(self, title: str) -> dict[str, object]:
        """Update the draft title."""
        self.draft.title = title
        self._mark_dirty()
        return {"title": self.draft.title}

    @user_tool()
    @pare_event_registered()
    def set_time_range(self, start_datetime: str, end_datetime: str) -> dict[str, object]:
        """Set the draft start/end datetimes."""
        self.draft.start_datetime = start_datetime
        self.draft.end_datetime = end_datetime
        self._mark_dirty()
        return {"start_datetime": self.draft.start_datetime, "end_datetime": self.draft.end_datetime}

    @user_tool()
    @pare_event_registered()
    def set_tag(self, tag: str | None) -> dict[str, object]:
        """Assign a label/tag to the draft."""
        self.draft.tag = tag
        self._mark_dirty()
        return {"tag": self.draft.tag}

    @user_tool()
    @pare_event_registered()
    def set_description(self, description: str | None) -> dict[str, object]:
        """Replace the draft description."""
        self.draft.description = description
        return {"description": self.draft.description}

    @user_tool()
    @pare_event_registered()
    def set_location(self, location: str | None) -> dict[str, object]:
        """Update the draft location."""
        self.draft.location = location
        return {"location": self.draft.location}

    @user_tool()
    @pare_event_registered()
    def set_attendees(self, attendees: list[str]) -> dict[str, object]:
        """Overwrite the current attendee list."""
        self.draft.attendees = attendees
        self._mark_dirty()
        return {"attendees": list(self.draft.attendees)}

    @user_tool()
    @pare_event_registered()
    def add_attendee(self, attendee: str) -> dict[str, object]:
        """Append an attendee to the draft if not already present."""
        if attendee not in self.draft.attendees:
            self.draft.attendees.append(attendee)
            self._mark_dirty()
        return {"attendees": list(self.draft.attendees)}

    @user_tool()
    @pare_event_registered()
    def remove_attendee(self, attendee: str) -> dict[str, object]:
        """Remove an attendee from the draft if included."""
        self.draft.attendees = [a for a in self.draft.attendees if a != attendee]
        self._mark_dirty()
        return {"attendees": list(self.draft.attendees)}

    @user_tool()
    @pare_event_registered()
    def save(self) -> str:
        """Persist current draft to backend, returning the event id."""
        if self.draft.event_id:
            self.app.edit_calendar_event(
                event_id=self.draft.event_id,
                title=self.draft.title if self.draft.title != self._original.title else None,
                start_datetime=self.draft.start_datetime,
                end_datetime=self.draft.end_datetime,
                tag=self.draft.tag,
                description=self.draft.description,
                location=self.draft.location,
                attendees=self.draft.attendees,
            )
            return self.draft.event_id

        event_id = self.app.add_calendar_event(
            title=self.draft.title,
            start_datetime=self.draft.start_datetime,
            end_datetime=self.draft.end_datetime,
            tag=self.draft.tag,
            description=self.draft.description,
            location=self.draft.location,
            attendees=self.draft.attendees or [],
        )
        self.draft.event_id = event_id
        return event_id

    @user_tool()
    @pare_event_registered()
    def discard(self) -> str:
        """Discard current draft and stay within compose state."""
        self.draft = EditDraft()
        self._original = replace(self.draft)
        self._mark_dirty()
        return "draft_discarded"

__init__(draft=None)

Initialise the edit state, optionally seeding it with an existing draft.

Source code in pare/apps/calendar/states.py
285
286
287
288
289
290
291
292
293
294
295
def __init__(self, draft: EditDraft | None = None) -> None:
    """Initialise the edit state, optionally seeding it with an existing draft."""
    super().__init__()
    if draft is None:
        self.draft = EditDraft()
    else:
        self.draft = replace(draft)
        self.draft.attendees = list(draft.attendees)
    original = replace(self.draft)
    original.attendees = list(self.draft.attendees)
    self._original = original

add_attendee(attendee)

Append an attendee to the draft if not already present.

Source code in pare/apps/calendar/states.py
355
356
357
358
359
360
361
362
@user_tool()
@pare_event_registered()
def add_attendee(self, attendee: str) -> dict[str, object]:
    """Append an attendee to the draft if not already present."""
    if attendee not in self.draft.attendees:
        self.draft.attendees.append(attendee)
        self._mark_dirty()
    return {"attendees": list(self.draft.attendees)}

discard()

Discard current draft and stay within compose state.

Source code in pare/apps/calendar/states.py
401
402
403
404
405
406
407
408
@user_tool()
@pare_event_registered()
def discard(self) -> str:
    """Discard current draft and stay within compose state."""
    self.draft = EditDraft()
    self._original = replace(self.draft)
    self._mark_dirty()
    return "draft_discarded"

on_enter()

Invalidate cached tools to pick up latest draft mutations.

Source code in pare/apps/calendar/states.py
297
298
299
def on_enter(self) -> None:
    """Invalidate cached tools to pick up latest draft mutations."""
    self._cached_tools = None

on_exit()

Reset tool cache on exit.

Source code in pare/apps/calendar/states.py
301
302
303
def on_exit(self) -> None:
    """Reset tool cache on exit."""
    self._cached_tools = None

remove_attendee(attendee)

Remove an attendee from the draft if included.

Source code in pare/apps/calendar/states.py
364
365
366
367
368
369
370
@user_tool()
@pare_event_registered()
def remove_attendee(self, attendee: str) -> dict[str, object]:
    """Remove an attendee from the draft if included."""
    self.draft.attendees = [a for a in self.draft.attendees if a != attendee]
    self._mark_dirty()
    return {"attendees": list(self.draft.attendees)}

save()

Persist current draft to backend, returning the event id.

Source code in pare/apps/calendar/states.py
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
@user_tool()
@pare_event_registered()
def save(self) -> str:
    """Persist current draft to backend, returning the event id."""
    if self.draft.event_id:
        self.app.edit_calendar_event(
            event_id=self.draft.event_id,
            title=self.draft.title if self.draft.title != self._original.title else None,
            start_datetime=self.draft.start_datetime,
            end_datetime=self.draft.end_datetime,
            tag=self.draft.tag,
            description=self.draft.description,
            location=self.draft.location,
            attendees=self.draft.attendees,
        )
        return self.draft.event_id

    event_id = self.app.add_calendar_event(
        title=self.draft.title,
        start_datetime=self.draft.start_datetime,
        end_datetime=self.draft.end_datetime,
        tag=self.draft.tag,
        description=self.draft.description,
        location=self.draft.location,
        attendees=self.draft.attendees or [],
    )
    self.draft.event_id = event_id
    return event_id

set_attendees(attendees)

Overwrite the current attendee list.

Source code in pare/apps/calendar/states.py
347
348
349
350
351
352
353
@user_tool()
@pare_event_registered()
def set_attendees(self, attendees: list[str]) -> dict[str, object]:
    """Overwrite the current attendee list."""
    self.draft.attendees = attendees
    self._mark_dirty()
    return {"attendees": list(self.draft.attendees)}

set_description(description)

Replace the draft description.

Source code in pare/apps/calendar/states.py
333
334
335
336
337
338
@user_tool()
@pare_event_registered()
def set_description(self, description: str | None) -> dict[str, object]:
    """Replace the draft description."""
    self.draft.description = description
    return {"description": self.draft.description}

set_location(location)

Update the draft location.

Source code in pare/apps/calendar/states.py
340
341
342
343
344
345
@user_tool()
@pare_event_registered()
def set_location(self, location: str | None) -> dict[str, object]:
    """Update the draft location."""
    self.draft.location = location
    return {"location": self.draft.location}

set_tag(tag)

Assign a label/tag to the draft.

Source code in pare/apps/calendar/states.py
325
326
327
328
329
330
331
@user_tool()
@pare_event_registered()
def set_tag(self, tag: str | None) -> dict[str, object]:
    """Assign a label/tag to the draft."""
    self.draft.tag = tag
    self._mark_dirty()
    return {"tag": self.draft.tag}

set_time_range(start_datetime, end_datetime)

Set the draft start/end datetimes.

Source code in pare/apps/calendar/states.py
316
317
318
319
320
321
322
323
@user_tool()
@pare_event_registered()
def set_time_range(self, start_datetime: str, end_datetime: str) -> dict[str, object]:
    """Set the draft start/end datetimes."""
    self.draft.start_datetime = start_datetime
    self.draft.end_datetime = end_datetime
    self._mark_dirty()
    return {"start_datetime": self.draft.start_datetime, "end_datetime": self.draft.end_datetime}

set_title(title)

Update the draft title.

Source code in pare/apps/calendar/states.py
308
309
310
311
312
313
314
@user_tool()
@pare_event_registered()
def set_title(self, title: str) -> dict[str, object]:
    """Update the draft title."""
    self.draft.title = title
    self._mark_dirty()
    return {"title": self.draft.title}

EventDetail

Bases: AppState

Detailed view of a single calendar event.

Source code in pare/apps/calendar/states.py
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
class EventDetail(AppState):
    """Detailed view of a single calendar event."""

    def __init__(self, event_id: str) -> None:
        """Create a detail view bound to the provided event identifier."""
        super().__init__()
        self.event_id = event_id
        self._event: CalendarEvent | None = None

    def on_enter(self) -> None:
        """Fetch the underlying event when entering the detail view."""
        self._event = self.app.get_calendar_event(self.event_id)

    def on_exit(self) -> None:
        """No teardown required; keep cached copy for go_back."""

    @property
    def event(self) -> CalendarEvent | None:
        """Return the cached event instance if available."""
        return self._event

    def _ensure_event(self) -> CalendarEvent | None:
        if self._event is None:
            self._event = self.app.get_calendar_event(self.event_id)
        return self._event

    @user_tool()
    @pare_event_registered()
    def refresh(self) -> CalendarEvent:
        """Reload event data from the backend."""
        self._event = self.app.get_calendar_event(self.event_id)
        return self._event

    @user_tool()
    @pare_event_registered()
    def delete(self) -> str:
        """Delete this event from the calendar."""
        return self.app.delete_calendar_event(event_id=self.event_id)

    @user_tool()
    @pare_event_registered()
    def delete_by_attendee(self, who_delete: str) -> str:
        """Delete the event as a particular attendee."""
        return self.app.delete_calendar_event_by_attendee(event_id=self.event_id, who_delete=who_delete)

    @user_tool()
    @pare_event_registered()
    def list_attendees(self) -> list[str]:
        """Return the attendee list for this event."""
        event = self._ensure_event()
        return list(event.attendees) if event else []

    @user_tool()
    @pare_event_registered()
    def edit_event(self) -> dict[str, object]:
        """Prepare a draft payload for editing this event."""
        event = self._ensure_event()
        if event is None:
            return {"draft": None}
        return {
            "draft": {
                "event_id": event.event_id,
                "title": event.title,
                "start_datetime": _format_timestamp(event.start_datetime),
                "end_datetime": _format_timestamp(event.end_datetime),
                "tag": event.tag,
                "description": event.description,
                "location": event.location,
                "attendees": list(event.attendees),
            }
        }

event property

Return the cached event instance if available.

__init__(event_id)

Create a detail view bound to the provided event identifier.

Source code in pare/apps/calendar/states.py
212
213
214
215
216
def __init__(self, event_id: str) -> None:
    """Create a detail view bound to the provided event identifier."""
    super().__init__()
    self.event_id = event_id
    self._event: CalendarEvent | None = None

delete()

Delete this event from the calendar.

Source code in pare/apps/calendar/states.py
242
243
244
245
246
@user_tool()
@pare_event_registered()
def delete(self) -> str:
    """Delete this event from the calendar."""
    return self.app.delete_calendar_event(event_id=self.event_id)

delete_by_attendee(who_delete)

Delete the event as a particular attendee.

Source code in pare/apps/calendar/states.py
248
249
250
251
252
@user_tool()
@pare_event_registered()
def delete_by_attendee(self, who_delete: str) -> str:
    """Delete the event as a particular attendee."""
    return self.app.delete_calendar_event_by_attendee(event_id=self.event_id, who_delete=who_delete)

edit_event()

Prepare a draft payload for editing this event.

Source code in pare/apps/calendar/states.py
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
@user_tool()
@pare_event_registered()
def edit_event(self) -> dict[str, object]:
    """Prepare a draft payload for editing this event."""
    event = self._ensure_event()
    if event is None:
        return {"draft": None}
    return {
        "draft": {
            "event_id": event.event_id,
            "title": event.title,
            "start_datetime": _format_timestamp(event.start_datetime),
            "end_datetime": _format_timestamp(event.end_datetime),
            "tag": event.tag,
            "description": event.description,
            "location": event.location,
            "attendees": list(event.attendees),
        }
    }

list_attendees()

Return the attendee list for this event.

Source code in pare/apps/calendar/states.py
254
255
256
257
258
259
@user_tool()
@pare_event_registered()
def list_attendees(self) -> list[str]:
    """Return the attendee list for this event."""
    event = self._ensure_event()
    return list(event.attendees) if event else []

on_enter()

Fetch the underlying event when entering the detail view.

Source code in pare/apps/calendar/states.py
218
219
220
def on_enter(self) -> None:
    """Fetch the underlying event when entering the detail view."""
    self._event = self.app.get_calendar_event(self.event_id)

on_exit()

No teardown required; keep cached copy for go_back.

Source code in pare/apps/calendar/states.py
222
223
def on_exit(self) -> None:
    """No teardown required; keep cached copy for go_back."""

refresh()

Reload event data from the backend.

Source code in pare/apps/calendar/states.py
235
236
237
238
239
240
@user_tool()
@pare_event_registered()
def refresh(self) -> CalendarEvent:
    """Reload event data from the backend."""
    self._event = self.app.get_calendar_event(self.event_id)
    return self._event

Cab App

Stateful cab app combining Meta-ARE cab backend with PARE navigation.

StatefulCabApp

Bases: StatefulApp, CabApp

Cab client with navigation-aware user tool exposure.

Source code in pare/apps/cab/app.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
class StatefulCabApp(StatefulApp, CabApp):
    """Cab client with navigation-aware user tool exposure."""

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        """Initialise the cab app and load the default home screen."""
        super().__init__(*args, **kwargs)
        self.load_root_state()

    def create_root_state(self) -> CabHome:
        """Return the root navigation state for the cab app."""
        return CabHome()

    def handle_state_transition(self, event: CompletedEvent) -> None:
        """Update navigation state after a cab operation completes."""
        fname = event.function_name()
        if fname is None:
            return

        event_args: dict[str, Any] = {}
        action = getattr(event, "action", None)
        if action is not None and hasattr(action, "args"):
            event_args = cast("dict[str, Any]", getattr(action, "args", {}))

        match fname:
            case "list_rides":
                self._handle_list_rides(event_args)
            case "get_quotation":
                self._handle_get_quotation(event)
            case "order_ride":
                self._handle_order_ride(event)
            case "open_current_ride":
                self._handle_open_current_ride(event)
            case "cancel_ride":
                self._handle_finish()

    def _handle_open_current_ride(self, event: CompletedEvent) -> None:
        """Navigate to the ride detail screen for the current ride."""
        ride_obj = event.metadata.return_value if event.metadata else None
        if ride_obj is not None:
            self.set_current_state(CabRideDetail(ride_obj))

    def _handle_list_rides(self, event_args: dict[str, Any]) -> None:
        """Navigate to service options after listing rides."""
        start = event_args.get("start_location")
        end = event_args.get("end_location")
        ride_time = event_args.get("ride_time")

        if start and end:
            self.set_current_state(CabServiceOptions(start, end, ride_time))

    def _handle_get_quotation(self, event: CompletedEvent) -> None:
        """Navigate to quotation detail after getting a quotation."""
        ride_obj = event.metadata.return_value if event.metadata else None
        if ride_obj:
            self.set_current_state(CabQuotationDetail(ride_obj))

    def _handle_order_ride(self, event: CompletedEvent) -> None:
        """Navigate to ride detail after ordering a ride."""
        ride_obj = event.metadata.return_value if event.metadata else None
        if ride_obj is not None:
            self.set_current_state(CabRideDetail(ride_obj))

    def _handle_finish(self) -> None:
        """Return to home screen after canceling or ending a ride."""
        self.load_root_state()

__init__(*args, **kwargs)

Initialise the cab app and load the default home screen.

Source code in pare/apps/cab/app.py
24
25
26
27
def __init__(self, *args: Any, **kwargs: Any) -> None:
    """Initialise the cab app and load the default home screen."""
    super().__init__(*args, **kwargs)
    self.load_root_state()

create_root_state()

Return the root navigation state for the cab app.

Source code in pare/apps/cab/app.py
29
30
31
def create_root_state(self) -> CabHome:
    """Return the root navigation state for the cab app."""
    return CabHome()

handle_state_transition(event)

Update navigation state after a cab operation completes.

Source code in pare/apps/cab/app.py
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
def handle_state_transition(self, event: CompletedEvent) -> None:
    """Update navigation state after a cab operation completes."""
    fname = event.function_name()
    if fname is None:
        return

    event_args: dict[str, Any] = {}
    action = getattr(event, "action", None)
    if action is not None and hasattr(action, "args"):
        event_args = cast("dict[str, Any]", getattr(action, "args", {}))

    match fname:
        case "list_rides":
            self._handle_list_rides(event_args)
        case "get_quotation":
            self._handle_get_quotation(event)
        case "order_ride":
            self._handle_order_ride(event)
        case "open_current_ride":
            self._handle_open_current_ride(event)
        case "cancel_ride":
            self._handle_finish()

CabHome

Bases: AppState

Home view for cab operations such as listing rides, quotations, and history.

This state provides the main interface for users to interact with the cab service, including listing available rides, getting quotations, ordering rides, and viewing ride history.

Source code in pare/apps/cab/states.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
class CabHome(AppState):
    """Home view for cab operations such as listing rides, quotations, and history.

    This state provides the main interface for users to interact with the cab service,
    including listing available rides, getting quotations, ordering rides, and viewing
    ride history.
    """

    def on_enter(self) -> None:
        """Called when entering this state."""
        pass

    def on_exit(self) -> None:
        """Called when exiting this state."""
        pass

    @user_tool()
    @pare_event_registered(operation_type=OperationType.READ)
    def list_rides(
        self,
        start_location: str,
        end_location: str,
        ride_time: str | None = None,
    ) -> list[Ride]:
        """List available ride quotations for all service types.

        Args:
            start_location: The starting location for the ride.
            end_location: The destination location for the ride.
            ride_time: The time for the ride in format 'YYYY-MM-DD HH:MM:SS'. If None, the current time is used.

        Returns:
            list[Ride]: Available ride objects with quotations for all service types.
        """
        app = cast("StatefulCabApp", self.app)
        with disable_events():
            return app.list_rides(
                start_location=start_location,
                end_location=end_location,
                ride_time=ride_time,
            )

    # should navigate to CabRideDetail
    @user_tool()
    @pare_event_registered(operation_type=OperationType.READ)
    def open_current_ride(self) -> Ride:
        """Get the details for the current ride.

        Returns:
            Ride: Current ride object if there is an ongoing ride.
        """
        app = cast("StatefulCabApp", self.app)
        with disable_events():
            return app.get_current_ride_status()

    @user_tool()
    @pare_event_registered(operation_type=OperationType.READ)
    def get_ride_history(self, offset: int = 0, limit: int = 10) -> dict[str, Any]:
        """Fetch ride history.

        Args:
            offset: The number of records to skip (default: 0).
            limit: The maximum number of records to return (default: 10).

        Returns:
            dict[str, Any]: Collection of historical ride records.
        """
        app = cast("StatefulCabApp", self.app)
        with disable_events():
            return app.get_ride_history(offset=offset, limit=limit)

get_ride_history(offset=0, limit=10)

Fetch ride history.

Parameters:

Name Type Description Default
offset int

The number of records to skip (default: 0).

0
limit int

The maximum number of records to return (default: 10).

10

Returns:

Type Description
dict[str, Any]

dict[str, Any]: Collection of historical ride records.

Source code in pare/apps/cab/states.py
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
@user_tool()
@pare_event_registered(operation_type=OperationType.READ)
def get_ride_history(self, offset: int = 0, limit: int = 10) -> dict[str, Any]:
    """Fetch ride history.

    Args:
        offset: The number of records to skip (default: 0).
        limit: The maximum number of records to return (default: 10).

    Returns:
        dict[str, Any]: Collection of historical ride records.
    """
    app = cast("StatefulCabApp", self.app)
    with disable_events():
        return app.get_ride_history(offset=offset, limit=limit)

list_rides(start_location, end_location, ride_time=None)

List available ride quotations for all service types.

Parameters:

Name Type Description Default
start_location str

The starting location for the ride.

required
end_location str

The destination location for the ride.

required
ride_time str | None

The time for the ride in format 'YYYY-MM-DD HH:MM:SS'. If None, the current time is used.

None

Returns:

Type Description
list[Ride]

list[Ride]: Available ride objects with quotations for all service types.

Source code in pare/apps/cab/states.py
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
@user_tool()
@pare_event_registered(operation_type=OperationType.READ)
def list_rides(
    self,
    start_location: str,
    end_location: str,
    ride_time: str | None = None,
) -> list[Ride]:
    """List available ride quotations for all service types.

    Args:
        start_location: The starting location for the ride.
        end_location: The destination location for the ride.
        ride_time: The time for the ride in format 'YYYY-MM-DD HH:MM:SS'. If None, the current time is used.

    Returns:
        list[Ride]: Available ride objects with quotations for all service types.
    """
    app = cast("StatefulCabApp", self.app)
    with disable_events():
        return app.list_rides(
            start_location=start_location,
            end_location=end_location,
            ride_time=ride_time,
        )

on_enter()

Called when entering this state.

Source code in pare/apps/cab/states.py
24
25
26
def on_enter(self) -> None:
    """Called when entering this state."""
    pass

on_exit()

Called when exiting this state.

Source code in pare/apps/cab/states.py
28
29
30
def on_exit(self) -> None:
    """Called when exiting this state."""
    pass

open_current_ride()

Get the details for the current ride.

Returns:

Name Type Description
Ride Ride

Current ride object if there is an ongoing ride.

Source code in pare/apps/cab/states.py
59
60
61
62
63
64
65
66
67
68
69
@user_tool()
@pare_event_registered(operation_type=OperationType.READ)
def open_current_ride(self) -> Ride:
    """Get the details for the current ride.

    Returns:
        Ride: Current ride object if there is an ongoing ride.
    """
    app = cast("StatefulCabApp", self.app)
    with disable_events():
        return app.get_current_ride_status()

CabQuotationDetail

Bases: AppState

Screen displaying a quotation (Ride before booking).

This state shows the details of a quotation and allows the user to confirm and book the ride.

Attributes:

Name Type Description
ride

The Ride object containing the quotation details.

Source code in pare/apps/cab/states.py
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
class CabQuotationDetail(AppState):
    """Screen displaying a quotation (Ride before booking).

    This state shows the details of a quotation and allows the user to confirm
    and book the ride.

    Attributes:
        ride: The Ride object containing the quotation details.
    """

    def __init__(self, ride: Ride) -> None:
        """Initialize quotation detail view.

        Args:
            ride: The Ride object containing the quotation to display.
        """
        super().__init__()
        self.ride = ride

    def on_enter(self) -> None:
        """Called when entering this state."""
        pass

    def on_exit(self) -> None:
        """Called when exiting this state."""
        pass

    @user_tool()
    @pare_event_registered(operation_type=OperationType.READ)
    def show_quotation(self) -> Ride:
        """Show the quotation details.

        Returns:
            Ride: The quotation details.
        """
        return self.ride

    @user_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def order_ride(self) -> Ride:
        """Confirm and book the ride from the quotation.

        Returns:
            Ride: The confirmed and booked ride.
        """
        app = cast("StatefulCabApp", self.app)
        with disable_events():
            return app.order_ride(
                start_location=self.ride.start_location,
                end_location=self.ride.end_location,
                service_type=self.ride.service_type,
                ride_time=None,
            )

__init__(ride)

Initialize quotation detail view.

Parameters:

Name Type Description Default
ride Ride

The Ride object containing the quotation to display.

required
Source code in pare/apps/cab/states.py
208
209
210
211
212
213
214
215
def __init__(self, ride: Ride) -> None:
    """Initialize quotation detail view.

    Args:
        ride: The Ride object containing the quotation to display.
    """
    super().__init__()
    self.ride = ride

on_enter()

Called when entering this state.

Source code in pare/apps/cab/states.py
217
218
219
def on_enter(self) -> None:
    """Called when entering this state."""
    pass

on_exit()

Called when exiting this state.

Source code in pare/apps/cab/states.py
221
222
223
def on_exit(self) -> None:
    """Called when exiting this state."""
    pass

order_ride()

Confirm and book the ride from the quotation.

Returns:

Name Type Description
Ride Ride

The confirmed and booked ride.

Source code in pare/apps/cab/states.py
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
@user_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def order_ride(self) -> Ride:
    """Confirm and book the ride from the quotation.

    Returns:
        Ride: The confirmed and booked ride.
    """
    app = cast("StatefulCabApp", self.app)
    with disable_events():
        return app.order_ride(
            start_location=self.ride.start_location,
            end_location=self.ride.end_location,
            service_type=self.ride.service_type,
            ride_time=None,
        )

show_quotation()

Show the quotation details.

Returns:

Name Type Description
Ride Ride

The quotation details.

Source code in pare/apps/cab/states.py
225
226
227
228
229
230
231
232
233
@user_tool()
@pare_event_registered(operation_type=OperationType.READ)
def show_quotation(self) -> Ride:
    """Show the quotation details.

    Returns:
        Ride: The quotation details.
    """
    return self.ride

CabRideDetail

Bases: AppState

Detail view for a specific ride.

This state provides detailed information and operations for a specific ride, including viewing ride details, checking status, and canceling or ending the ride.

Source code in pare/apps/cab/states.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
class CabRideDetail(AppState):
    """Detail view for a specific ride.

    This state provides detailed information and operations for a specific ride,
    including viewing ride details, checking status, and canceling or ending the ride.
    """

    def __init__(self, ride: Ride) -> None:
        """Initialize ride detail view with a ride index.

        Args:
            ride: The ride object to display.
        """
        super().__init__()
        self.ride = ride

    def on_enter(self) -> None:
        """Called when entering this state."""
        pass

    def on_exit(self) -> None:
        """Called when exiting this state."""
        pass

    @user_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def cancel_ride(self) -> str:
        """Cancel the current ride.

        Returns:
            str: Cancellation confirmation message.
        """
        app = cast("StatefulCabApp", self.app)
        with disable_events():
            return app.user_cancel_ride()

__init__(ride)

Initialize ride detail view with a ride index.

Parameters:

Name Type Description Default
ride Ride

The ride object to display.

required
Source code in pare/apps/cab/states.py
 96
 97
 98
 99
100
101
102
103
def __init__(self, ride: Ride) -> None:
    """Initialize ride detail view with a ride index.

    Args:
        ride: The ride object to display.
    """
    super().__init__()
    self.ride = ride

cancel_ride()

Cancel the current ride.

Returns:

Name Type Description
str str

Cancellation confirmation message.

Source code in pare/apps/cab/states.py
113
114
115
116
117
118
119
120
121
122
123
@user_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def cancel_ride(self) -> str:
    """Cancel the current ride.

    Returns:
        str: Cancellation confirmation message.
    """
    app = cast("StatefulCabApp", self.app)
    with disable_events():
        return app.user_cancel_ride()

on_enter()

Called when entering this state.

Source code in pare/apps/cab/states.py
105
106
107
def on_enter(self) -> None:
    """Called when entering this state."""
    pass

on_exit()

Called when exiting this state.

Source code in pare/apps/cab/states.py
109
110
111
def on_exit(self) -> None:
    """Called when exiting this state."""
    pass

CabServiceOptions

Bases: AppState

Screen displaying available service types.

This state allows users to browse available service types and view quotations for specific service types based on their journey details.

Attributes:

Name Type Description
start_location

The starting location for the ride.

end_location

The destination location for the ride.

ride_time

Optional scheduled time for the ride.

Source code in pare/apps/cab/states.py
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
class CabServiceOptions(AppState):
    """Screen displaying available service types.

    This state allows users to browse available service types and view quotations
    for specific service types based on their journey details.

    Attributes:
        start_location: The starting location for the ride.
        end_location: The destination location for the ride.
        ride_time: Optional scheduled time for the ride.
    """

    def __init__(
        self,
        start_location: str,
        end_location: str,
        ride_time: str | None = None,
    ) -> None:
        """Initialize service options view.

        Args:
            start_location: The starting location for the ride.
            end_location: The destination location for the ride.
            ride_time: Optional scheduled time for the ride.
        """
        super().__init__()
        self.start_location = start_location
        self.end_location = end_location
        self.ride_time = ride_time

    def on_enter(self) -> None:
        """Called when entering this state."""
        pass

    def on_exit(self) -> None:
        """Called when exiting this state."""
        pass

    @user_tool()
    @pare_event_registered(operation_type=OperationType.READ)
    def list_service_types(self) -> list[str]:
        """List all available service types.

        Returns:
            list[str]: Sorted list of available service type names.
        """
        app = cast("StatefulCabApp", self.app)
        return sorted(app.d_service_config.keys())

    @user_tool()
    @pare_event_registered(operation_type=OperationType.READ)
    def get_quotation(self, service_type: str) -> Ride:
        """View quotation for a specific service type.

        Args:
            service_type: The type of service to get a quotation for.

        Returns:
            Ride: Quotation for the specified service type.
        """
        app = cast("StatefulCabApp", self.app)
        with disable_events():
            return app.get_quotation(
                start_location=self.start_location,
                end_location=self.end_location,
                service_type=service_type,
                ride_time=self.ride_time,
            )

__init__(start_location, end_location, ride_time=None)

Initialize service options view.

Parameters:

Name Type Description Default
start_location str

The starting location for the ride.

required
end_location str

The destination location for the ride.

required
ride_time str | None

Optional scheduled time for the ride.

None
Source code in pare/apps/cab/states.py
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
def __init__(
    self,
    start_location: str,
    end_location: str,
    ride_time: str | None = None,
) -> None:
    """Initialize service options view.

    Args:
        start_location: The starting location for the ride.
        end_location: The destination location for the ride.
        ride_time: Optional scheduled time for the ride.
    """
    super().__init__()
    self.start_location = start_location
    self.end_location = end_location
    self.ride_time = ride_time

get_quotation(service_type)

View quotation for a specific service type.

Parameters:

Name Type Description Default
service_type str

The type of service to get a quotation for.

required

Returns:

Name Type Description
Ride Ride

Quotation for the specified service type.

Source code in pare/apps/cab/states.py
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
@user_tool()
@pare_event_registered(operation_type=OperationType.READ)
def get_quotation(self, service_type: str) -> Ride:
    """View quotation for a specific service type.

    Args:
        service_type: The type of service to get a quotation for.

    Returns:
        Ride: Quotation for the specified service type.
    """
    app = cast("StatefulCabApp", self.app)
    with disable_events():
        return app.get_quotation(
            start_location=self.start_location,
            end_location=self.end_location,
            service_type=service_type,
            ride_time=self.ride_time,
        )

list_service_types()

List all available service types.

Returns:

Type Description
list[str]

list[str]: Sorted list of available service type names.

Source code in pare/apps/cab/states.py
165
166
167
168
169
170
171
172
173
174
@user_tool()
@pare_event_registered(operation_type=OperationType.READ)
def list_service_types(self) -> list[str]:
    """List all available service types.

    Returns:
        list[str]: Sorted list of available service type names.
    """
    app = cast("StatefulCabApp", self.app)
    return sorted(app.d_service_config.keys())

on_enter()

Called when entering this state.

Source code in pare/apps/cab/states.py
157
158
159
def on_enter(self) -> None:
    """Called when entering this state."""
    pass

on_exit()

Called when exiting this state.

Source code in pare/apps/cab/states.py
161
162
163
def on_exit(self) -> None:
    """Called when exiting this state."""
    pass

Apartment App

Stateful Apartment app combining ARE ApartmentListingApp with PARE navigation.

StatefulApartmentApp

Bases: StatefulApp, ApartmentListingApp

Apartment app with navigation-aware PARE behavior.

Source code in pare/apps/apartment/app.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
class StatefulApartmentApp(StatefulApp, ApartmentListingApp):
    """Apartment app with navigation-aware PARE behavior."""

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        """Initialize apartment app and load the root navigation state."""
        super().__init__(*args, **kwargs)
        self.load_root_state()

    def create_root_state(self) -> ApartmentHome:
        """Return the root navigation state for the apartment app."""
        return ApartmentHome()

    def handle_state_transition(self, event: CompletedEvent) -> None:
        """Update navigation state after an apartment operation completes."""
        current_state = self.current_state
        fname = event.function_name()

        if current_state is None or fname is None:  # defensive, Email-style
            return

        action = event.action
        args: dict[str, Any] = action.args if action and hasattr(action, "args") else {}

        if isinstance(current_state, ApartmentHome):
            self._handle_home_transition(fname, args)
            return

        if isinstance(current_state, ApartmentSearch):
            self._handle_search_transition(fname, args)
            return

        if isinstance(current_state, ApartmentFavorites):
            self._handle_saved_transition(fname, args)

    def _handle_home_transition(self, fname: str, args: dict[str, Any]) -> None:
        if fname == "view_apartment":
            apt_id = args.get("apartment_id")
            if apt_id:
                self.set_current_state(ApartmentDetail(apartment_id=apt_id))
            return

        if fname == "open_search":
            self.set_current_state(ApartmentSearch())
            return

        if fname == "open_favorites":
            self.set_current_state(ApartmentFavorites())
            return

    def _handle_search_transition(self, fname: str, args: dict[str, Any]) -> None:
        if fname == "view_apartment":
            apt_id = args.get("apartment_id")
            if apt_id:
                self.set_current_state(ApartmentDetail(apartment_id=apt_id))
            return

    def _handle_saved_transition(self, fname: str, args: dict[str, Any]) -> None:
        if fname == "view_apartment":
            apt_id = args.get("apartment_id")
            if apt_id:
                self.set_current_state(ApartmentDetail(apartment_id=apt_id))
            return

__init__(*args, **kwargs)

Initialize apartment app and load the root navigation state.

Source code in pare/apps/apartment/app.py
24
25
26
27
def __init__(self, *args: Any, **kwargs: Any) -> None:
    """Initialize apartment app and load the root navigation state."""
    super().__init__(*args, **kwargs)
    self.load_root_state()

create_root_state()

Return the root navigation state for the apartment app.

Source code in pare/apps/apartment/app.py
29
30
31
def create_root_state(self) -> ApartmentHome:
    """Return the root navigation state for the apartment app."""
    return ApartmentHome()

handle_state_transition(event)

Update navigation state after an apartment operation completes.

Source code in pare/apps/apartment/app.py
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
def handle_state_transition(self, event: CompletedEvent) -> None:
    """Update navigation state after an apartment operation completes."""
    current_state = self.current_state
    fname = event.function_name()

    if current_state is None or fname is None:  # defensive, Email-style
        return

    action = event.action
    args: dict[str, Any] = action.args if action and hasattr(action, "args") else {}

    if isinstance(current_state, ApartmentHome):
        self._handle_home_transition(fname, args)
        return

    if isinstance(current_state, ApartmentSearch):
        self._handle_search_transition(fname, args)
        return

    if isinstance(current_state, ApartmentFavorites):
        self._handle_saved_transition(fname, args)

ApartmentDetail

Bases: AppState

Detail screen for a specific apartment.

Source code in pare/apps/apartment/states.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
class ApartmentDetail(AppState):
    """Detail screen for a specific apartment."""

    def __init__(self, apartment_id: str) -> None:
        """Initialize the detail screen.

        Args:
            apartment_id: Unique identifier for the apartment.
        """
        super().__init__()
        self.apartment_id = apartment_id

    def on_enter(self) -> None:
        """Run when entering the detail screen."""
        pass

    def on_exit(self) -> None:
        """Run when exiting the detail screen."""
        pass

    @user_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def save(self) -> None:
        """Save this apartment to saved apartments lists."""
        app = cast("StatefulApartmentApp", self.app)
        with disable_events():
            return app.save_apartment(apartment_id=self.apartment_id)

    @user_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def unsave(self) -> None:
        """Remove this apartment from the saved list."""
        app = cast("StatefulApartmentApp", self.app)
        with disable_events():
            return app.remove_saved_apartment(apartment_id=self.apartment_id)

__init__(apartment_id)

Initialize the detail screen.

Parameters:

Name Type Description Default
apartment_id str

Unique identifier for the apartment.

required
Source code in pare/apps/apartment/states.py
90
91
92
93
94
95
96
97
def __init__(self, apartment_id: str) -> None:
    """Initialize the detail screen.

    Args:
        apartment_id: Unique identifier for the apartment.
    """
    super().__init__()
    self.apartment_id = apartment_id

on_enter()

Run when entering the detail screen.

Source code in pare/apps/apartment/states.py
 99
100
101
def on_enter(self) -> None:
    """Run when entering the detail screen."""
    pass

on_exit()

Run when exiting the detail screen.

Source code in pare/apps/apartment/states.py
103
104
105
def on_exit(self) -> None:
    """Run when exiting the detail screen."""
    pass

save()

Save this apartment to saved apartments lists.

Source code in pare/apps/apartment/states.py
107
108
109
110
111
112
113
@user_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def save(self) -> None:
    """Save this apartment to saved apartments lists."""
    app = cast("StatefulApartmentApp", self.app)
    with disable_events():
        return app.save_apartment(apartment_id=self.apartment_id)

unsave()

Remove this apartment from the saved list.

Source code in pare/apps/apartment/states.py
115
116
117
118
119
120
121
@user_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def unsave(self) -> None:
    """Remove this apartment from the saved list."""
    app = cast("StatefulApartmentApp", self.app)
    with disable_events():
        return app.remove_saved_apartment(apartment_id=self.apartment_id)

ApartmentFavorites

Bases: AppState

Screen showing saved apartments.

Source code in pare/apps/apartment/states.py
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
class ApartmentFavorites(AppState):
    """Screen showing saved apartments."""

    def on_enter(self) -> None:
        """Run when entering the saved apartments screen."""
        pass

    def on_exit(self) -> None:
        """Run when exiting the saved apartments screen."""
        pass

    @user_tool()
    @pare_event_registered(operation_type=OperationType.READ)
    def view_apartment(self, apartment_id: str) -> Apartment:
        """View an apartment from the saved list.

        Args:
            apartment_id: Unique identifier for the apartment.

        Returns:
            Apartment: Apartment details.
        """
        app = cast("StatefulApartmentApp", self.app)
        with disable_events():
            return app.get_apartment_details(apartment_id=apartment_id)

on_enter()

Run when entering the saved apartments screen.

Source code in pare/apps/apartment/states.py
199
200
201
def on_enter(self) -> None:
    """Run when entering the saved apartments screen."""
    pass

on_exit()

Run when exiting the saved apartments screen.

Source code in pare/apps/apartment/states.py
203
204
205
def on_exit(self) -> None:
    """Run when exiting the saved apartments screen."""
    pass

view_apartment(apartment_id)

View an apartment from the saved list.

Parameters:

Name Type Description Default
apartment_id str

Unique identifier for the apartment.

required

Returns:

Name Type Description
Apartment Apartment

Apartment details.

Source code in pare/apps/apartment/states.py
207
208
209
210
211
212
213
214
215
216
217
218
219
220
@user_tool()
@pare_event_registered(operation_type=OperationType.READ)
def view_apartment(self, apartment_id: str) -> Apartment:
    """View an apartment from the saved list.

    Args:
        apartment_id: Unique identifier for the apartment.

    Returns:
        Apartment: Apartment details.
    """
    app = cast("StatefulApartmentApp", self.app)
    with disable_events():
        return app.get_apartment_details(apartment_id=apartment_id)

ApartmentHome

Bases: AppState

Main screen for listing apartments and navigating to other views.

Source code in pare/apps/apartment/states.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
class ApartmentHome(AppState):
    """Main screen for listing apartments and navigating to other views."""

    def on_enter(self) -> None:
        """Run when entering the home screen."""
        pass

    def on_exit(self) -> None:
        """Run when exiting the home screen."""
        pass

    @user_tool()
    @pare_event_registered(operation_type=OperationType.READ)
    def list_apartments(self) -> dict[str, Any]:
        """List all apartments.

        Returns:
            dict[str, Any]: All available apartment records.
        """
        app = cast("StatefulApartmentApp", self.app)
        with disable_events():
            apartments = app.list_all_apartments()

        logger.debug(f"Listed Apartments: {apartments}")

        return apartments

    @user_tool()
    @pare_event_registered(operation_type=OperationType.READ)
    def view_apartment(self, apartment_id: str) -> Apartment:
        """Open the detail screen for a specific apartment.

        Args:
            apartment_id: Unique identifier for the apartment.

        Returns:
            Apartment: Apartment details.
        """
        app = cast("StatefulApartmentApp", self.app)
        with disable_events():
            return app.get_apartment_details(apartment_id=apartment_id)

    @user_tool()
    @pare_event_registered()
    def open_search(self) -> str:
        """Navigate to the search page.

        Returns:
            str: Confirmation that the search view is open.
        """
        return "Search Apartments view is open."

    @user_tool()
    @pare_event_registered()
    def open_favorites(self) -> dict[str, Apartment]:
        """Navigate to the saved apartments page.

        Returns:
            dict[str, Apartment]: Saved apartments.
        """
        app = cast("StatefulApartmentApp", self.app)
        with disable_events():
            return app.list_saved_apartments()

list_apartments()

List all apartments.

Returns:

Type Description
dict[str, Any]

dict[str, Any]: All available apartment records.

Source code in pare/apps/apartment/states.py
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@user_tool()
@pare_event_registered(operation_type=OperationType.READ)
def list_apartments(self) -> dict[str, Any]:
    """List all apartments.

    Returns:
        dict[str, Any]: All available apartment records.
    """
    app = cast("StatefulApartmentApp", self.app)
    with disable_events():
        apartments = app.list_all_apartments()

    logger.debug(f"Listed Apartments: {apartments}")

    return apartments

on_enter()

Run when entering the home screen.

Source code in pare/apps/apartment/states.py
24
25
26
def on_enter(self) -> None:
    """Run when entering the home screen."""
    pass

on_exit()

Run when exiting the home screen.

Source code in pare/apps/apartment/states.py
28
29
30
def on_exit(self) -> None:
    """Run when exiting the home screen."""
    pass

open_favorites()

Navigate to the saved apartments page.

Returns:

Type Description
dict[str, Apartment]

dict[str, Apartment]: Saved apartments.

Source code in pare/apps/apartment/states.py
73
74
75
76
77
78
79
80
81
82
83
@user_tool()
@pare_event_registered()
def open_favorites(self) -> dict[str, Apartment]:
    """Navigate to the saved apartments page.

    Returns:
        dict[str, Apartment]: Saved apartments.
    """
    app = cast("StatefulApartmentApp", self.app)
    with disable_events():
        return app.list_saved_apartments()

Navigate to the search page.

Returns:

Name Type Description
str str

Confirmation that the search view is open.

Source code in pare/apps/apartment/states.py
63
64
65
66
67
68
69
70
71
@user_tool()
@pare_event_registered()
def open_search(self) -> str:
    """Navigate to the search page.

    Returns:
        str: Confirmation that the search view is open.
    """
    return "Search Apartments view is open."

view_apartment(apartment_id)

Open the detail screen for a specific apartment.

Parameters:

Name Type Description Default
apartment_id str

Unique identifier for the apartment.

required

Returns:

Name Type Description
Apartment Apartment

Apartment details.

Source code in pare/apps/apartment/states.py
48
49
50
51
52
53
54
55
56
57
58
59
60
61
@user_tool()
@pare_event_registered(operation_type=OperationType.READ)
def view_apartment(self, apartment_id: str) -> Apartment:
    """Open the detail screen for a specific apartment.

    Args:
        apartment_id: Unique identifier for the apartment.

    Returns:
        Apartment: Apartment details.
    """
    app = cast("StatefulApartmentApp", self.app)
    with disable_events():
        return app.get_apartment_details(apartment_id=apartment_id)

ApartmentSearch

Bases: AppState

Screen for searching apartments with optional filtering.

Source code in pare/apps/apartment/states.py
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
class ApartmentSearch(AppState):
    """Screen for searching apartments with optional filtering."""

    def on_enter(self) -> None:
        """Run when entering the search screen."""
        pass

    def on_exit(self) -> None:
        """Run when exiting the search screen."""
        pass

    @user_tool()
    @pare_event_registered(operation_type=OperationType.READ)
    def search(
        self,
        name: str | None = None,
        location: str | None = None,
        zip_code: str | None = None,
        min_price: float | None = None,
        max_price: float | None = None,
        number_of_bedrooms: int | None = None,
        number_of_bathrooms: int | None = None,
        property_type: str | None = None,
        square_footage: int | None = None,
        furnished_status: str | None = None,
        floor_level: str | None = None,
        pet_policy: str | None = None,
        lease_term: str | None = None,
        amenities: list[str] | None = None,
    ) -> dict[str, Apartment]:
        """Search apartments using optional filtering criteria.

        Returns:
            dict[str, Apartment]: Filtered apartment results.
        """
        app = cast("StatefulApartmentApp", self.app)
        with disable_events():
            return app.search_apartments(
                name=name,
                location=location,
                zip_code=zip_code,
                min_price=min_price,
                max_price=max_price,
                number_of_bedrooms=number_of_bedrooms,
                number_of_bathrooms=number_of_bathrooms,
                property_type=property_type,
                square_footage=square_footage,
                furnished_status=furnished_status,
                floor_level=floor_level,
                pet_policy=pet_policy,
                lease_term=lease_term,
                amenities=amenities,
            )

    @user_tool()
    @pare_event_registered(operation_type=OperationType.READ)
    def view_apartment(self, apartment_id: str) -> Apartment:
        """Open detail page from search results.

        Args:
            apartment_id: Unique identifier for the apartment.

        Returns:
            Apartment: Apartment details.
        """
        app = cast("StatefulApartmentApp", self.app)
        with disable_events():
            return app.get_apartment_details(apartment_id=apartment_id)

on_enter()

Run when entering the search screen.

Source code in pare/apps/apartment/states.py
128
129
130
def on_enter(self) -> None:
    """Run when entering the search screen."""
    pass

on_exit()

Run when exiting the search screen.

Source code in pare/apps/apartment/states.py
132
133
134
def on_exit(self) -> None:
    """Run when exiting the search screen."""
    pass

search(name=None, location=None, zip_code=None, min_price=None, max_price=None, number_of_bedrooms=None, number_of_bathrooms=None, property_type=None, square_footage=None, furnished_status=None, floor_level=None, pet_policy=None, lease_term=None, amenities=None)

Search apartments using optional filtering criteria.

Returns:

Type Description
dict[str, Apartment]

dict[str, Apartment]: Filtered apartment results.

Source code in pare/apps/apartment/states.py
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
@user_tool()
@pare_event_registered(operation_type=OperationType.READ)
def search(
    self,
    name: str | None = None,
    location: str | None = None,
    zip_code: str | None = None,
    min_price: float | None = None,
    max_price: float | None = None,
    number_of_bedrooms: int | None = None,
    number_of_bathrooms: int | None = None,
    property_type: str | None = None,
    square_footage: int | None = None,
    furnished_status: str | None = None,
    floor_level: str | None = None,
    pet_policy: str | None = None,
    lease_term: str | None = None,
    amenities: list[str] | None = None,
) -> dict[str, Apartment]:
    """Search apartments using optional filtering criteria.

    Returns:
        dict[str, Apartment]: Filtered apartment results.
    """
    app = cast("StatefulApartmentApp", self.app)
    with disable_events():
        return app.search_apartments(
            name=name,
            location=location,
            zip_code=zip_code,
            min_price=min_price,
            max_price=max_price,
            number_of_bedrooms=number_of_bedrooms,
            number_of_bathrooms=number_of_bathrooms,
            property_type=property_type,
            square_footage=square_footage,
            furnished_status=furnished_status,
            floor_level=floor_level,
            pet_policy=pet_policy,
            lease_term=lease_term,
            amenities=amenities,
        )

view_apartment(apartment_id)

Open detail page from search results.

Parameters:

Name Type Description Default
apartment_id str

Unique identifier for the apartment.

required

Returns:

Name Type Description
Apartment Apartment

Apartment details.

Source code in pare/apps/apartment/states.py
179
180
181
182
183
184
185
186
187
188
189
190
191
192
@user_tool()
@pare_event_registered(operation_type=OperationType.READ)
def view_apartment(self, apartment_id: str) -> Apartment:
    """Open detail page from search results.

    Args:
        apartment_id: Unique identifier for the apartment.

    Returns:
        Apartment: Apartment details.
    """
    app = cast("StatefulApartmentApp", self.app)
    with disable_events():
        return app.get_apartment_details(apartment_id=apartment_id)

Reminder App

Stateful reminder app combining ARE ReminderApp with PARE navigation.

StatefulReminderApp

Bases: StatefulApp, ReminderApp

Reminder application with PARE navigation support.

Source code in pare/apps/reminder/app.py
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
class StatefulReminderApp(StatefulApp, ReminderApp):
    """Reminder application with PARE navigation support."""

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        """Initialize the reminder app and load the root navigation state.

        Args:
            *args: Positional arguments passed to ReminderApp.
            **kwargs: Keyword arguments passed to ReminderApp.
        """
        super().__init__(*args, **kwargs)
        self.load_root_state()

    def create_root_state(self) -> ReminderList:
        """Create and return the root navigation state.

        Returns:
            The initial ReminderList state.
        """
        return ReminderList()

    def get_reminder_with_id(self, reminder_id: str) -> Reminder:
        """Retrieve a reminder by its ID.

        Args:
            reminder_id: The ID of the reminder to retrieve.

        Returns:
            The Reminder object corresponding to the given ID.

        Raises:
            KeyError: If the reminder ID does not exist.
        """
        if reminder_id not in self.reminders:
            raise KeyError(f"Reminder {reminder_id} not found.")
        return self.reminders[reminder_id]

    @app_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def update_reminder(
        self,
        reminder_id: str,
        title: str,
        description: str,
        due_datetime: str,
        repetition_unit: str | None,
        repetition_value: int | None,
    ) -> str:
        """Update an existing reminder and regenerate its repetitions.

        Args:
            reminder_id: ID of the reminder to update.
            title: Updated title.
            description: Updated description.
            due_datetime: Updated due datetime in "YYYY-MM-DD HH:MM:SS" format.
            repetition_unit: Repetition unit (e.g., "day", "week"), or None.
            repetition_value: Repetition interval value, or None.

        Returns:
            str: The reminder ID after update.

        Raises:
            ValueError: If the reminder ID does not exist.
        """
        if reminder_id not in self.reminders:
            raise ValueError(f"Reminder {reminder_id} not found.")

        dt = datetime.strptime(due_datetime, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC)

        reminder = self.reminders[reminder_id]
        reminder.title = title
        reminder.description = description
        reminder.due_datetime = dt
        reminder.repetition_unit = repetition_unit
        reminder.repetition_value = repetition_value

        base_id = reminder_id.split("_rep_")[0]
        to_delete = [k for k in self.reminders if k.startswith(f"{base_id}_rep_")]
        for k in to_delete:
            del self.reminders[k]

        next_id = reminder_id
        count = 0
        while repetition_unit and next_id and count < self.max_reminder_repetitions:
            next_id = self.add_reminder_repetition(next_id)
            if next_id:
                count += 1

        return reminder_id

    def handle_state_transition(self, event: CompletedEvent) -> None:
        """Update navigation state after a reminder operation completes.

        Args:
            event: Completed event containing tool invocation information.
        """
        current_state = self.current_state
        fname = event.function_name()

        if current_state is None or fname is None:
            return

        action = event.action
        args: dict[str, Any] = action.args if action and hasattr(action, "args") else {}

        metadata = event.metadata

        if isinstance(current_state, ReminderList):
            self._handle_list_transition(fname, args)
        elif isinstance(current_state, ReminderDetail):
            reminder_id = current_state.reminder_id
            self._handle_detail_transition(fname, reminder_id)
        elif isinstance(current_state, EditReminder):
            # EditReminder state can be reached from both creating a new reminder and editing an existing reminder.
            # If we are editing an existing reminder, we use the current reminder ID to navigate back to the ReminderDetail state.
            # Whereas, if we are creating a new reminder, there is no current reminder ID and we get the ID from the metadata after saving.
            saved_reminder_id = getattr(metadata, "return_value", None) if metadata else None
            original_reminder_id = current_state.reminder_id
            self._handle_edit_transition(
                fname, saved_reminder_id=saved_reminder_id, original_reminder_id=original_reminder_id
            )

    def _handle_list_transition(self, fname: str, args: dict[str, Any]) -> None:
        """Handle transitions from the reminder list state.

        Args:
            fname: Name of the invoked tool.
            args: Tool arguments.
        """
        if fname == "open_reminder":
            reminder_id = args.get("reminder_id")
            if reminder_id:
                self.set_current_state(ReminderDetail(reminder_id))
        elif fname == "create_new":
            self.set_current_state(EditReminder())

    def _handle_detail_transition(self, fname: str, reminder_id: str) -> None:
        """Handle transitions from the reminder detail state.

        Args:
            fname: Name of the invoked tool.
            reminder_id: ID of the current reminder being viewed.
        """
        if fname == "edit":
            self.set_current_state(EditReminder(reminder_id=reminder_id))
        elif fname == "delete":
            self.load_root_state()

    def _handle_edit_transition(
        self, fname: str, saved_reminder_id: str | None, original_reminder_id: str | None
    ) -> None:
        """Handle transitions from the edit reminder state.

        Args:
            fname: Name of the invoked tool.
            saved_reminder_id: ID of the reminder after save, if any.
            original_reminder_id: ID of the reminder being edited, if any.
        """
        if fname == "save":
            if saved_reminder_id is not None:
                self.set_current_state(ReminderDetail(reminder_id=saved_reminder_id))
        elif fname == "cancel":
            if original_reminder_id is not None:
                self.set_current_state(ReminderDetail(reminder_id=original_reminder_id))
            else:
                self.load_root_state()

__init__(*args, **kwargs)

Initialize the reminder app and load the root navigation state.

Parameters:

Name Type Description Default
*args Any

Positional arguments passed to ReminderApp.

()
**kwargs Any

Keyword arguments passed to ReminderApp.

{}
Source code in pare/apps/reminder/app.py
26
27
28
29
30
31
32
33
34
def __init__(self, *args: Any, **kwargs: Any) -> None:
    """Initialize the reminder app and load the root navigation state.

    Args:
        *args: Positional arguments passed to ReminderApp.
        **kwargs: Keyword arguments passed to ReminderApp.
    """
    super().__init__(*args, **kwargs)
    self.load_root_state()

create_root_state()

Create and return the root navigation state.

Returns:

Type Description
ReminderList

The initial ReminderList state.

Source code in pare/apps/reminder/app.py
36
37
38
39
40
41
42
def create_root_state(self) -> ReminderList:
    """Create and return the root navigation state.

    Returns:
        The initial ReminderList state.
    """
    return ReminderList()

get_reminder_with_id(reminder_id)

Retrieve a reminder by its ID.

Parameters:

Name Type Description Default
reminder_id str

The ID of the reminder to retrieve.

required

Returns:

Type Description
Reminder

The Reminder object corresponding to the given ID.

Raises:

Type Description
KeyError

If the reminder ID does not exist.

Source code in pare/apps/reminder/app.py
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
def get_reminder_with_id(self, reminder_id: str) -> Reminder:
    """Retrieve a reminder by its ID.

    Args:
        reminder_id: The ID of the reminder to retrieve.

    Returns:
        The Reminder object corresponding to the given ID.

    Raises:
        KeyError: If the reminder ID does not exist.
    """
    if reminder_id not in self.reminders:
        raise KeyError(f"Reminder {reminder_id} not found.")
    return self.reminders[reminder_id]

handle_state_transition(event)

Update navigation state after a reminder operation completes.

Parameters:

Name Type Description Default
event CompletedEvent

Completed event containing tool invocation information.

required
Source code in pare/apps/reminder/app.py
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
def handle_state_transition(self, event: CompletedEvent) -> None:
    """Update navigation state after a reminder operation completes.

    Args:
        event: Completed event containing tool invocation information.
    """
    current_state = self.current_state
    fname = event.function_name()

    if current_state is None or fname is None:
        return

    action = event.action
    args: dict[str, Any] = action.args if action and hasattr(action, "args") else {}

    metadata = event.metadata

    if isinstance(current_state, ReminderList):
        self._handle_list_transition(fname, args)
    elif isinstance(current_state, ReminderDetail):
        reminder_id = current_state.reminder_id
        self._handle_detail_transition(fname, reminder_id)
    elif isinstance(current_state, EditReminder):
        # EditReminder state can be reached from both creating a new reminder and editing an existing reminder.
        # If we are editing an existing reminder, we use the current reminder ID to navigate back to the ReminderDetail state.
        # Whereas, if we are creating a new reminder, there is no current reminder ID and we get the ID from the metadata after saving.
        saved_reminder_id = getattr(metadata, "return_value", None) if metadata else None
        original_reminder_id = current_state.reminder_id
        self._handle_edit_transition(
            fname, saved_reminder_id=saved_reminder_id, original_reminder_id=original_reminder_id
        )

update_reminder(reminder_id, title, description, due_datetime, repetition_unit, repetition_value)

Update an existing reminder and regenerate its repetitions.

Parameters:

Name Type Description Default
reminder_id str

ID of the reminder to update.

required
title str

Updated title.

required
description str

Updated description.

required
due_datetime str

Updated due datetime in "YYYY-MM-DD HH:MM:SS" format.

required
repetition_unit str | None

Repetition unit (e.g., "day", "week"), or None.

required
repetition_value int | None

Repetition interval value, or None.

required

Returns:

Name Type Description
str str

The reminder ID after update.

Raises:

Type Description
ValueError

If the reminder ID does not exist.

Source code in pare/apps/reminder/app.py
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
@app_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def update_reminder(
    self,
    reminder_id: str,
    title: str,
    description: str,
    due_datetime: str,
    repetition_unit: str | None,
    repetition_value: int | None,
) -> str:
    """Update an existing reminder and regenerate its repetitions.

    Args:
        reminder_id: ID of the reminder to update.
        title: Updated title.
        description: Updated description.
        due_datetime: Updated due datetime in "YYYY-MM-DD HH:MM:SS" format.
        repetition_unit: Repetition unit (e.g., "day", "week"), or None.
        repetition_value: Repetition interval value, or None.

    Returns:
        str: The reminder ID after update.

    Raises:
        ValueError: If the reminder ID does not exist.
    """
    if reminder_id not in self.reminders:
        raise ValueError(f"Reminder {reminder_id} not found.")

    dt = datetime.strptime(due_datetime, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC)

    reminder = self.reminders[reminder_id]
    reminder.title = title
    reminder.description = description
    reminder.due_datetime = dt
    reminder.repetition_unit = repetition_unit
    reminder.repetition_value = repetition_value

    base_id = reminder_id.split("_rep_")[0]
    to_delete = [k for k in self.reminders if k.startswith(f"{base_id}_rep_")]
    for k in to_delete:
        del self.reminders[k]

    next_id = reminder_id
    count = 0
    while repetition_unit and next_id and count < self.max_reminder_repetitions:
        next_id = self.add_reminder_repetition(next_id)
        if next_id:
            count += 1

    return reminder_id

EditReminder

Bases: AppState

State enabling editing of an existing reminder.

Source code in pare/apps/reminder/states.py
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
class EditReminder(AppState):
    """State enabling editing of an existing reminder."""

    def __init__(self, reminder_id: str | None = None) -> None:
        """Initialize the edit wizard.

        Args:
            reminder_id (str | None): ID of the reminder to edit.
        """
        super().__init__()
        self.reminder_id = reminder_id
        self.draft = ReminderDraft()

    def on_enter(self) -> None:
        """Load existing reminder fields into draft."""
        if self.reminder_id is None:
            return
        app = cast("StatefulReminderApp", self.app)
        try:
            r = app.get_reminder_with_id(self.reminder_id)
            self.draft.title = r.title
            self.draft.description = r.description
            self.draft.due_datetime = r.due_datetime.strftime("%Y-%m-%d %H:%M:%S")
            self.draft.repetition_unit = r.repetition_unit
            self.draft.repetition_value = r.repetition_value
        except KeyError:
            # If the reminder does not exist, keep the draft empty
            pass

    def on_exit(self) -> None:
        """Lifecycle hook called when exiting EditReminder."""
        pass

    @user_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def set_title(self, title: str) -> ReminderDraft:
        """Update title in the draft.

        Args:
            title (str): New title.

        Returns:
            ReminderDraft: Updated draft with new title.
        """
        self.draft.title = title
        return self.draft

    @user_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def set_description(self, description: str) -> ReminderDraft:
        """Update description in draft.

        Args:
            description (str): New description.

        Returns:
            ReminderDraft: Updated draft with new description.
        """
        self.draft.description = description
        return self.draft

    @user_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def set_due_datetime(self, due_datetime: str) -> ReminderDraft:
        """Update due datetime.

        Args:
            due_datetime (str): New datetime.

        Returns:
            ReminderDraft: Updated draft with new due datetime.
        """
        self.draft.due_datetime = due_datetime
        return self.draft

    @user_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def set_repetition(self, unit: str | None, value: int | None = None) -> ReminderDraft:
        """Update repetition settings.

        Args:
            unit (str | None): Repetition unit.
            value (int | None): Repetition count.

        Returns:
            ReminderDraft: Updated draft with new repetition settings.
        """
        self.draft.repetition_unit = unit
        self.draft.repetition_value = value
        return self.draft

    @user_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def save(self) -> str:
        """Save the changes to the reminder.

        Returns:
            str: Reminder ID after update.
        """
        app = cast("StatefulReminderApp", self.app)
        if self.reminder_id is None:
            # Creating a new reminder
            return app.add_reminder(
                title=self.draft.title,
                description=self.draft.description,
                due_datetime=self.draft.due_datetime,
                repetition_unit=self.draft.repetition_unit,
                repetition_value=self.draft.repetition_value,
            )
        else:
            return app.update_reminder(
                reminder_id=self.reminder_id,
                title=self.draft.title,
                description=self.draft.description,
                due_datetime=self.draft.due_datetime,
                repetition_unit=self.draft.repetition_unit,
                repetition_value=self.draft.repetition_value,
            )

    @user_tool()
    @pare_event_registered(operation_type=OperationType.READ)
    def cancel(self) -> str:
        """Abort editing and go back.

        Returns:
            str: Confirmation of cancellation.
        """
        return "Reminder editing cancelled."

__init__(reminder_id=None)

Initialize the edit wizard.

Parameters:

Name Type Description Default
reminder_id str | None

ID of the reminder to edit.

None
Source code in pare/apps/reminder/states.py
155
156
157
158
159
160
161
162
163
def __init__(self, reminder_id: str | None = None) -> None:
    """Initialize the edit wizard.

    Args:
        reminder_id (str | None): ID of the reminder to edit.
    """
    super().__init__()
    self.reminder_id = reminder_id
    self.draft = ReminderDraft()

cancel()

Abort editing and go back.

Returns:

Name Type Description
str str

Confirmation of cancellation.

Source code in pare/apps/reminder/states.py
271
272
273
274
275
276
277
278
279
@user_tool()
@pare_event_registered(operation_type=OperationType.READ)
def cancel(self) -> str:
    """Abort editing and go back.

    Returns:
        str: Confirmation of cancellation.
    """
    return "Reminder editing cancelled."

on_enter()

Load existing reminder fields into draft.

Source code in pare/apps/reminder/states.py
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
def on_enter(self) -> None:
    """Load existing reminder fields into draft."""
    if self.reminder_id is None:
        return
    app = cast("StatefulReminderApp", self.app)
    try:
        r = app.get_reminder_with_id(self.reminder_id)
        self.draft.title = r.title
        self.draft.description = r.description
        self.draft.due_datetime = r.due_datetime.strftime("%Y-%m-%d %H:%M:%S")
        self.draft.repetition_unit = r.repetition_unit
        self.draft.repetition_value = r.repetition_value
    except KeyError:
        # If the reminder does not exist, keep the draft empty
        pass

on_exit()

Lifecycle hook called when exiting EditReminder.

Source code in pare/apps/reminder/states.py
181
182
183
def on_exit(self) -> None:
    """Lifecycle hook called when exiting EditReminder."""
    pass

save()

Save the changes to the reminder.

Returns:

Name Type Description
str str

Reminder ID after update.

Source code in pare/apps/reminder/states.py
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
@user_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def save(self) -> str:
    """Save the changes to the reminder.

    Returns:
        str: Reminder ID after update.
    """
    app = cast("StatefulReminderApp", self.app)
    if self.reminder_id is None:
        # Creating a new reminder
        return app.add_reminder(
            title=self.draft.title,
            description=self.draft.description,
            due_datetime=self.draft.due_datetime,
            repetition_unit=self.draft.repetition_unit,
            repetition_value=self.draft.repetition_value,
        )
    else:
        return app.update_reminder(
            reminder_id=self.reminder_id,
            title=self.draft.title,
            description=self.draft.description,
            due_datetime=self.draft.due_datetime,
            repetition_unit=self.draft.repetition_unit,
            repetition_value=self.draft.repetition_value,
        )

set_description(description)

Update description in draft.

Parameters:

Name Type Description Default
description str

New description.

required

Returns:

Name Type Description
ReminderDraft ReminderDraft

Updated draft with new description.

Source code in pare/apps/reminder/states.py
199
200
201
202
203
204
205
206
207
208
209
210
211
@user_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def set_description(self, description: str) -> ReminderDraft:
    """Update description in draft.

    Args:
        description (str): New description.

    Returns:
        ReminderDraft: Updated draft with new description.
    """
    self.draft.description = description
    return self.draft

set_due_datetime(due_datetime)

Update due datetime.

Parameters:

Name Type Description Default
due_datetime str

New datetime.

required

Returns:

Name Type Description
ReminderDraft ReminderDraft

Updated draft with new due datetime.

Source code in pare/apps/reminder/states.py
213
214
215
216
217
218
219
220
221
222
223
224
225
@user_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def set_due_datetime(self, due_datetime: str) -> ReminderDraft:
    """Update due datetime.

    Args:
        due_datetime (str): New datetime.

    Returns:
        ReminderDraft: Updated draft with new due datetime.
    """
    self.draft.due_datetime = due_datetime
    return self.draft

set_repetition(unit, value=None)

Update repetition settings.

Parameters:

Name Type Description Default
unit str | None

Repetition unit.

required
value int | None

Repetition count.

None

Returns:

Name Type Description
ReminderDraft ReminderDraft

Updated draft with new repetition settings.

Source code in pare/apps/reminder/states.py
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
@user_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def set_repetition(self, unit: str | None, value: int | None = None) -> ReminderDraft:
    """Update repetition settings.

    Args:
        unit (str | None): Repetition unit.
        value (int | None): Repetition count.

    Returns:
        ReminderDraft: Updated draft with new repetition settings.
    """
    self.draft.repetition_unit = unit
    self.draft.repetition_value = value
    return self.draft

set_title(title)

Update title in the draft.

Parameters:

Name Type Description Default
title str

New title.

required

Returns:

Name Type Description
ReminderDraft ReminderDraft

Updated draft with new title.

Source code in pare/apps/reminder/states.py
185
186
187
188
189
190
191
192
193
194
195
196
197
@user_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def set_title(self, title: str) -> ReminderDraft:
    """Update title in the draft.

    Args:
        title (str): New title.

    Returns:
        ReminderDraft: Updated draft with new title.
    """
    self.draft.title = title
    return self.draft

ReminderDetail

Bases: AppState

State displaying the full details of a single reminder.

Source code in pare/apps/reminder/states.py
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
class ReminderDetail(AppState):
    """State displaying the full details of a single reminder."""

    def __init__(self, reminder_id: str) -> None:
        """Initialize the detail view.

        Args:
            reminder_id (str): The ID of the reminder being displayed.
        """
        super().__init__()
        self.reminder_id = reminder_id

    def on_enter(self) -> None:
        """Lifecycle hook called when entering ReminderDetail."""
        pass

    def on_exit(self) -> None:
        """Lifecycle hook called when leaving ReminderDetail."""
        pass

    @user_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def edit(self) -> str:
        """Request to edit this reminder.

        Returns:
            str: ID of reminder to edit.
        """
        return self.reminder_id

    @user_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def delete(self) -> str:
        """Delete this reminder.

        Returns:
            str: ID of deleted reminder.
        """
        app = cast("StatefulReminderApp", self.app)
        with disable_events():
            return app.delete_reminder(self.reminder_id)

__init__(reminder_id)

Initialize the detail view.

Parameters:

Name Type Description Default
reminder_id str

The ID of the reminder being displayed.

required
Source code in pare/apps/reminder/states.py
111
112
113
114
115
116
117
118
def __init__(self, reminder_id: str) -> None:
    """Initialize the detail view.

    Args:
        reminder_id (str): The ID of the reminder being displayed.
    """
    super().__init__()
    self.reminder_id = reminder_id

delete()

Delete this reminder.

Returns:

Name Type Description
str str

ID of deleted reminder.

Source code in pare/apps/reminder/states.py
138
139
140
141
142
143
144
145
146
147
148
@user_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def delete(self) -> str:
    """Delete this reminder.

    Returns:
        str: ID of deleted reminder.
    """
    app = cast("StatefulReminderApp", self.app)
    with disable_events():
        return app.delete_reminder(self.reminder_id)

edit()

Request to edit this reminder.

Returns:

Name Type Description
str str

ID of reminder to edit.

Source code in pare/apps/reminder/states.py
128
129
130
131
132
133
134
135
136
@user_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def edit(self) -> str:
    """Request to edit this reminder.

    Returns:
        str: ID of reminder to edit.
    """
    return self.reminder_id

on_enter()

Lifecycle hook called when entering ReminderDetail.

Source code in pare/apps/reminder/states.py
120
121
122
def on_enter(self) -> None:
    """Lifecycle hook called when entering ReminderDetail."""
    pass

on_exit()

Lifecycle hook called when leaving ReminderDetail.

Source code in pare/apps/reminder/states.py
124
125
126
def on_exit(self) -> None:
    """Lifecycle hook called when leaving ReminderDetail."""
    pass

ReminderDraft dataclass

Container representing mutable reminder fields during editing or creation.

Source code in pare/apps/reminder/states.py
17
18
19
20
21
22
23
24
25
@dataclass
class ReminderDraft:
    """Container representing mutable reminder fields during editing or creation."""

    title: str = ""
    description: str = ""
    due_datetime: str = ""
    repetition_unit: str | None = None
    repetition_value: int | None = None

ReminderList

Bases: AppState

State showing the full list of reminders.

Source code in pare/apps/reminder/states.py
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
class ReminderList(AppState):
    """State showing the full list of reminders."""

    def on_enter(self) -> None:
        """Lifecycle hook called when entering ReminderList."""
        pass

    def on_exit(self) -> None:
        """Lifecycle hook called when leaving ReminderList."""
        pass

    @user_tool(llm_formatter=lambda x: "\n\n".join([str(reminder) for reminder in x]))
    @pare_event_registered(operation_type=OperationType.READ)
    def list_all_reminders(self) -> list[Reminder]:
        """Get all the reminders from the reminder system.

        Returns:
            list[Reminder]: A list of reminders as returned by the backend.
        """
        app = cast("StatefulReminderApp", self.app)
        with disable_events():
            return app.get_all_reminders()

    @user_tool(llm_formatter=lambda x: "\n\n".join([str(reminder) for reminder in x]))
    @pare_event_registered(operation_type=OperationType.READ)
    def list_upcoming_reminders(self) -> list[Reminder]:
        """Get upcoming reminders from the reminder system. Upcoming reminders are those that are due in the near future.

        Returns:
            list[Reminder]: A list of upcoming reminders as returned by the backend.
        """
        app = cast("StatefulReminderApp", self.app)
        upcoming_reminders = []
        current_time = app.time_manager.time()
        with disable_events():
            reminders = app.get_all_reminders()
        for _, reminder in reminders.items():
            if reminder.due_datetime.timestamp() > current_time:
                upcoming_reminders.append(reminder)
        return upcoming_reminders

    @user_tool(llm_formatter=lambda x: "\n\n".join([str(reminder) for reminder in x]))
    @pare_event_registered(operation_type=OperationType.READ)
    def list_due_reminders(self) -> list[Reminder]:
        """Get due reminders from the reminder system. Due reminders are those that are past their due datetime.

        Returns:
            list[Reminder]: A list of due reminders as returned by the backend.
        """
        app = cast("StatefulReminderApp", self.app)
        with disable_events():
            return app.get_due_reminders()

    @user_tool()
    @pare_event_registered(operation_type=OperationType.READ)
    def open_reminder(self, reminder_id: str) -> Reminder:
        """Request to view details of a specific reminder.

        Args:
            reminder_id (str): ID of the reminder to view.

        Returns:
            Reminder: The Reminder object corresponding to the given ID.
        """
        app = cast("StatefulReminderApp", self.app)
        return app.get_reminder_with_id(reminder_id=reminder_id)

    @user_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def create_new(self) -> ReminderDraft:
        """Request to start creating a new reminder. Initially the reminder is empty.

        Returns:
            ReminderDraft: Draft Reminder object to be filled out.
        """
        return ReminderDraft()

create_new()

Request to start creating a new reminder. Initially the reminder is empty.

Returns:

Name Type Description
ReminderDraft ReminderDraft

Draft Reminder object to be filled out.

Source code in pare/apps/reminder/states.py
 96
 97
 98
 99
100
101
102
103
104
@user_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def create_new(self) -> ReminderDraft:
    """Request to start creating a new reminder. Initially the reminder is empty.

    Returns:
        ReminderDraft: Draft Reminder object to be filled out.
    """
    return ReminderDraft()

list_all_reminders()

Get all the reminders from the reminder system.

Returns:

Type Description
list[Reminder]

list[Reminder]: A list of reminders as returned by the backend.

Source code in pare/apps/reminder/states.py
40
41
42
43
44
45
46
47
48
49
50
@user_tool(llm_formatter=lambda x: "\n\n".join([str(reminder) for reminder in x]))
@pare_event_registered(operation_type=OperationType.READ)
def list_all_reminders(self) -> list[Reminder]:
    """Get all the reminders from the reminder system.

    Returns:
        list[Reminder]: A list of reminders as returned by the backend.
    """
    app = cast("StatefulReminderApp", self.app)
    with disable_events():
        return app.get_all_reminders()

list_due_reminders()

Get due reminders from the reminder system. Due reminders are those that are past their due datetime.

Returns:

Type Description
list[Reminder]

list[Reminder]: A list of due reminders as returned by the backend.

Source code in pare/apps/reminder/states.py
70
71
72
73
74
75
76
77
78
79
80
@user_tool(llm_formatter=lambda x: "\n\n".join([str(reminder) for reminder in x]))
@pare_event_registered(operation_type=OperationType.READ)
def list_due_reminders(self) -> list[Reminder]:
    """Get due reminders from the reminder system. Due reminders are those that are past their due datetime.

    Returns:
        list[Reminder]: A list of due reminders as returned by the backend.
    """
    app = cast("StatefulReminderApp", self.app)
    with disable_events():
        return app.get_due_reminders()

list_upcoming_reminders()

Get upcoming reminders from the reminder system. Upcoming reminders are those that are due in the near future.

Returns:

Type Description
list[Reminder]

list[Reminder]: A list of upcoming reminders as returned by the backend.

Source code in pare/apps/reminder/states.py
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
@user_tool(llm_formatter=lambda x: "\n\n".join([str(reminder) for reminder in x]))
@pare_event_registered(operation_type=OperationType.READ)
def list_upcoming_reminders(self) -> list[Reminder]:
    """Get upcoming reminders from the reminder system. Upcoming reminders are those that are due in the near future.

    Returns:
        list[Reminder]: A list of upcoming reminders as returned by the backend.
    """
    app = cast("StatefulReminderApp", self.app)
    upcoming_reminders = []
    current_time = app.time_manager.time()
    with disable_events():
        reminders = app.get_all_reminders()
    for _, reminder in reminders.items():
        if reminder.due_datetime.timestamp() > current_time:
            upcoming_reminders.append(reminder)
    return upcoming_reminders

on_enter()

Lifecycle hook called when entering ReminderList.

Source code in pare/apps/reminder/states.py
32
33
34
def on_enter(self) -> None:
    """Lifecycle hook called when entering ReminderList."""
    pass

on_exit()

Lifecycle hook called when leaving ReminderList.

Source code in pare/apps/reminder/states.py
36
37
38
def on_exit(self) -> None:
    """Lifecycle hook called when leaving ReminderList."""
    pass

open_reminder(reminder_id)

Request to view details of a specific reminder.

Parameters:

Name Type Description Default
reminder_id str

ID of the reminder to view.

required

Returns:

Name Type Description
Reminder Reminder

The Reminder object corresponding to the given ID.

Source code in pare/apps/reminder/states.py
82
83
84
85
86
87
88
89
90
91
92
93
94
@user_tool()
@pare_event_registered(operation_type=OperationType.READ)
def open_reminder(self, reminder_id: str) -> Reminder:
    """Request to view details of a specific reminder.

    Args:
        reminder_id (str): ID of the reminder to view.

    Returns:
        Reminder: The Reminder object corresponding to the given ID.
    """
    app = cast("StatefulReminderApp", self.app)
    return app.get_reminder_with_id(reminder_id=reminder_id)

Shopping App

Stateful shopping app combining Meta-ARE shopping backend with PARE navigation.

StatefulShoppingApp

Bases: StatefulApp, ShoppingApp

Shopping app with PARE-aware navigation.

Source code in pare/apps/shopping/app.py
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
class StatefulShoppingApp(StatefulApp, ShoppingApp):
    """Shopping app with PARE-aware navigation."""

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        """Initialise shopping app with root state."""
        super().__init__(*args, **kwargs)
        self.load_root_state()

    @type_check
    @data_tool()
    def add_order(
        self,
        order_id: str,
        order_status: str,
        order_date: float,
        order_total: float,
        item_id: str,
        quantity: int,
    ) -> str:
        """Add an order (used for scenario seeding).

        The upstream `are` ShoppingApp currently returns extra keys (e.g. `name`,
        `product_id`) from `_get_item()`, but its `CartItem` dataclass does not
        accept them. We defensively filter to the fields `CartItem` supports.

        Args:
            order_id: The ID of the order.
            order_status: The status of the order.
            order_date: The date of the order as a timestamp.
            order_total: The total amount of the order.
            item_id: The ID of the item to add to the order.
            quantity: The quantity of the item to add to the order.

        Returns:
            str: The ID of the created order.
        """
        item_dict = self._get_item(item_id)
        if not item_dict:
            raise ValueError("Item does not exist")

        cart_item = CartItem(
            item_id=item_dict["item_id"],
            quantity=quantity,
            price=item_dict["price"],
            available=item_dict.get("available", True),
            options=item_dict.get("options", {}),
        )
        self.orders[order_id] = Order(
            order_status=order_status,
            order_date=order_date,
            order_total=order_total,
            order_id=order_id,
            order_items={item_id: cart_item},
        )
        return order_id

    @type_check
    @data_tool()
    def add_order_multiple_items(
        self,
        order_id: str,
        order_status: str,
        order_date: float,
        order_total: float,
        items: dict[str, int],
    ) -> str:
        """Add an order with multiple items (used for scenario seeding).

        The upstream `are` ShoppingApp currently returns extra keys (e.g. `name`,
        `product_id`) from `_get_item()`, but its `CartItem` dataclass does not
        accept them. We defensively filter to the fields `CartItem` supports.

        Args:
            order_id: The ID of the order.
            order_status: The status of the order.
            order_date: The date of the order as a timestamp.
            order_total: The total amount of the order.
            items: A dictionary mapping item IDs to quantities.

        Returns:
            str: The ID of the created order.
        """
        order_items: dict[str, CartItem] = {}
        for item_id, quantity in items.items():
            item_dict = self._get_item(item_id)
            if not item_dict:
                raise ValueError(f"Item {item_id} does not exist")

            cart_item = CartItem(
                item_id=item_dict["item_id"],
                quantity=quantity,
                price=item_dict["price"],
                available=item_dict.get("available", True),
                options=item_dict.get("options", {}),
            )
            order_items[item_id] = cart_item

        self.orders[order_id] = Order(
            order_status=order_status,
            order_date=order_date,
            order_total=order_total,
            order_id=order_id,
            order_items=order_items,
        )
        return order_id

    def handle_state_transition(self, event: CompletedEvent) -> None:
        """Update navigation state based on completed operations."""
        current_state = self.current_state
        function_name = event.function_name()

        if current_state is None or function_name is None:
            return

        action = event.action
        args = action.resolved_args or action.args

        if isinstance(current_state, ShoppingHome):
            self._handle_home_transition(function_name, args, event)
            return

        if isinstance(current_state, ProductDetail):
            self._handle_product_transition(function_name, args, event)
            return

        if isinstance(current_state, VariantDetail):
            self._handle_variant_transition(function_name, args, event)
            return

        if isinstance(current_state, CartView):
            self._handle_cart_transition(function_name, args, event)
            return

        if isinstance(current_state, OrderListView):
            self._handle_order_list_transition(function_name, args, event)
            return

        if isinstance(current_state, OrderDetailView):
            self._handle_order_detail_transition(function_name, args, event)

    def _handle_home_transition(
        self,
        function_name: str,
        args: dict[str, object],
        event: CompletedEvent,
    ) -> None:
        """Handle transitions from ShoppingHome."""
        if function_name in {"view_product", "get_product", "get_product_details"}:
            pid = args.get("product_id")
            if isinstance(pid, str):
                self.set_current_state(ProductDetail(product_id=pid))
            return

        if function_name in {"get_item", "get_item_details", "_get_item"}:
            iid = args.get("item_id")
            if isinstance(iid, str):
                self.set_current_state(VariantDetail(item_id=iid))
            return

        if function_name in {"view_cart", "add_to_cart", "list_cart", "get_cart"}:
            self.set_current_state(CartView())
            return

        if function_name == "list_orders":
            self.set_current_state(OrderListView())

    def _handle_product_transition(
        self,
        function_name: str,
        args: dict[str, object],
        event: CompletedEvent,
    ) -> None:
        """Handle transitions from ProductDetail."""
        if function_name in {"view_variant", "get_item", "get_item_details", "_get_item"}:
            iid = args.get("item_id")
            if isinstance(iid, str):
                self.set_current_state(VariantDetail(item_id=iid))
            return

        if function_name == "add_to_cart":
            self.set_current_state(CartView())

    def _handle_variant_transition(
        self,
        function_name: str,
        args: dict[str, object],
        event: CompletedEvent,
    ) -> None:
        """Handle transitions from VariantDetail."""
        if function_name == "add_to_cart":
            self.set_current_state(CartView())

    def _handle_cart_transition(
        self,
        function_name: str,
        args: dict[str, object],
        event: CompletedEvent,
    ) -> None:
        """Handle transitions from CartView."""
        if function_name == "checkout":
            order_id = self._order_id_from_event(event)
            if isinstance(order_id, str):
                self.set_current_state(OrderDetailView(order_id=order_id))
            return

        if function_name in {"remove_item", "remove_from_cart"}:
            return

    def _handle_order_list_transition(
        self,
        function_name: str,
        args: dict[str, object],
        event: CompletedEvent,
    ) -> None:
        """Handle transitions from OrderListView."""
        if function_name in {"view_order", "get_order_details"}:
            oid = args.get("order_id")
            if isinstance(oid, str):
                self.set_current_state(OrderDetailView(order_id=oid))

    def _handle_order_detail_transition(
        self,
        function_name: str,
        args: dict[str, object],
        event: CompletedEvent,
    ) -> None:
        """Handle transitions from OrderDetailView."""
        return None

    @staticmethod
    def _order_id_from_event(event: CompletedEvent) -> str | None:
        """Extract order_id from event return payload."""
        if hasattr(event, "_return_value") and event._return_value:
            rv = event._return_value
            if isinstance(rv, str):
                return rv
            if isinstance(rv, dict):
                val = rv.get("order_id")
                return val if isinstance(val, str) else None

        meta = event.metadata.return_value if event.metadata else None
        if isinstance(meta, str):
            return meta
        if isinstance(meta, dict):
            val = meta.get("order_id")
            return val if isinstance(val, str) else None

        return None

    def create_root_state(self) -> ShoppingHome:
        """Return root navigation state."""
        return ShoppingHome()

    def get_item(self, item_id: str) -> dict[str, object]:
        """Wrapper for _get_item for compatibility."""
        return self._get_item(item_id)

    def get_cart(self) -> dict[str, object]:
        """Wrapper for list_cart() used by states."""
        return self.list_cart()

__init__(*args, **kwargs)

Initialise shopping app with root state.

Source code in pare/apps/shopping/app.py
28
29
30
31
def __init__(self, *args: Any, **kwargs: Any) -> None:
    """Initialise shopping app with root state."""
    super().__init__(*args, **kwargs)
    self.load_root_state()

add_order(order_id, order_status, order_date, order_total, item_id, quantity)

Add an order (used for scenario seeding).

The upstream are ShoppingApp currently returns extra keys (e.g. name, product_id) from _get_item(), but its CartItem dataclass does not accept them. We defensively filter to the fields CartItem supports.

Parameters:

Name Type Description Default
order_id str

The ID of the order.

required
order_status str

The status of the order.

required
order_date float

The date of the order as a timestamp.

required
order_total float

The total amount of the order.

required
item_id str

The ID of the item to add to the order.

required
quantity int

The quantity of the item to add to the order.

required

Returns:

Name Type Description
str str

The ID of the created order.

Source code in pare/apps/shopping/app.py
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
@type_check
@data_tool()
def add_order(
    self,
    order_id: str,
    order_status: str,
    order_date: float,
    order_total: float,
    item_id: str,
    quantity: int,
) -> str:
    """Add an order (used for scenario seeding).

    The upstream `are` ShoppingApp currently returns extra keys (e.g. `name`,
    `product_id`) from `_get_item()`, but its `CartItem` dataclass does not
    accept them. We defensively filter to the fields `CartItem` supports.

    Args:
        order_id: The ID of the order.
        order_status: The status of the order.
        order_date: The date of the order as a timestamp.
        order_total: The total amount of the order.
        item_id: The ID of the item to add to the order.
        quantity: The quantity of the item to add to the order.

    Returns:
        str: The ID of the created order.
    """
    item_dict = self._get_item(item_id)
    if not item_dict:
        raise ValueError("Item does not exist")

    cart_item = CartItem(
        item_id=item_dict["item_id"],
        quantity=quantity,
        price=item_dict["price"],
        available=item_dict.get("available", True),
        options=item_dict.get("options", {}),
    )
    self.orders[order_id] = Order(
        order_status=order_status,
        order_date=order_date,
        order_total=order_total,
        order_id=order_id,
        order_items={item_id: cart_item},
    )
    return order_id

add_order_multiple_items(order_id, order_status, order_date, order_total, items)

Add an order with multiple items (used for scenario seeding).

The upstream are ShoppingApp currently returns extra keys (e.g. name, product_id) from _get_item(), but its CartItem dataclass does not accept them. We defensively filter to the fields CartItem supports.

Parameters:

Name Type Description Default
order_id str

The ID of the order.

required
order_status str

The status of the order.

required
order_date float

The date of the order as a timestamp.

required
order_total float

The total amount of the order.

required
items dict[str, int]

A dictionary mapping item IDs to quantities.

required

Returns:

Name Type Description
str str

The ID of the created order.

Source code in pare/apps/shopping/app.py
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
@type_check
@data_tool()
def add_order_multiple_items(
    self,
    order_id: str,
    order_status: str,
    order_date: float,
    order_total: float,
    items: dict[str, int],
) -> str:
    """Add an order with multiple items (used for scenario seeding).

    The upstream `are` ShoppingApp currently returns extra keys (e.g. `name`,
    `product_id`) from `_get_item()`, but its `CartItem` dataclass does not
    accept them. We defensively filter to the fields `CartItem` supports.

    Args:
        order_id: The ID of the order.
        order_status: The status of the order.
        order_date: The date of the order as a timestamp.
        order_total: The total amount of the order.
        items: A dictionary mapping item IDs to quantities.

    Returns:
        str: The ID of the created order.
    """
    order_items: dict[str, CartItem] = {}
    for item_id, quantity in items.items():
        item_dict = self._get_item(item_id)
        if not item_dict:
            raise ValueError(f"Item {item_id} does not exist")

        cart_item = CartItem(
            item_id=item_dict["item_id"],
            quantity=quantity,
            price=item_dict["price"],
            available=item_dict.get("available", True),
            options=item_dict.get("options", {}),
        )
        order_items[item_id] = cart_item

    self.orders[order_id] = Order(
        order_status=order_status,
        order_date=order_date,
        order_total=order_total,
        order_id=order_id,
        order_items=order_items,
    )
    return order_id

create_root_state()

Return root navigation state.

Source code in pare/apps/shopping/app.py
274
275
276
def create_root_state(self) -> ShoppingHome:
    """Return root navigation state."""
    return ShoppingHome()

get_cart()

Wrapper for list_cart() used by states.

Source code in pare/apps/shopping/app.py
282
283
284
def get_cart(self) -> dict[str, object]:
    """Wrapper for list_cart() used by states."""
    return self.list_cart()

get_item(item_id)

Wrapper for _get_item for compatibility.

Source code in pare/apps/shopping/app.py
278
279
280
def get_item(self, item_id: str) -> dict[str, object]:
    """Wrapper for _get_item for compatibility."""
    return self._get_item(item_id)

handle_state_transition(event)

Update navigation state based on completed operations.

Source code in pare/apps/shopping/app.py
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
def handle_state_transition(self, event: CompletedEvent) -> None:
    """Update navigation state based on completed operations."""
    current_state = self.current_state
    function_name = event.function_name()

    if current_state is None or function_name is None:
        return

    action = event.action
    args = action.resolved_args or action.args

    if isinstance(current_state, ShoppingHome):
        self._handle_home_transition(function_name, args, event)
        return

    if isinstance(current_state, ProductDetail):
        self._handle_product_transition(function_name, args, event)
        return

    if isinstance(current_state, VariantDetail):
        self._handle_variant_transition(function_name, args, event)
        return

    if isinstance(current_state, CartView):
        self._handle_cart_transition(function_name, args, event)
        return

    if isinstance(current_state, OrderListView):
        self._handle_order_list_transition(function_name, args, event)
        return

    if isinstance(current_state, OrderDetailView):
        self._handle_order_detail_transition(function_name, args, event)

Navigation state implementations for the stateful shopping app.

This module defines the navigation-aware states used by StatefulShoppingApp, providing product browsing, variant inspection, cart interaction, and order history viewing.

Output format conventions
  • READ operations → dict | list | object
  • WRITE operations → str | dict

CartView

Bases: AppState

Navigation state showing cart contents.

Source code in pare/apps/shopping/states.py
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
class CartView(AppState):
    """Navigation state showing cart contents."""

    def __init__(self) -> None:
        """Initialise the cart view."""
        super().__init__()

    def on_enter(self) -> None:
        """No-op entry hook."""
        return None

    def on_exit(self) -> None:
        """No-op exit hook."""
        return None

    @user_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def remove_item(self, item_id: str, quantity: int = 1) -> str | dict[str, Any]:
        """Remove an item or reduce its quantity.

        Args:
            item_id: Identifier of the variant to remove.
            quantity: Quantity to remove.

        Returns:
            str | dict[str, Any]: Backend removal result.
        """
        app = cast("StatefulShoppingApp", self.app)
        return app.remove_from_cart(item_id=item_id, quantity=quantity)

    @user_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def checkout(self, discount_code: str | None = None) -> str | dict[str, Any]:
        """Checkout the cart and create an order.

        Args:
            discount_code: Optional discount code.

        Returns:
            str | dict[str, Any]: Order confirmation.
        """
        app = cast("StatefulShoppingApp", self.app)
        return app.checkout(discount_code=discount_code)

__init__()

Initialise the cart view.

Source code in pare/apps/shopping/states.py
169
170
171
def __init__(self) -> None:
    """Initialise the cart view."""
    super().__init__()

checkout(discount_code=None)

Checkout the cart and create an order.

Parameters:

Name Type Description Default
discount_code str | None

Optional discount code.

None

Returns:

Type Description
str | dict[str, Any]

str | dict[str, Any]: Order confirmation.

Source code in pare/apps/shopping/states.py
196
197
198
199
200
201
202
203
204
205
206
207
208
@user_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def checkout(self, discount_code: str | None = None) -> str | dict[str, Any]:
    """Checkout the cart and create an order.

    Args:
        discount_code: Optional discount code.

    Returns:
        str | dict[str, Any]: Order confirmation.
    """
    app = cast("StatefulShoppingApp", self.app)
    return app.checkout(discount_code=discount_code)

on_enter()

No-op entry hook.

Source code in pare/apps/shopping/states.py
173
174
175
def on_enter(self) -> None:
    """No-op entry hook."""
    return None

on_exit()

No-op exit hook.

Source code in pare/apps/shopping/states.py
177
178
179
def on_exit(self) -> None:
    """No-op exit hook."""
    return None

remove_item(item_id, quantity=1)

Remove an item or reduce its quantity.

Parameters:

Name Type Description Default
item_id str

Identifier of the variant to remove.

required
quantity int

Quantity to remove.

1

Returns:

Type Description
str | dict[str, Any]

str | dict[str, Any]: Backend removal result.

Source code in pare/apps/shopping/states.py
181
182
183
184
185
186
187
188
189
190
191
192
193
194
@user_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def remove_item(self, item_id: str, quantity: int = 1) -> str | dict[str, Any]:
    """Remove an item or reduce its quantity.

    Args:
        item_id: Identifier of the variant to remove.
        quantity: Quantity to remove.

    Returns:
        str | dict[str, Any]: Backend removal result.
    """
    app = cast("StatefulShoppingApp", self.app)
    return app.remove_from_cart(item_id=item_id, quantity=quantity)

OrderDetailView

Bases: AppState

Detailed view for a single order.

Source code in pare/apps/shopping/states.py
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
class OrderDetailView(AppState):
    """Detailed view for a single order."""

    def __init__(self, order_id: str) -> None:
        """Bind state to a specific order.

        Args:
            order_id: Identifier of the order to display.
        """
        super().__init__()
        self.order_id = order_id

    def on_enter(self) -> None:
        """No-op entry hook."""
        return None

    def on_exit(self) -> None:
        """No-op exit hook."""
        return None

    @user_tool()
    @pare_event_registered(operation_type=OperationType.READ)
    def view_order(self) -> dict[str, Any]:
        """Retrieve backend data for the current order.

        Returns:
            dict[str, Any]: Order detail payload.
        """
        app = cast("StatefulShoppingApp", self.app)
        return app.get_order_details(order_id=self.order_id)

__init__(order_id)

Bind state to a specific order.

Parameters:

Name Type Description Default
order_id str

Identifier of the order to display.

required
Source code in pare/apps/shopping/states.py
246
247
248
249
250
251
252
253
def __init__(self, order_id: str) -> None:
    """Bind state to a specific order.

    Args:
        order_id: Identifier of the order to display.
    """
    super().__init__()
    self.order_id = order_id

on_enter()

No-op entry hook.

Source code in pare/apps/shopping/states.py
255
256
257
def on_enter(self) -> None:
    """No-op entry hook."""
    return None

on_exit()

No-op exit hook.

Source code in pare/apps/shopping/states.py
259
260
261
def on_exit(self) -> None:
    """No-op exit hook."""
    return None

view_order()

Retrieve backend data for the current order.

Returns:

Type Description
dict[str, Any]

dict[str, Any]: Order detail payload.

Source code in pare/apps/shopping/states.py
263
264
265
266
267
268
269
270
271
272
@user_tool()
@pare_event_registered(operation_type=OperationType.READ)
def view_order(self) -> dict[str, Any]:
    """Retrieve backend data for the current order.

    Returns:
        dict[str, Any]: Order detail payload.
    """
    app = cast("StatefulShoppingApp", self.app)
    return app.get_order_details(order_id=self.order_id)

OrderListView

Bases: AppState

State listing all completed orders.

Source code in pare/apps/shopping/states.py
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
class OrderListView(AppState):
    """State listing all completed orders."""

    def __init__(self) -> None:
        """Initialise the order list view."""
        super().__init__()

    def on_enter(self) -> None:
        """No-op entry hook."""
        return None

    def on_exit(self) -> None:
        """No-op exit hook."""
        return None

    @user_tool()
    @pare_event_registered(operation_type=OperationType.READ)
    def view_order(self, order_id: str) -> dict[str, Any]:
        """Open details for a specific order.

        Args:
            order_id: Order identifier.

        Returns:
            dict[str, Any]: Order detail payload.
        """
        app = cast("StatefulShoppingApp", self.app)
        return app.get_order_details(order_id=order_id)

__init__()

Initialise the order list view.

Source code in pare/apps/shopping/states.py
215
216
217
def __init__(self) -> None:
    """Initialise the order list view."""
    super().__init__()

on_enter()

No-op entry hook.

Source code in pare/apps/shopping/states.py
219
220
221
def on_enter(self) -> None:
    """No-op entry hook."""
    return None

on_exit()

No-op exit hook.

Source code in pare/apps/shopping/states.py
223
224
225
def on_exit(self) -> None:
    """No-op exit hook."""
    return None

view_order(order_id)

Open details for a specific order.

Parameters:

Name Type Description Default
order_id str

Order identifier.

required

Returns:

Type Description
dict[str, Any]

dict[str, Any]: Order detail payload.

Source code in pare/apps/shopping/states.py
227
228
229
230
231
232
233
234
235
236
237
238
239
@user_tool()
@pare_event_registered(operation_type=OperationType.READ)
def view_order(self, order_id: str) -> dict[str, Any]:
    """Open details for a specific order.

    Args:
        order_id: Order identifier.

    Returns:
        dict[str, Any]: Order detail payload.
    """
    app = cast("StatefulShoppingApp", self.app)
    return app.get_order_details(order_id=order_id)

ProductDetail

Bases: AppState

Detail view for a specific product.

Source code in pare/apps/shopping/states.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
class ProductDetail(AppState):
    """Detail view for a specific product."""

    def __init__(self, product_id: str) -> None:
        """Bind this state to a product identifier.

        Args:
            product_id: Product ID for which details are displayed.
        """
        super().__init__()
        self.product_id = product_id

    def on_enter(self) -> None:
        """No-op entry hook."""
        return None

    def on_exit(self) -> None:
        """No-op exit hook."""
        return None

    @user_tool()
    @pare_event_registered(operation_type=OperationType.READ)
    def view_variant(self, item_id: str) -> dict[str, Any]:
        """Open a specific variant for this product.

        Args:
            item_id: Variant (item) identifier.

        Returns:
            dict[str, Any]: Variant detail payload.
        """
        app = cast("StatefulShoppingApp", self.app)
        return app._get_item(item_id=item_id)

__init__(product_id)

Bind this state to a product identifier.

Parameters:

Name Type Description Default
product_id str

Product ID for which details are displayed.

required
Source code in pare/apps/shopping/states.py
 97
 98
 99
100
101
102
103
104
def __init__(self, product_id: str) -> None:
    """Bind this state to a product identifier.

    Args:
        product_id: Product ID for which details are displayed.
    """
    super().__init__()
    self.product_id = product_id

on_enter()

No-op entry hook.

Source code in pare/apps/shopping/states.py
106
107
108
def on_enter(self) -> None:
    """No-op entry hook."""
    return None

on_exit()

No-op exit hook.

Source code in pare/apps/shopping/states.py
110
111
112
def on_exit(self) -> None:
    """No-op exit hook."""
    return None

view_variant(item_id)

Open a specific variant for this product.

Parameters:

Name Type Description Default
item_id str

Variant (item) identifier.

required

Returns:

Type Description
dict[str, Any]

dict[str, Any]: Variant detail payload.

Source code in pare/apps/shopping/states.py
114
115
116
117
118
119
120
121
122
123
124
125
126
@user_tool()
@pare_event_registered(operation_type=OperationType.READ)
def view_variant(self, item_id: str) -> dict[str, Any]:
    """Open a specific variant for this product.

    Args:
        item_id: Variant (item) identifier.

    Returns:
        dict[str, Any]: Variant detail payload.
    """
    app = cast("StatefulShoppingApp", self.app)
    return app._get_item(item_id=item_id)

ShoppingHome

Bases: AppState

Root navigation state providing product catalog access.

Source code in pare/apps/shopping/states.py
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
class ShoppingHome(AppState):
    """Root navigation state providing product catalog access."""

    def __init__(self) -> None:
        """Initialise the home state."""
        super().__init__()

    def on_enter(self) -> None:
        """No-op hook for entering the home screen."""
        return None

    def on_exit(self) -> None:
        """No-op hook for exiting the home screen."""
        return None

    @user_tool()
    @pare_event_registered(operation_type=OperationType.READ)
    def list_products(self, offset: int = 0, limit: int = 10) -> dict[str, Any]:
        """List all available products.

        Args:
            offset: Pagination offset.
            limit: Maximum number of products to return.

        Returns:
            dict[str, Any]: Backend payload containing products and metadata.
        """
        app = cast("StatefulShoppingApp", self.app)
        return app.list_all_products(offset=offset, limit=limit)

    @user_tool()
    @pare_event_registered(operation_type=OperationType.READ)
    def view_product(self, product_id: str) -> dict[str, Any]:
        """Retrieve product details and navigate to ProductDetail.

        Args:
            product_id: Identifier for the product to open.

        Returns:
            dict[str, Any]: Detailed product information.
        """
        app = cast("StatefulShoppingApp", self.app)
        return app.get_product_details(product_id=product_id)

    @user_tool()
    @pare_event_registered(operation_type=OperationType.READ)
    def view_cart(self) -> dict[str, Any]:
        """Open the cart screen.

        Returns:
            dict[str, Any]: Cart contents.
        """
        app = cast("StatefulShoppingApp", self.app)
        return app.get_cart()

    @user_tool()
    @pare_event_registered(operation_type=OperationType.READ)
    def list_orders(self) -> list[dict[str, Any]]:
        """List all previous orders.

        Returns:
            list[dict[str, Any]]: Summaries of past orders.
        """
        app = cast("StatefulShoppingApp", self.app)
        return app.list_orders()

__init__()

Initialise the home state.

Source code in pare/apps/shopping/states.py
29
30
31
def __init__(self) -> None:
    """Initialise the home state."""
    super().__init__()

list_orders()

List all previous orders.

Returns:

Type Description
list[dict[str, Any]]

list[dict[str, Any]]: Summaries of past orders.

Source code in pare/apps/shopping/states.py
81
82
83
84
85
86
87
88
89
90
@user_tool()
@pare_event_registered(operation_type=OperationType.READ)
def list_orders(self) -> list[dict[str, Any]]:
    """List all previous orders.

    Returns:
        list[dict[str, Any]]: Summaries of past orders.
    """
    app = cast("StatefulShoppingApp", self.app)
    return app.list_orders()

list_products(offset=0, limit=10)

List all available products.

Parameters:

Name Type Description Default
offset int

Pagination offset.

0
limit int

Maximum number of products to return.

10

Returns:

Type Description
dict[str, Any]

dict[str, Any]: Backend payload containing products and metadata.

Source code in pare/apps/shopping/states.py
41
42
43
44
45
46
47
48
49
50
51
52
53
54
@user_tool()
@pare_event_registered(operation_type=OperationType.READ)
def list_products(self, offset: int = 0, limit: int = 10) -> dict[str, Any]:
    """List all available products.

    Args:
        offset: Pagination offset.
        limit: Maximum number of products to return.

    Returns:
        dict[str, Any]: Backend payload containing products and metadata.
    """
    app = cast("StatefulShoppingApp", self.app)
    return app.list_all_products(offset=offset, limit=limit)

on_enter()

No-op hook for entering the home screen.

Source code in pare/apps/shopping/states.py
33
34
35
def on_enter(self) -> None:
    """No-op hook for entering the home screen."""
    return None

on_exit()

No-op hook for exiting the home screen.

Source code in pare/apps/shopping/states.py
37
38
39
def on_exit(self) -> None:
    """No-op hook for exiting the home screen."""
    return None

view_cart()

Open the cart screen.

Returns:

Type Description
dict[str, Any]

dict[str, Any]: Cart contents.

Source code in pare/apps/shopping/states.py
70
71
72
73
74
75
76
77
78
79
@user_tool()
@pare_event_registered(operation_type=OperationType.READ)
def view_cart(self) -> dict[str, Any]:
    """Open the cart screen.

    Returns:
        dict[str, Any]: Cart contents.
    """
    app = cast("StatefulShoppingApp", self.app)
    return app.get_cart()

view_product(product_id)

Retrieve product details and navigate to ProductDetail.

Parameters:

Name Type Description Default
product_id str

Identifier for the product to open.

required

Returns:

Type Description
dict[str, Any]

dict[str, Any]: Detailed product information.

Source code in pare/apps/shopping/states.py
56
57
58
59
60
61
62
63
64
65
66
67
68
@user_tool()
@pare_event_registered(operation_type=OperationType.READ)
def view_product(self, product_id: str) -> dict[str, Any]:
    """Retrieve product details and navigate to ProductDetail.

    Args:
        product_id: Identifier for the product to open.

    Returns:
        dict[str, Any]: Detailed product information.
    """
    app = cast("StatefulShoppingApp", self.app)
    return app.get_product_details(product_id=product_id)

VariantDetail

Bases: AppState

Detail view for a single product variant.

Source code in pare/apps/shopping/states.py
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
class VariantDetail(AppState):
    """Detail view for a single product variant."""

    def __init__(self, item_id: str) -> None:
        """Initialise the state with an item identifier.

        Args:
            item_id: The variant being displayed.
        """
        super().__init__()
        self.item_id = item_id

    def on_enter(self) -> None:
        """No-op entry hook."""
        return None

    def on_exit(self) -> None:
        """No-op exit hook."""
        return None

    @user_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def add_to_cart(self, quantity: int = 1) -> str | dict[str, Any]:
        """Add this variant to the cart.

        Args:
            quantity: Number of units to add.

        Returns:
            str | dict[str, Any]: Backend confirmation or cart update result.
        """
        app = cast("StatefulShoppingApp", self.app)
        return app.add_to_cart(item_id=self.item_id, quantity=quantity)

__init__(item_id)

Initialise the state with an item identifier.

Parameters:

Name Type Description Default
item_id str

The variant being displayed.

required
Source code in pare/apps/shopping/states.py
133
134
135
136
137
138
139
140
def __init__(self, item_id: str) -> None:
    """Initialise the state with an item identifier.

    Args:
        item_id: The variant being displayed.
    """
    super().__init__()
    self.item_id = item_id

add_to_cart(quantity=1)

Add this variant to the cart.

Parameters:

Name Type Description Default
quantity int

Number of units to add.

1

Returns:

Type Description
str | dict[str, Any]

str | dict[str, Any]: Backend confirmation or cart update result.

Source code in pare/apps/shopping/states.py
150
151
152
153
154
155
156
157
158
159
160
161
162
@user_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def add_to_cart(self, quantity: int = 1) -> str | dict[str, Any]:
    """Add this variant to the cart.

    Args:
        quantity: Number of units to add.

    Returns:
        str | dict[str, Any]: Backend confirmation or cart update result.
    """
    app = cast("StatefulShoppingApp", self.app)
    return app.add_to_cart(item_id=self.item_id, quantity=quantity)

on_enter()

No-op entry hook.

Source code in pare/apps/shopping/states.py
142
143
144
def on_enter(self) -> None:
    """No-op entry hook."""
    return None

on_exit()

No-op exit hook.

Source code in pare/apps/shopping/states.py
146
147
148
def on_exit(self) -> None:
    """No-op exit hook."""
    return None

Notes App

NotesFolder

Container managing notes within a single folder.

Source code in pare/apps/note/app.py
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
class NotesFolder:
    """Container managing notes within a single folder."""

    def __init__(self, folder_name: str) -> None:
        """Initialize a note folder.

        Args:
            folder_name (str): Name of the folder.
        """
        self.folder_name = folder_name
        self.notes: dict[str, Note] = {}

    def add_note(self, note: Note) -> None:
        """Add a note and sort by timestamp.

        Args:
            note (Note): Note to add.
        """
        if not isinstance(note, Note):
            raise TypeError(f"Note must be an instance of Note, got {type(note)}.")
        self.notes[note.note_id] = note

    def remove_note(self, note_id: str) -> bool:
        """Remove a note by ID.

        Args:
            note_id (str): ID of note to remove.

        Returns:
            bool: True if removed, False if not found.
        """
        if note_id not in self.notes:
            return False
        del self.notes[note_id]
        return True

    def get_notes(self, offset: int = 0, limit: int = 5) -> ReturnedNotes:
        """Retrieve paginated notes with the most recently updated notes first.

        Args:
            offset (int): Starting index.
            limit (int): Maximum number of notes to return.

        Returns:
            ReturnedNotes: Paginated result container.
        """
        if not isinstance(offset, int):
            raise TypeError(f"Offset must be an integer, got {type(offset)}.")
        if offset < 0:
            raise ValueError("Offset must be non-negative.")
        if offset > len(self.notes):
            raise ValueError("Offset must be less than the number of notes.")

        total = len(self.notes)
        end = min(offset + limit, total)
        returned = list(self.notes.values())[offset:end]
        returned = sorted(returned, key=lambda n: n.updated_at, reverse=True)

        return ReturnedNotes(
            notes=returned, notes_range=(offset, end), total_returned_notes=len(returned), total_notes=total
        )

    def get_note(self, idx: int) -> Note:
        """Get a note by index.

        Args:
            idx (int): Index of the note.

        Returns:
            Note: The note at the given index.
        """
        if not isinstance(idx, int):
            raise TypeError(f"Index must be an integer, got {type(idx)}.")
        if idx < 0:
            raise ValueError("Index must be non-negative.")
        if int(idx) >= len(self.notes):
            raise ValueError(f"Index {idx} is out of range.")
        return list(self.notes.values())[idx]

    def get_note_by_id(self, note_id: str) -> Note | None:
        """Lookup a note by ID.

        Args:
            note_id (str): Target note ID.

        Returns:
            Note: Found note.
        """
        if note_id not in self.notes:
            return None
        return self.notes[note_id]

    def search_notes(self, query: str) -> list[Note]:
        """Search notes within this folder using a query string.

        Args:
            query (str): Search query.

        Returns:
            list[Note]: Matched notes.
        """
        query_lower = query.lower()
        return [n for n in self.notes.values() if query_lower in n.title.lower() or query_lower in n.content.lower()]

    def get_state(self) -> dict[str, Any]:
        """Serialize folder state.

        Returns:
            dict[str, Any]: Serialized state.
        """
        return get_state_dict(self, ["folder_name", "notes"])

    def load_state(self, state_dict: dict[str, Any]) -> None:
        """Deserialize folder state.

        Args:
            state_dict (dict[str, Any]): State to load.
        """
        self.folder_name = state_dict["folder_name"]
        self.notes = {note_id: Note(**note_data) for note_id, note_data in state_dict["notes"].items()}
        self.notes = dict(sorted(self.notes.items(), key=lambda item: item[1].updated_at, reverse=True))

__init__(folder_name)

Initialize a note folder.

Parameters:

Name Type Description Default
folder_name str

Name of the folder.

required
Source code in pare/apps/note/app.py
33
34
35
36
37
38
39
40
def __init__(self, folder_name: str) -> None:
    """Initialize a note folder.

    Args:
        folder_name (str): Name of the folder.
    """
    self.folder_name = folder_name
    self.notes: dict[str, Note] = {}

add_note(note)

Add a note and sort by timestamp.

Parameters:

Name Type Description Default
note Note

Note to add.

required
Source code in pare/apps/note/app.py
42
43
44
45
46
47
48
49
50
def add_note(self, note: Note) -> None:
    """Add a note and sort by timestamp.

    Args:
        note (Note): Note to add.
    """
    if not isinstance(note, Note):
        raise TypeError(f"Note must be an instance of Note, got {type(note)}.")
    self.notes[note.note_id] = note

get_note(idx)

Get a note by index.

Parameters:

Name Type Description Default
idx int

Index of the note.

required

Returns:

Name Type Description
Note Note

The note at the given index.

Source code in pare/apps/note/app.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
def get_note(self, idx: int) -> Note:
    """Get a note by index.

    Args:
        idx (int): Index of the note.

    Returns:
        Note: The note at the given index.
    """
    if not isinstance(idx, int):
        raise TypeError(f"Index must be an integer, got {type(idx)}.")
    if idx < 0:
        raise ValueError("Index must be non-negative.")
    if int(idx) >= len(self.notes):
        raise ValueError(f"Index {idx} is out of range.")
    return list(self.notes.values())[idx]

get_note_by_id(note_id)

Lookup a note by ID.

Parameters:

Name Type Description Default
note_id str

Target note ID.

required

Returns:

Name Type Description
Note Note | None

Found note.

Source code in pare/apps/note/app.py
109
110
111
112
113
114
115
116
117
118
119
120
def get_note_by_id(self, note_id: str) -> Note | None:
    """Lookup a note by ID.

    Args:
        note_id (str): Target note ID.

    Returns:
        Note: Found note.
    """
    if note_id not in self.notes:
        return None
    return self.notes[note_id]

get_notes(offset=0, limit=5)

Retrieve paginated notes with the most recently updated notes first.

Parameters:

Name Type Description Default
offset int

Starting index.

0
limit int

Maximum number of notes to return.

5

Returns:

Name Type Description
ReturnedNotes ReturnedNotes

Paginated result container.

Source code in pare/apps/note/app.py
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
def get_notes(self, offset: int = 0, limit: int = 5) -> ReturnedNotes:
    """Retrieve paginated notes with the most recently updated notes first.

    Args:
        offset (int): Starting index.
        limit (int): Maximum number of notes to return.

    Returns:
        ReturnedNotes: Paginated result container.
    """
    if not isinstance(offset, int):
        raise TypeError(f"Offset must be an integer, got {type(offset)}.")
    if offset < 0:
        raise ValueError("Offset must be non-negative.")
    if offset > len(self.notes):
        raise ValueError("Offset must be less than the number of notes.")

    total = len(self.notes)
    end = min(offset + limit, total)
    returned = list(self.notes.values())[offset:end]
    returned = sorted(returned, key=lambda n: n.updated_at, reverse=True)

    return ReturnedNotes(
        notes=returned, notes_range=(offset, end), total_returned_notes=len(returned), total_notes=total
    )

get_state()

Serialize folder state.

Returns:

Type Description
dict[str, Any]

dict[str, Any]: Serialized state.

Source code in pare/apps/note/app.py
134
135
136
137
138
139
140
def get_state(self) -> dict[str, Any]:
    """Serialize folder state.

    Returns:
        dict[str, Any]: Serialized state.
    """
    return get_state_dict(self, ["folder_name", "notes"])

load_state(state_dict)

Deserialize folder state.

Parameters:

Name Type Description Default
state_dict dict[str, Any]

State to load.

required
Source code in pare/apps/note/app.py
142
143
144
145
146
147
148
149
150
def load_state(self, state_dict: dict[str, Any]) -> None:
    """Deserialize folder state.

    Args:
        state_dict (dict[str, Any]): State to load.
    """
    self.folder_name = state_dict["folder_name"]
    self.notes = {note_id: Note(**note_data) for note_id, note_data in state_dict["notes"].items()}
    self.notes = dict(sorted(self.notes.items(), key=lambda item: item[1].updated_at, reverse=True))

remove_note(note_id)

Remove a note by ID.

Parameters:

Name Type Description Default
note_id str

ID of note to remove.

required

Returns:

Name Type Description
bool bool

True if removed, False if not found.

Source code in pare/apps/note/app.py
52
53
54
55
56
57
58
59
60
61
62
63
64
def remove_note(self, note_id: str) -> bool:
    """Remove a note by ID.

    Args:
        note_id (str): ID of note to remove.

    Returns:
        bool: True if removed, False if not found.
    """
    if note_id not in self.notes:
        return False
    del self.notes[note_id]
    return True

search_notes(query)

Search notes within this folder using a query string.

Parameters:

Name Type Description Default
query str

Search query.

required

Returns:

Type Description
list[Note]

list[Note]: Matched notes.

Source code in pare/apps/note/app.py
122
123
124
125
126
127
128
129
130
131
132
def search_notes(self, query: str) -> list[Note]:
    """Search notes within this folder using a query string.

    Args:
        query (str): Search query.

    Returns:
        list[Note]: Matched notes.
    """
    query_lower = query.lower()
    return [n for n in self.notes.values() if query_lower in n.title.lower() or query_lower in n.content.lower()]

StatefulNotesApp dataclass

Bases: StatefulApp

A Notes application that manages user's notes and folder organization. This class provides comprehensive functionality for handling notes including creating, updating, deleting, and searching notes.

This app maintains the notes in different folders. Default folders are "Inbox", "Personal", and "Work". New folders can be created by the user.

Key Features: - Note Management: Create, update, move and delete notes - Folder Management: Create, delete, and search folders (Default folders cannot be deleted) - Attachment Management: Handle note attachments (upload and download) - Search Functionality: Search notes across folders with text-based queries - State Management: Save and load application state

Key Components: - Folders: Each NotesFolder instance maintains its own collection of notes - View Limits: Configurable limit for note viewing and pagination - Event Registration: All operations are tracked through event registration

Notes: - Note IDs are automatically generated when creating new notes. - Attachments are handled using base64 encoding. - Search operations are case-insensitive. - All notes operations maintain folder integrity.

Source code in pare/apps/note/app.py
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
@dataclass
class StatefulNotesApp(StatefulApp):
    """A Notes application that manages user's notes and folder organization. This class provides comprehensive functionality for handling notes including creating, updating, deleting, and searching notes.

    This app maintains the notes in different folders. Default folders are "Inbox", "Personal", and "Work". New folders can be created by the user.

    Key Features:
    - Note Management: Create, update, move and delete notes
    - Folder Management: Create, delete, and search folders (Default folders cannot be deleted)
    - Attachment Management: Handle note attachments (upload and download)
    - Search Functionality: Search notes across folders with text-based queries
    - State Management: Save and load application state

    Key Components:
    - Folders: Each NotesFolder instance maintains its own collection of notes
    - View Limits: Configurable limit for note viewing and pagination
    - Event Registration: All operations are tracked through event registration

    Notes:
    - Note IDs are automatically generated when creating new notes.
    - Attachments are handled using base64 encoding.
    - Search operations are case-insensitive.
    - All notes operations maintain folder integrity.
    """

    name: str | None = None
    view_limit: int = 5
    folders: dict[str, NotesFolder] = field(default_factory=dict)
    internal_fs: SandboxLocalFileSystem | VirtualFileSystem | None = None

    def __post_init__(self) -> None:
        """Initialize app with default folders."""
        super().__init__(self.name or "note")

        # Initialize default folders
        self.default_folders = ["Inbox", "Personal", "Work"]
        for folder_name in self.default_folders:
            if folder_name not in self.folders:
                self.folders[folder_name] = NotesFolder(folder_name)

        self.load_root_state()

    def connect_to_protocols(self, protocols: dict[Protocol, Any]) -> None:
        """Connect to the given list of protocols.

        Args:
            protocols (dict[Protocol, Any]): Dictionary of protocols.
        """
        file_system = protocols.get(Protocol.FILE_SYSTEM)
        if isinstance(file_system, (SandboxLocalFileSystem, VirtualFileSystem)):
            self.internal_fs = file_system

    def create_root_state(self) -> NoteList:
        """Return the root navigation state.

        Returns:
            NoteList: Default folder view.
        """
        return NoteList("Inbox")

    def get_state(self) -> dict[str, Any]:
        """Serialize app state.

        Returns:
            dict[str, Any]: Complete app state.
        """
        return {
            "view_limit": self.view_limit,
            "folders": {k: v.get_state() for k, v in self.folders.items()},
        }

    def load_state(self, state_dict: dict[str, Any]) -> None:
        """Deserialize app state.

        Args:
            state_dict (dict[str, Any]): State to restore.
        """
        self.view_limit = state_dict["view_limit"]
        self.folders.clear()
        for folder_name, folder_state in state_dict.get("folders", {}).items():
            folder = NotesFolder(folder_name)
            folder.load_state(folder_state)
            self.folders[folder_name] = folder

    def reset(self) -> None:
        """Reset the app to empty state."""
        super().reset()
        for folder in self.folders:
            self.folders[folder].notes.clear()

    def _get_note_from_any_folder(self, note_id: str) -> tuple[str, Note] | None:
        """Find a note across all folders.

        Args:
            note_id (str): Note ID to find.

        Returns:
            tuple[str, Note] | None: Folder Name and Note object if found, None otherwise.
        """
        for name, folder in self.folders.items():
            note = folder.get_note_by_id(note_id)
            if note is not None:
                return (name, note)
        return None

    def open_folder(self, folder: str) -> list[Note]:
        """Open a folder and return the notes in the folder.

        Args:
            folder (str): Name of the folder to open.

        Returns:
            list[Note]: List of notes in the folder.

        Raises:
            KeyError: If folder does not exist.
            ValueError: If folder name is empty.
        """
        if folder not in self.folders:
            raise KeyError(f"Folder {folder} does not exist")
        if len(folder) == 0:
            raise ValueError("Folder name must be non-empty")
        return list(self.folders[folder].notes.values())

    @type_check
    @app_tool()
    @pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
    def new_folder(self, folder_name: str) -> str:
        """Create a new empty folder with the given name.

        Args:
            folder_name (str): Name of the new folder.

        Returns:
            str: Name of the newly created folder.

        Raises:
            KeyError: If folder already exists.
        """
        if folder_name in self.folders:
            raise KeyError(f"Folder {folder_name} already exists")
        self.folders[folder_name] = NotesFolder(folder_name)
        return folder_name

    @type_check
    @env_tool()
    @app_tool()
    @pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
    def delete_folder(self, folder_name: str) -> str:
        """Delete a folder and all it's notes. Default folders "Inbox", "Personal", and "Work" cannot be deleted.

        Args:
            folder_name (str): Name of the folder to delete.

        Returns:
            str: Name of the deleted folder if successful.

        Raises:
            KeyError: If folder does not exist, or if the folder to be deleted is one of the default folders.
        """
        if folder_name not in self.folders:
            raise KeyError(f"Folder {folder_name} does not exist")
        if folder_name in self.default_folders:
            raise KeyError(f"Cannot delete default folder {folder_name}")

        self.folders[folder_name].notes.clear()
        del self.folders[folder_name]
        logger.debug(f"Deleted folder {folder_name}")
        return folder_name

    @type_check
    @app_tool()
    @pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
    def rename_folder(self, folder: str, new_folder: str) -> str:
        """Rename an already existing folder. Default folders "Inbox", "Personal", and "Work" cannot be renamed.

        Args:
            folder (str): Name of the folder to rename.
            new_folder(str): New name for the folder.

        Returns:
            str: Name of the renamed folder.

        Raises:
            KeyError: If folder_name does not exist, a folder with the new name already exists or if the folder to be renamed is one of the default folders.
        """
        if folder not in self.folders:
            raise KeyError(f"Folder {folder} does not exist")
        if new_folder in self.folders:
            raise KeyError(f"Folder {new_folder} already exists")
        if folder in self.default_folders:
            raise KeyError(f"Cannot rename default folder {folder}")
        self.folders[new_folder] = deepcopy(self.folders[folder])
        self.folders[new_folder].folder_name = new_folder
        del self.folders[folder]
        logger.debug(f"Renamed folder {folder} to {new_folder}")
        return new_folder

    @data_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def create_note_with_time(
        self,
        folder: str = "Inbox",
        title: str = "",
        content: str = "",
        pinned: bool = False,
        created_at: str = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"),
        updated_at: str | None = None,
    ) -> str:
        """Create a new note with title and content at a specific time. If title string is empty, it will be set to the first 50 characters of the content. If specified folder is not found, a new folder will be created.

        Args:
            folder (str): Folder to create the note under.
            title (str): Title of the note.
            content (str): Content of the note.
            pinned (bool): Whether the note should be pinned.
            created_at (str): Time of the note creation. Defaults to the current time.
            updated_at (str): Time of the note update. Defaults to the creation time.

        Returns:
            str: ID of the newly created note.

        Raises:
            ValueError: If creation or update time is invalid, or if updated time is before creation time.
        """
        try:
            creation_time = datetime.strptime(created_at, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC).timestamp()
        except ValueError as e:
            raise ValueError("Invalid datetime format for the creation time. Please use YYYY-MM-DD HH:MM:SS") from e
        if updated_at is not None:
            try:
                update_time = datetime.strptime(updated_at, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC).timestamp()
            except ValueError as e:
                raise ValueError("Invalid datetime format for the update time. Please use YYYY-MM-DD HH:MM:SS") from e
        else:
            update_time = creation_time

        if folder not in self.folders:
            with disable_events():
                self.new_folder(folder)

        if update_time < creation_time:
            raise ValueError(
                "Updated time cannot be before creation time. Creation Time: {creation_time}, Updated Time: {update_time}"
            )
        note_id = uuid_hex(self.rng)
        note = Note(
            note_id=note_id,
            title=title,
            content=content,
            pinned=pinned,
            created_at=creation_time,
            updated_at=update_time,
        )
        self.folders[folder].add_note(note)
        return note.note_id

    @type_check
    @app_tool()
    @pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
    def create_note(self, folder: str = "Inbox", title: str = "", content: str = "", pinned: bool = False) -> str:
        """Create a new note with title and content. If title string is empty, it will be set to the first 50 characters of the content.

        Args:
            folder (str): Folder to create the note under.
            title (str): Title of the note.
            content (str): Content of the note.
            pinned (bool): Whether the note should be pinned.

        Returns:
            str: ID of the newly created note.

        Raises:
            KeyError: If specified folder is not found.
        """
        if folder not in self.folders:
            raise KeyError(f"Folder {folder} does not exist")
        if title is None or len(title.strip()) == 0:
            title = content[:50]
        note_id = uuid_hex(self.rng)
        note = Note(
            note_id=note_id,
            title=title,
            content=content,
            pinned=False,
            created_at=self.time_manager.time(),
            updated_at=self.time_manager.time(),
        )
        self.folders[folder].add_note(note)
        return note.note_id

    @type_check
    @data_tool()
    @app_tool()
    @pare_event_registered(operation_type=OperationType.READ, event_type=EventType.AGENT)
    def get_note_by_id(self, note_id: str) -> Note:
        """Retrieve a note by ID.

        Args:
            note_id (str): Target note ID.

        Returns:
            Note: The retrieved note object.

        Raises:
            KeyError: If note not found.
        """
        if not isinstance(note_id, str):
            raise TypeError(f"Note ID must be a string, got {type(note_id)}.")
        if len(note_id) == 0:
            raise ValueError("Note ID must be non-empty.")
        result = self._get_note_from_any_folder(note_id)
        if result is None:
            raise KeyError(f"Note {note_id} not found")
        return result[1]

    @type_check
    @app_tool()
    @pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
    def update_note(self, note_id: str, title: str | None = None, content: str | None = None) -> str:
        """Update the title or the content of the note. At least one of title or content must be provided.

        Notes:
        - If both title and content are provided, both will be updated.
        - If the note has no title and new title is provided, the title will be set to the new title.
        - If the note has no title and content is provided, the title will be set to the first 50 characters of the content.

        Args:
            note_id (str): Target note ID.
            title (str | None): New title for the note.
            content (str | None): New content for the note.

        Returns:
            str: Note ID of the updated note.

        Raises:
            KeyError: If note not found.
            ValueError: If both title and content are empty.
        """
        result = self._get_note_from_any_folder(note_id)
        if result is None:
            raise KeyError(f"Note {note_id} not found")

        folder, note = result

        if (title is None or len(title.strip()) == 0) and (content is None or len(content.strip()) == 0):
            raise ValueError(
                "Both title and content cannot be empty. At least one of title or content must be provided."
            )

        if title is not None and len(title.strip()) > 0:
            note.title = title

        # Title was not provided, content was provided
        if content is not None and len(content.strip()) > 0:
            if note.title is None or len(note.title.strip()) == 0:
                note.title = content[:50]
            note.content = content

        note.updated_at = self.time_manager.time()
        self.folders[folder].notes[note.note_id] = note

        return note_id

    @type_check
    @app_tool()
    @pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
    def delete_note(self, note_id: str) -> str:
        """Delete a note with the specified ID. Deleted Note ID is returned.

        Args:
            note_id (str): ID of note to delete.

        Returns:
            str: ID of the deleted note.

        Raises:
            TypeError: If note ID is not a string.
            ValueError: If note ID is empty.
            KeyError: If note not found.
        """
        if not isinstance(note_id, str):
            raise TypeError(f"Note ID must be a string, got {type(note_id)}.")
        if len(note_id) == 0:
            raise ValueError("Note ID must be non-empty.")
        result = self._get_note_from_any_folder(note_id)
        if result is None:
            raise KeyError(f"Note {note_id} not found")
        folder, _ = result
        self.folders[folder].remove_note(note_id)
        return note_id

    @type_check
    @app_tool()
    @pare_event_registered(operation_type=OperationType.READ, event_type=EventType.AGENT)
    def list_notes(self, folder: str, offset: int = 0, limit: int = 10) -> ReturnedNotes:
        """List notes in the specific folder with a specified offset.

        Args:
            folder (str): The folder to list notes from.
            offset (int): The offset of the first note to return.
            limit (int): The maximum number of notes to return.

        Returns:
            ReturnedNotes: Notes with additional metadata about the range of notes retrieved and total number of notes
        """
        if folder not in self.folders:
            raise ValueError(f"Folder {folder} not found")

        return self.folders[folder].get_notes(offset, limit)

    @type_check
    @app_tool()
    @pare_event_registered(operation_type=OperationType.READ, event_type=EventType.AGENT)
    def list_folders(self) -> list[str]:
        """List all folder names.

        Returns:
            list[str]: Folder list.
        """
        return list(self.folders.keys())

    @type_check
    @app_tool()
    @pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
    def move_note(self, note_id: str, source_folder_name: str = "Inbox", dest_folder_name: str = "Personal") -> str:
        """Move a note with the specified ID to the specified folder.

        Args:
            note_id (str): The ID of the note to move.
            source_folder_name (str): The folder to move the note from. Defaults to Inbox.
            dest_folder_name (str): The folder to move the note to. Defaults to Personal.

        Returns:
            str: The ID of the moved note

        Raises:
            KeyError: If source or destination folder not found or note not found in source folder.
        """
        if source_folder_name not in self.folders:
            raise KeyError(f"Folder {source_folder_name} not found.")
        if dest_folder_name not in self.folders:
            raise KeyError(f"Folder {dest_folder_name} not found.")
        note = self.folders[source_folder_name].get_note_by_id(note_id)
        if note is None:
            raise KeyError(f"Note {note_id} not found in folder {source_folder_name}.")
        self.folders[dest_folder_name].add_note(note)
        self.folders[source_folder_name].remove_note(note_id)
        return note_id

    @type_check
    @app_tool()
    @pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
    def duplicate_note(self, folder_name: str, note_id: str) -> str:
        """Create a duplicated copy of a note. The new note is added to the same folder as the original note and the title is "Copy of <original title>".

        Args:
            folder_name (str): The folder of the original note. Defaults to Inbox.
            note_id (str): The ID of the note to copy.

        Returns:
            str: The ID of the newly created duplicate.

        Raises:
            KeyError: If folder not found or note not found in folder.
        """
        if folder_name not in self.folders:
            raise KeyError(f"Folder {folder_name} not found.")
        current_note = self.folders[folder_name].get_note_by_id(note_id)
        if current_note is None:
            raise KeyError(f"Note {note_id} not found in folder {folder_name}.")

        new_note_id = uuid_hex(self.rng)
        new_note = Note(
            note_id=new_note_id,
            title=f"Copy of {current_note.title}",
            content=current_note.content,
            pinned=False,
            attachments=deepcopy(current_note.attachments),
        )
        self.folders[folder_name].add_note(new_note)

        return new_note_id

    @type_check
    @app_tool()
    @pare_event_registered(operation_type=OperationType.READ, event_type=EventType.AGENT)
    def search_notes(self, query: str) -> list[Note]:
        """Search for notes across all folders based on a query string. The search looks for partial matches in title, and content.

        Args:
            query (str): The search query string.

        Returns:
            list[Note]: A list of notes that match the query.
        """
        results = []
        for folder in self.folders:
            results.extend(self.folders[folder].search_notes(query))
        return results

    @type_check
    @app_tool()
    @pare_event_registered(operation_type=OperationType.READ, event_type=EventType.AGENT)
    def search_notes_in_folder(self, query: str, folder_name: str) -> list[Note]:
        """Search for notes in a specific folder based on a query string. The search looks for partial matches in title, and content.

        Args:
            query (str): The search query string.
            folder_name (str): The folder to search in. Defaults to Inbox.

        Returns:
            list[Note]: A list of notes that match the query.

        Raises:
            KeyError: If folder not found.
        """
        if folder_name not in self.folders:
            raise KeyError(f"Folder {folder_name} not found.")
        return self.folders[folder_name].search_notes(query)

    def add_attachment(self, note: Note, attachment_path: str) -> Note:
        """Add a file attachment to a note.

        Args:
            note (Note): The note to add the attachment to.
            attachment_path (str): The path to the attachment to add.

        Returns:
            Note: The updated note object.

        Raises:
            ValueError: If file does not exist.
        """
        if self.internal_fs is not None:
            if not self.internal_fs.exists(attachment_path):
                raise ValueError(f"File does not exist: {attachment_path}")
            with disable_events(), self.internal_fs.open(attachment_path, "rb") as f:
                file_content = base64.b64encode(f.read())
                file_name = Path(attachment_path).name
                if not note.attachments:
                    note.attachments = {}
                note.attachments[file_name] = file_content
        else:
            note.add_attachment(attachment_path)

        return note

    @type_check
    @app_tool()
    @pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
    def add_attachment_to_note(self, note_id: str, attachment_path: str) -> str:
        """Add a file attachment to a note.

        Args:
            note_id (str): The ID of the note to add the attachment to.
            attachment_path (str): The path to the attachment to add.

        Returns:
            str: The ID of the note that the attachment was added to.
        """
        result = self._get_note_from_any_folder(note_id)
        if result is None:
            raise KeyError(f"Note {note_id} not found in any folder.")
        folder_name, note = result
        note = self.add_attachment(note, attachment_path)
        note.updated_at = self.time_manager.time()
        self.folders[folder_name].notes[note.note_id] = note
        return note.note_id

    @type_check
    @app_tool()
    @pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
    def remove_attachment(self, note_id: str, attachment: str) -> str:
        """Remove an attachment from a note.

        Args:
            note_id (str): Target note ID.
            attachment (str): Attachment to remove.

        Returns:
            str: The ID of the note that the attachment was removed from.

        Raises:
            KeyError: If note not found in any folder or attachment not found in note.
        """
        result = self._get_note_from_any_folder(note_id)
        if result is None:
            raise KeyError(f"Note {note_id} not found in any folder.")
        folder_name, note = result
        # code path is not reachable
        if note.attachments is None:
            raise KeyError(f"Note {note_id} has no attachments.")

        if attachment not in note.attachments:
            raise KeyError(f"Attachment {attachment} not found in note {note_id}")

        del note.attachments[attachment]
        note.updated_at = self.time_manager.time()
        self.folders[folder_name].notes[note.note_id] = note

        return note.note_id

    @type_check
    @app_tool()
    @pare_event_registered(operation_type=OperationType.READ, event_type=EventType.AGENT)
    def list_attachments(self, note_id: str) -> list[str]:
        """List attachment identifiers for a note.

        Args:
            note_id (str): Target note ID.

        Returns:
            list[str]: Attachment list.

        Raises:
            KeyError: If note not found.
        """
        result = self._get_note_from_any_folder(note_id)
        if result is None:
            raise KeyError(f"Note {note_id} not found")
        _, note = result
        # code path is not reachable
        if note.attachments is None:
            return []

        return list(note.attachments.keys())

    def _resolve_note_id(self, args: dict[str, Any], metadata: object | None) -> str | None:
        """Extract note_id from args or metadata. Assumes that note_id is either in args or return value of the completed event.

        Args:
            args: Function arguments dictionary.
            metadata: Return value from the completed event.

        Returns:
            str | None: Extracted note ID or None.
        """
        note_id = args.get("note_id")
        if isinstance(note_id, str):
            return note_id
        if isinstance(metadata, str):
            return metadata
        return None

    def handle_state_transition(self, event: CompletedEvent) -> None:
        """Core navigation handler mapping backend operations to state transitions."""
        current_state = self.current_state
        fname = event.function_name()

        if current_state is None or fname is None:
            return

        action = event.action
        args = action.resolved_args or action.args

        metadata_value = event.metadata.return_value if event.metadata else None
        if isinstance(current_state, NoteList):
            self._handle_note_list_transition(fname, args, metadata_value)
        elif isinstance(current_state, NoteDetail):
            self._handle_note_detail_transition(fname, args, metadata_value)
        elif isinstance(current_state, EditNote):
            self._handle_edit_note_transition(fname, args, metadata_value)
        elif isinstance(current_state, FolderList):
            self._handle_folder_list_transition(fname, args, metadata_value)

    def _handle_note_list_transition(self, fname: str, args: dict[str, Any], metadata: object | None) -> None:
        """Process transitions from the note list view."""
        if fname == "new_note":
            note_id = self._resolve_note_id(args, metadata)
            if note_id:
                self.set_current_state(EditNote(note_id))
            return

        if fname == "open":
            note_id = self._resolve_note_id(args, metadata)
            if note_id:
                self.set_current_state(NoteDetail(note_id))
            return

        if fname == "list_folders":
            self.set_current_state(FolderList())

    def _handle_note_detail_transition(self, fname: str, args: dict[str, Any], metadata: object | None) -> None:
        """Process transitions from the note detail view."""
        if fname == "edit":
            note_id = self._resolve_note_id(args, metadata)
            if note_id:
                self.set_current_state(EditNote(note_id))
            return

        if fname == "delete" and self.navigation_stack:
            with disable_events():
                self.go_back()
            return

        if fname == "duplicate":
            note_id = self._resolve_note_id(args, metadata)
            if note_id:
                self.set_current_state(NoteDetail(note_id))
            return

        if fname == "move":
            dest = args.get("dest_folder_name")
            if isinstance(dest, str):
                self.set_current_state(NoteList(dest))

    def _handle_edit_note_transition(self, fname: str, args: dict[str, Any], metadata: object | None) -> None:
        """Process transitions from the edit note view."""
        if fname == "update":
            note_id = self._resolve_note_id(args, metadata)
            if note_id:
                self.set_current_state(NoteDetail(note_id))

    def _handle_folder_list_transition(self, fname: str, args: dict[str, Any], metadata: object | None) -> None:
        """Process transitions from the folder list view."""
        if fname == "open":
            folder = args.get("folder")
            if isinstance(folder, str):
                self.set_current_state(NoteList(folder))

__post_init__()

Initialize app with default folders.

Source code in pare/apps/note/app.py
183
184
185
186
187
188
189
190
191
192
193
def __post_init__(self) -> None:
    """Initialize app with default folders."""
    super().__init__(self.name or "note")

    # Initialize default folders
    self.default_folders = ["Inbox", "Personal", "Work"]
    for folder_name in self.default_folders:
        if folder_name not in self.folders:
            self.folders[folder_name] = NotesFolder(folder_name)

    self.load_root_state()

add_attachment(note, attachment_path)

Add a file attachment to a note.

Parameters:

Name Type Description Default
note Note

The note to add the attachment to.

required
attachment_path str

The path to the attachment to add.

required

Returns:

Name Type Description
Note Note

The updated note object.

Raises:

Type Description
ValueError

If file does not exist.

Source code in pare/apps/note/app.py
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
def add_attachment(self, note: Note, attachment_path: str) -> Note:
    """Add a file attachment to a note.

    Args:
        note (Note): The note to add the attachment to.
        attachment_path (str): The path to the attachment to add.

    Returns:
        Note: The updated note object.

    Raises:
        ValueError: If file does not exist.
    """
    if self.internal_fs is not None:
        if not self.internal_fs.exists(attachment_path):
            raise ValueError(f"File does not exist: {attachment_path}")
        with disable_events(), self.internal_fs.open(attachment_path, "rb") as f:
            file_content = base64.b64encode(f.read())
            file_name = Path(attachment_path).name
            if not note.attachments:
                note.attachments = {}
            note.attachments[file_name] = file_content
    else:
        note.add_attachment(attachment_path)

    return note

add_attachment_to_note(note_id, attachment_path)

Add a file attachment to a note.

Parameters:

Name Type Description Default
note_id str

The ID of the note to add the attachment to.

required
attachment_path str

The path to the attachment to add.

required

Returns:

Name Type Description
str str

The ID of the note that the attachment was added to.

Source code in pare/apps/note/app.py
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
@type_check
@app_tool()
@pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
def add_attachment_to_note(self, note_id: str, attachment_path: str) -> str:
    """Add a file attachment to a note.

    Args:
        note_id (str): The ID of the note to add the attachment to.
        attachment_path (str): The path to the attachment to add.

    Returns:
        str: The ID of the note that the attachment was added to.
    """
    result = self._get_note_from_any_folder(note_id)
    if result is None:
        raise KeyError(f"Note {note_id} not found in any folder.")
    folder_name, note = result
    note = self.add_attachment(note, attachment_path)
    note.updated_at = self.time_manager.time()
    self.folders[folder_name].notes[note.note_id] = note
    return note.note_id

connect_to_protocols(protocols)

Connect to the given list of protocols.

Parameters:

Name Type Description Default
protocols dict[Protocol, Any]

Dictionary of protocols.

required
Source code in pare/apps/note/app.py
195
196
197
198
199
200
201
202
203
def connect_to_protocols(self, protocols: dict[Protocol, Any]) -> None:
    """Connect to the given list of protocols.

    Args:
        protocols (dict[Protocol, Any]): Dictionary of protocols.
    """
    file_system = protocols.get(Protocol.FILE_SYSTEM)
    if isinstance(file_system, (SandboxLocalFileSystem, VirtualFileSystem)):
        self.internal_fs = file_system

create_note(folder='Inbox', title='', content='', pinned=False)

Create a new note with title and content. If title string is empty, it will be set to the first 50 characters of the content.

Parameters:

Name Type Description Default
folder str

Folder to create the note under.

'Inbox'
title str

Title of the note.

''
content str

Content of the note.

''
pinned bool

Whether the note should be pinned.

False

Returns:

Name Type Description
str str

ID of the newly created note.

Raises:

Type Description
KeyError

If specified folder is not found.

Source code in pare/apps/note/app.py
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
@type_check
@app_tool()
@pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
def create_note(self, folder: str = "Inbox", title: str = "", content: str = "", pinned: bool = False) -> str:
    """Create a new note with title and content. If title string is empty, it will be set to the first 50 characters of the content.

    Args:
        folder (str): Folder to create the note under.
        title (str): Title of the note.
        content (str): Content of the note.
        pinned (bool): Whether the note should be pinned.

    Returns:
        str: ID of the newly created note.

    Raises:
        KeyError: If specified folder is not found.
    """
    if folder not in self.folders:
        raise KeyError(f"Folder {folder} does not exist")
    if title is None or len(title.strip()) == 0:
        title = content[:50]
    note_id = uuid_hex(self.rng)
    note = Note(
        note_id=note_id,
        title=title,
        content=content,
        pinned=False,
        created_at=self.time_manager.time(),
        updated_at=self.time_manager.time(),
    )
    self.folders[folder].add_note(note)
    return note.note_id

create_note_with_time(folder='Inbox', title='', content='', pinned=False, created_at=datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S'), updated_at=None)

Create a new note with title and content at a specific time. If title string is empty, it will be set to the first 50 characters of the content. If specified folder is not found, a new folder will be created.

Parameters:

Name Type Description Default
folder str

Folder to create the note under.

'Inbox'
title str

Title of the note.

''
content str

Content of the note.

''
pinned bool

Whether the note should be pinned.

False
created_at str

Time of the note creation. Defaults to the current time.

strftime('%Y-%m-%d %H:%M:%S')
updated_at str

Time of the note update. Defaults to the creation time.

None

Returns:

Name Type Description
str str

ID of the newly created note.

Raises:

Type Description
ValueError

If creation or update time is invalid, or if updated time is before creation time.

Source code in pare/apps/note/app.py
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
@data_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def create_note_with_time(
    self,
    folder: str = "Inbox",
    title: str = "",
    content: str = "",
    pinned: bool = False,
    created_at: str = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"),
    updated_at: str | None = None,
) -> str:
    """Create a new note with title and content at a specific time. If title string is empty, it will be set to the first 50 characters of the content. If specified folder is not found, a new folder will be created.

    Args:
        folder (str): Folder to create the note under.
        title (str): Title of the note.
        content (str): Content of the note.
        pinned (bool): Whether the note should be pinned.
        created_at (str): Time of the note creation. Defaults to the current time.
        updated_at (str): Time of the note update. Defaults to the creation time.

    Returns:
        str: ID of the newly created note.

    Raises:
        ValueError: If creation or update time is invalid, or if updated time is before creation time.
    """
    try:
        creation_time = datetime.strptime(created_at, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC).timestamp()
    except ValueError as e:
        raise ValueError("Invalid datetime format for the creation time. Please use YYYY-MM-DD HH:MM:SS") from e
    if updated_at is not None:
        try:
            update_time = datetime.strptime(updated_at, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC).timestamp()
        except ValueError as e:
            raise ValueError("Invalid datetime format for the update time. Please use YYYY-MM-DD HH:MM:SS") from e
    else:
        update_time = creation_time

    if folder not in self.folders:
        with disable_events():
            self.new_folder(folder)

    if update_time < creation_time:
        raise ValueError(
            "Updated time cannot be before creation time. Creation Time: {creation_time}, Updated Time: {update_time}"
        )
    note_id = uuid_hex(self.rng)
    note = Note(
        note_id=note_id,
        title=title,
        content=content,
        pinned=pinned,
        created_at=creation_time,
        updated_at=update_time,
    )
    self.folders[folder].add_note(note)
    return note.note_id

create_root_state()

Return the root navigation state.

Returns:

Name Type Description
NoteList NoteList

Default folder view.

Source code in pare/apps/note/app.py
205
206
207
208
209
210
211
def create_root_state(self) -> NoteList:
    """Return the root navigation state.

    Returns:
        NoteList: Default folder view.
    """
    return NoteList("Inbox")

delete_folder(folder_name)

Delete a folder and all it's notes. Default folders "Inbox", "Personal", and "Work" cannot be deleted.

Parameters:

Name Type Description Default
folder_name str

Name of the folder to delete.

required

Returns:

Name Type Description
str str

Name of the deleted folder if successful.

Raises:

Type Description
KeyError

If folder does not exist, or if the folder to be deleted is one of the default folders.

Source code in pare/apps/note/app.py
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
@type_check
@env_tool()
@app_tool()
@pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
def delete_folder(self, folder_name: str) -> str:
    """Delete a folder and all it's notes. Default folders "Inbox", "Personal", and "Work" cannot be deleted.

    Args:
        folder_name (str): Name of the folder to delete.

    Returns:
        str: Name of the deleted folder if successful.

    Raises:
        KeyError: If folder does not exist, or if the folder to be deleted is one of the default folders.
    """
    if folder_name not in self.folders:
        raise KeyError(f"Folder {folder_name} does not exist")
    if folder_name in self.default_folders:
        raise KeyError(f"Cannot delete default folder {folder_name}")

    self.folders[folder_name].notes.clear()
    del self.folders[folder_name]
    logger.debug(f"Deleted folder {folder_name}")
    return folder_name

delete_note(note_id)

Delete a note with the specified ID. Deleted Note ID is returned.

Parameters:

Name Type Description Default
note_id str

ID of note to delete.

required

Returns:

Name Type Description
str str

ID of the deleted note.

Raises:

Type Description
TypeError

If note ID is not a string.

ValueError

If note ID is empty.

KeyError

If note not found.

Source code in pare/apps/note/app.py
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
@type_check
@app_tool()
@pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
def delete_note(self, note_id: str) -> str:
    """Delete a note with the specified ID. Deleted Note ID is returned.

    Args:
        note_id (str): ID of note to delete.

    Returns:
        str: ID of the deleted note.

    Raises:
        TypeError: If note ID is not a string.
        ValueError: If note ID is empty.
        KeyError: If note not found.
    """
    if not isinstance(note_id, str):
        raise TypeError(f"Note ID must be a string, got {type(note_id)}.")
    if len(note_id) == 0:
        raise ValueError("Note ID must be non-empty.")
    result = self._get_note_from_any_folder(note_id)
    if result is None:
        raise KeyError(f"Note {note_id} not found")
    folder, _ = result
    self.folders[folder].remove_note(note_id)
    return note_id

duplicate_note(folder_name, note_id)

Create a duplicated copy of a note. The new note is added to the same folder as the original note and the title is "Copy of ".

Parameters:

Name Type Description Default
folder_name str

The folder of the original note. Defaults to Inbox.

required
note_id str

The ID of the note to copy.

required

Returns:

Name Type Description
str str

The ID of the newly created duplicate.

Raises:

Type Description
KeyError

If folder not found or note not found in folder.

Source code in pare/apps/note/app.py
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
@type_check
@app_tool()
@pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
def duplicate_note(self, folder_name: str, note_id: str) -> str:
    """Create a duplicated copy of a note. The new note is added to the same folder as the original note and the title is "Copy of <original title>".

    Args:
        folder_name (str): The folder of the original note. Defaults to Inbox.
        note_id (str): The ID of the note to copy.

    Returns:
        str: The ID of the newly created duplicate.

    Raises:
        KeyError: If folder not found or note not found in folder.
    """
    if folder_name not in self.folders:
        raise KeyError(f"Folder {folder_name} not found.")
    current_note = self.folders[folder_name].get_note_by_id(note_id)
    if current_note is None:
        raise KeyError(f"Note {note_id} not found in folder {folder_name}.")

    new_note_id = uuid_hex(self.rng)
    new_note = Note(
        note_id=new_note_id,
        title=f"Copy of {current_note.title}",
        content=current_note.content,
        pinned=False,
        attachments=deepcopy(current_note.attachments),
    )
    self.folders[folder_name].add_note(new_note)

    return new_note_id

get_note_by_id(note_id)

Retrieve a note by ID.

Parameters:

Name Type Description Default
note_id str

Target note ID.

required

Returns:

Name Type Description
Note Note

The retrieved note object.

Raises:

Type Description
KeyError

If note not found.

Source code in pare/apps/note/app.py
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
@type_check
@data_tool()
@app_tool()
@pare_event_registered(operation_type=OperationType.READ, event_type=EventType.AGENT)
def get_note_by_id(self, note_id: str) -> Note:
    """Retrieve a note by ID.

    Args:
        note_id (str): Target note ID.

    Returns:
        Note: The retrieved note object.

    Raises:
        KeyError: If note not found.
    """
    if not isinstance(note_id, str):
        raise TypeError(f"Note ID must be a string, got {type(note_id)}.")
    if len(note_id) == 0:
        raise ValueError("Note ID must be non-empty.")
    result = self._get_note_from_any_folder(note_id)
    if result is None:
        raise KeyError(f"Note {note_id} not found")
    return result[1]

get_state()

Serialize app state.

Returns:

Type Description
dict[str, Any]

dict[str, Any]: Complete app state.

Source code in pare/apps/note/app.py
213
214
215
216
217
218
219
220
221
222
def get_state(self) -> dict[str, Any]:
    """Serialize app state.

    Returns:
        dict[str, Any]: Complete app state.
    """
    return {
        "view_limit": self.view_limit,
        "folders": {k: v.get_state() for k, v in self.folders.items()},
    }

handle_state_transition(event)

Core navigation handler mapping backend operations to state transitions.

Source code in pare/apps/note/app.py
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
def handle_state_transition(self, event: CompletedEvent) -> None:
    """Core navigation handler mapping backend operations to state transitions."""
    current_state = self.current_state
    fname = event.function_name()

    if current_state is None or fname is None:
        return

    action = event.action
    args = action.resolved_args or action.args

    metadata_value = event.metadata.return_value if event.metadata else None
    if isinstance(current_state, NoteList):
        self._handle_note_list_transition(fname, args, metadata_value)
    elif isinstance(current_state, NoteDetail):
        self._handle_note_detail_transition(fname, args, metadata_value)
    elif isinstance(current_state, EditNote):
        self._handle_edit_note_transition(fname, args, metadata_value)
    elif isinstance(current_state, FolderList):
        self._handle_folder_list_transition(fname, args, metadata_value)

list_attachments(note_id)

List attachment identifiers for a note.

Parameters:

Name Type Description Default
note_id str

Target note ID.

required

Returns:

Type Description
list[str]

list[str]: Attachment list.

Raises:

Type Description
KeyError

If note not found.

Source code in pare/apps/note/app.py
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
@type_check
@app_tool()
@pare_event_registered(operation_type=OperationType.READ, event_type=EventType.AGENT)
def list_attachments(self, note_id: str) -> list[str]:
    """List attachment identifiers for a note.

    Args:
        note_id (str): Target note ID.

    Returns:
        list[str]: Attachment list.

    Raises:
        KeyError: If note not found.
    """
    result = self._get_note_from_any_folder(note_id)
    if result is None:
        raise KeyError(f"Note {note_id} not found")
    _, note = result
    # code path is not reachable
    if note.attachments is None:
        return []

    return list(note.attachments.keys())

list_folders()

List all folder names.

Returns:

Type Description
list[str]

list[str]: Folder list.

Source code in pare/apps/note/app.py
564
565
566
567
568
569
570
571
572
573
@type_check
@app_tool()
@pare_event_registered(operation_type=OperationType.READ, event_type=EventType.AGENT)
def list_folders(self) -> list[str]:
    """List all folder names.

    Returns:
        list[str]: Folder list.
    """
    return list(self.folders.keys())

list_notes(folder, offset=0, limit=10)

List notes in the specific folder with a specified offset.

Parameters:

Name Type Description Default
folder str

The folder to list notes from.

required
offset int

The offset of the first note to return.

0
limit int

The maximum number of notes to return.

10

Returns:

Name Type Description
ReturnedNotes ReturnedNotes

Notes with additional metadata about the range of notes retrieved and total number of notes

Source code in pare/apps/note/app.py
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
@type_check
@app_tool()
@pare_event_registered(operation_type=OperationType.READ, event_type=EventType.AGENT)
def list_notes(self, folder: str, offset: int = 0, limit: int = 10) -> ReturnedNotes:
    """List notes in the specific folder with a specified offset.

    Args:
        folder (str): The folder to list notes from.
        offset (int): The offset of the first note to return.
        limit (int): The maximum number of notes to return.

    Returns:
        ReturnedNotes: Notes with additional metadata about the range of notes retrieved and total number of notes
    """
    if folder not in self.folders:
        raise ValueError(f"Folder {folder} not found")

    return self.folders[folder].get_notes(offset, limit)

load_state(state_dict)

Deserialize app state.

Parameters:

Name Type Description Default
state_dict dict[str, Any]

State to restore.

required
Source code in pare/apps/note/app.py
224
225
226
227
228
229
230
231
232
233
234
235
def load_state(self, state_dict: dict[str, Any]) -> None:
    """Deserialize app state.

    Args:
        state_dict (dict[str, Any]): State to restore.
    """
    self.view_limit = state_dict["view_limit"]
    self.folders.clear()
    for folder_name, folder_state in state_dict.get("folders", {}).items():
        folder = NotesFolder(folder_name)
        folder.load_state(folder_state)
        self.folders[folder_name] = folder

move_note(note_id, source_folder_name='Inbox', dest_folder_name='Personal')

Move a note with the specified ID to the specified folder.

Parameters:

Name Type Description Default
note_id str

The ID of the note to move.

required
source_folder_name str

The folder to move the note from. Defaults to Inbox.

'Inbox'
dest_folder_name str

The folder to move the note to. Defaults to Personal.

'Personal'

Returns:

Name Type Description
str str

The ID of the moved note

Raises:

Type Description
KeyError

If source or destination folder not found or note not found in source folder.

Source code in pare/apps/note/app.py
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
@type_check
@app_tool()
@pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
def move_note(self, note_id: str, source_folder_name: str = "Inbox", dest_folder_name: str = "Personal") -> str:
    """Move a note with the specified ID to the specified folder.

    Args:
        note_id (str): The ID of the note to move.
        source_folder_name (str): The folder to move the note from. Defaults to Inbox.
        dest_folder_name (str): The folder to move the note to. Defaults to Personal.

    Returns:
        str: The ID of the moved note

    Raises:
        KeyError: If source or destination folder not found or note not found in source folder.
    """
    if source_folder_name not in self.folders:
        raise KeyError(f"Folder {source_folder_name} not found.")
    if dest_folder_name not in self.folders:
        raise KeyError(f"Folder {dest_folder_name} not found.")
    note = self.folders[source_folder_name].get_note_by_id(note_id)
    if note is None:
        raise KeyError(f"Note {note_id} not found in folder {source_folder_name}.")
    self.folders[dest_folder_name].add_note(note)
    self.folders[source_folder_name].remove_note(note_id)
    return note_id

new_folder(folder_name)

Create a new empty folder with the given name.

Parameters:

Name Type Description Default
folder_name str

Name of the new folder.

required

Returns:

Name Type Description
str str

Name of the newly created folder.

Raises:

Type Description
KeyError

If folder already exists.

Source code in pare/apps/note/app.py
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
@type_check
@app_tool()
@pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
def new_folder(self, folder_name: str) -> str:
    """Create a new empty folder with the given name.

    Args:
        folder_name (str): Name of the new folder.

    Returns:
        str: Name of the newly created folder.

    Raises:
        KeyError: If folder already exists.
    """
    if folder_name in self.folders:
        raise KeyError(f"Folder {folder_name} already exists")
    self.folders[folder_name] = NotesFolder(folder_name)
    return folder_name

open_folder(folder)

Open a folder and return the notes in the folder.

Parameters:

Name Type Description Default
folder str

Name of the folder to open.

required

Returns:

Type Description
list[Note]

list[Note]: List of notes in the folder.

Raises:

Type Description
KeyError

If folder does not exist.

ValueError

If folder name is empty.

Source code in pare/apps/note/app.py
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
def open_folder(self, folder: str) -> list[Note]:
    """Open a folder and return the notes in the folder.

    Args:
        folder (str): Name of the folder to open.

    Returns:
        list[Note]: List of notes in the folder.

    Raises:
        KeyError: If folder does not exist.
        ValueError: If folder name is empty.
    """
    if folder not in self.folders:
        raise KeyError(f"Folder {folder} does not exist")
    if len(folder) == 0:
        raise ValueError("Folder name must be non-empty")
    return list(self.folders[folder].notes.values())

remove_attachment(note_id, attachment)

Remove an attachment from a note.

Parameters:

Name Type Description Default
note_id str

Target note ID.

required
attachment str

Attachment to remove.

required

Returns:

Name Type Description
str str

The ID of the note that the attachment was removed from.

Raises:

Type Description
KeyError

If note not found in any folder or attachment not found in note.

Source code in pare/apps/note/app.py
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
@type_check
@app_tool()
@pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
def remove_attachment(self, note_id: str, attachment: str) -> str:
    """Remove an attachment from a note.

    Args:
        note_id (str): Target note ID.
        attachment (str): Attachment to remove.

    Returns:
        str: The ID of the note that the attachment was removed from.

    Raises:
        KeyError: If note not found in any folder or attachment not found in note.
    """
    result = self._get_note_from_any_folder(note_id)
    if result is None:
        raise KeyError(f"Note {note_id} not found in any folder.")
    folder_name, note = result
    # code path is not reachable
    if note.attachments is None:
        raise KeyError(f"Note {note_id} has no attachments.")

    if attachment not in note.attachments:
        raise KeyError(f"Attachment {attachment} not found in note {note_id}")

    del note.attachments[attachment]
    note.updated_at = self.time_manager.time()
    self.folders[folder_name].notes[note.note_id] = note

    return note.note_id

rename_folder(folder, new_folder)

Rename an already existing folder. Default folders "Inbox", "Personal", and "Work" cannot be renamed.

Parameters:

Name Type Description Default
folder str

Name of the folder to rename.

required
new_folder str

New name for the folder.

required

Returns:

Name Type Description
str str

Name of the renamed folder.

Raises:

Type Description
KeyError

If folder_name does not exist, a folder with the new name already exists or if the folder to be renamed is one of the default folders.

Source code in pare/apps/note/app.py
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
@type_check
@app_tool()
@pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
def rename_folder(self, folder: str, new_folder: str) -> str:
    """Rename an already existing folder. Default folders "Inbox", "Personal", and "Work" cannot be renamed.

    Args:
        folder (str): Name of the folder to rename.
        new_folder(str): New name for the folder.

    Returns:
        str: Name of the renamed folder.

    Raises:
        KeyError: If folder_name does not exist, a folder with the new name already exists or if the folder to be renamed is one of the default folders.
    """
    if folder not in self.folders:
        raise KeyError(f"Folder {folder} does not exist")
    if new_folder in self.folders:
        raise KeyError(f"Folder {new_folder} already exists")
    if folder in self.default_folders:
        raise KeyError(f"Cannot rename default folder {folder}")
    self.folders[new_folder] = deepcopy(self.folders[folder])
    self.folders[new_folder].folder_name = new_folder
    del self.folders[folder]
    logger.debug(f"Renamed folder {folder} to {new_folder}")
    return new_folder

reset()

Reset the app to empty state.

Source code in pare/apps/note/app.py
237
238
239
240
241
def reset(self) -> None:
    """Reset the app to empty state."""
    super().reset()
    for folder in self.folders:
        self.folders[folder].notes.clear()

search_notes(query)

Search for notes across all folders based on a query string. The search looks for partial matches in title, and content.

Parameters:

Name Type Description Default
query str

The search query string.

required

Returns:

Type Description
list[Note]

list[Note]: A list of notes that match the query.

Source code in pare/apps/note/app.py
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
@type_check
@app_tool()
@pare_event_registered(operation_type=OperationType.READ, event_type=EventType.AGENT)
def search_notes(self, query: str) -> list[Note]:
    """Search for notes across all folders based on a query string. The search looks for partial matches in title, and content.

    Args:
        query (str): The search query string.

    Returns:
        list[Note]: A list of notes that match the query.
    """
    results = []
    for folder in self.folders:
        results.extend(self.folders[folder].search_notes(query))
    return results

search_notes_in_folder(query, folder_name)

Search for notes in a specific folder based on a query string. The search looks for partial matches in title, and content.

Parameters:

Name Type Description Default
query str

The search query string.

required
folder_name str

The folder to search in. Defaults to Inbox.

required

Returns:

Type Description
list[Note]

list[Note]: A list of notes that match the query.

Raises:

Type Description
KeyError

If folder not found.

Source code in pare/apps/note/app.py
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
@type_check
@app_tool()
@pare_event_registered(operation_type=OperationType.READ, event_type=EventType.AGENT)
def search_notes_in_folder(self, query: str, folder_name: str) -> list[Note]:
    """Search for notes in a specific folder based on a query string. The search looks for partial matches in title, and content.

    Args:
        query (str): The search query string.
        folder_name (str): The folder to search in. Defaults to Inbox.

    Returns:
        list[Note]: A list of notes that match the query.

    Raises:
        KeyError: If folder not found.
    """
    if folder_name not in self.folders:
        raise KeyError(f"Folder {folder_name} not found.")
    return self.folders[folder_name].search_notes(query)

update_note(note_id, title=None, content=None)

Update the title or the content of the note. At least one of title or content must be provided.

Notes: - If both title and content are provided, both will be updated. - If the note has no title and new title is provided, the title will be set to the new title. - If the note has no title and content is provided, the title will be set to the first 50 characters of the content.

Parameters:

Name Type Description Default
note_id str

Target note ID.

required
title str | None

New title for the note.

None
content str | None

New content for the note.

None

Returns:

Name Type Description
str str

Note ID of the updated note.

Raises:

Type Description
KeyError

If note not found.

ValueError

If both title and content are empty.

Source code in pare/apps/note/app.py
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
@type_check
@app_tool()
@pare_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT)
def update_note(self, note_id: str, title: str | None = None, content: str | None = None) -> str:
    """Update the title or the content of the note. At least one of title or content must be provided.

    Notes:
    - If both title and content are provided, both will be updated.
    - If the note has no title and new title is provided, the title will be set to the new title.
    - If the note has no title and content is provided, the title will be set to the first 50 characters of the content.

    Args:
        note_id (str): Target note ID.
        title (str | None): New title for the note.
        content (str | None): New content for the note.

    Returns:
        str: Note ID of the updated note.

    Raises:
        KeyError: If note not found.
        ValueError: If both title and content are empty.
    """
    result = self._get_note_from_any_folder(note_id)
    if result is None:
        raise KeyError(f"Note {note_id} not found")

    folder, note = result

    if (title is None or len(title.strip()) == 0) and (content is None or len(content.strip()) == 0):
        raise ValueError(
            "Both title and content cannot be empty. At least one of title or content must be provided."
        )

    if title is not None and len(title.strip()) > 0:
        note.title = title

    # Title was not provided, content was provided
    if content is not None and len(content.strip()) > 0:
        if note.title is None or len(note.title.strip()) == 0:
            note.title = content[:50]
        note.content = content

    note.updated_at = self.time_manager.time()
    self.folders[folder].notes[note.note_id] = note

    return note_id

EditNote

Bases: AppState

State enabling editing capabilities for an existing note.

Source code in pare/apps/note/states.py
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
class EditNote(AppState):
    """State enabling editing capabilities for an existing note."""

    def __init__(self, note_id: str) -> None:
        """Initialize editing.

        Args:
            note_id (str): The note to modify.
        """
        super().__init__()
        self.note_id = note_id
        self._note: Note | None = None

    def on_enter(self) -> None:
        """Lifecycle hook when entering EditNote."""
        with disable_events():
            self._note = self.app.get_note_by_id(self.note_id)

    def on_exit(self) -> None:
        """Lifecycle hook when leaving EditNote."""
        pass

    @user_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def update(self, title: str, content: str) -> str:
        """Update note content and title.

        Args:
            title (str): Updated title.
            content (str): Updated content.

        Returns:
            str: Note ID of the updated note.
        """
        with disable_events():
            return cast("StatefulNotesApp", self.app).update_note(self.note_id, title, content)

__init__(note_id)

Initialize editing.

Parameters:

Name Type Description Default
note_id str

The note to modify.

required
Source code in pare/apps/note/states.py
247
248
249
250
251
252
253
254
255
def __init__(self, note_id: str) -> None:
    """Initialize editing.

    Args:
        note_id (str): The note to modify.
    """
    super().__init__()
    self.note_id = note_id
    self._note: Note | None = None

on_enter()

Lifecycle hook when entering EditNote.

Source code in pare/apps/note/states.py
257
258
259
260
def on_enter(self) -> None:
    """Lifecycle hook when entering EditNote."""
    with disable_events():
        self._note = self.app.get_note_by_id(self.note_id)

on_exit()

Lifecycle hook when leaving EditNote.

Source code in pare/apps/note/states.py
262
263
264
def on_exit(self) -> None:
    """Lifecycle hook when leaving EditNote."""
    pass

update(title, content)

Update note content and title.

Parameters:

Name Type Description Default
title str

Updated title.

required
content str

Updated content.

required

Returns:

Name Type Description
str str

Note ID of the updated note.

Source code in pare/apps/note/states.py
266
267
268
269
270
271
272
273
274
275
276
277
278
279
@user_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def update(self, title: str, content: str) -> str:
    """Update note content and title.

    Args:
        title (str): Updated title.
        content (str): Updated content.

    Returns:
        str: Note ID of the updated note.
    """
    with disable_events():
        return cast("StatefulNotesApp", self.app).update_note(self.note_id, title, content)

FolderList

Bases: AppState

State displaying the list of folders.

Source code in pare/apps/note/states.py
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
class FolderList(AppState):
    """State displaying the list of folders."""

    def on_enter(self) -> None:
        """Lifecycle hook when entering FolderList."""
        pass

    def on_exit(self) -> None:
        """Lifecycle hook when leaving FolderList."""
        pass

    @user_tool()
    @pare_event_registered(operation_type=OperationType.READ)
    def list_folders(self) -> list[str]:
        """Return all folders.

        Returns:
            list[str]: Folder names.
        """
        with disable_events():
            return cast("StatefulNotesApp", self.app).list_folders()

    @user_tool()
    @pare_event_registered(operation_type=OperationType.READ)
    def open(self, folder: str) -> list[Note]:
        """Open the selected folder.

        Args:
            folder (str): Folder name.

        Returns:
            list[Note]: Notes in the opened folder.
        """
        return cast("StatefulNotesApp", self.app).open_folder(folder)

list_folders()

Return all folders.

Returns:

Type Description
list[str]

list[str]: Folder names.

Source code in pare/apps/note/states.py
293
294
295
296
297
298
299
300
301
302
@user_tool()
@pare_event_registered(operation_type=OperationType.READ)
def list_folders(self) -> list[str]:
    """Return all folders.

    Returns:
        list[str]: Folder names.
    """
    with disable_events():
        return cast("StatefulNotesApp", self.app).list_folders()

on_enter()

Lifecycle hook when entering FolderList.

Source code in pare/apps/note/states.py
285
286
287
def on_enter(self) -> None:
    """Lifecycle hook when entering FolderList."""
    pass

on_exit()

Lifecycle hook when leaving FolderList.

Source code in pare/apps/note/states.py
289
290
291
def on_exit(self) -> None:
    """Lifecycle hook when leaving FolderList."""
    pass

open(folder)

Open the selected folder.

Parameters:

Name Type Description Default
folder str

Folder name.

required

Returns:

Type Description
list[Note]

list[Note]: Notes in the opened folder.

Source code in pare/apps/note/states.py
304
305
306
307
308
309
310
311
312
313
314
315
@user_tool()
@pare_event_registered(operation_type=OperationType.READ)
def open(self, folder: str) -> list[Note]:
    """Open the selected folder.

    Args:
        folder (str): Folder name.

    Returns:
        list[Note]: Notes in the opened folder.
    """
    return cast("StatefulNotesApp", self.app).open_folder(folder)

NoteDetail

Bases: AppState

State showing detailed view of a single note.

Source code in pare/apps/note/states.py
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
class NoteDetail(AppState):
    """State showing detailed view of a single note."""

    def __init__(self, note_id: str) -> None:
        """Initialize NoteDetail.

        Args:
            note_id (str): ID of the note being viewed.
        """
        super().__init__()
        self.note_id = note_id
        self._note: Note | None = None

    def on_enter(self) -> None:
        """Lifecycle hook when entering NoteDetail."""
        with disable_events():
            self._note = self.app.get_note_by_id(self.note_id)

    def on_exit(self) -> None:
        """Lifecycle hook when leaving NoteDetail."""
        pass

    @user_tool()
    @pare_event_registered(operation_type=OperationType.READ)
    def go_back(self) -> None:
        """Navigate back to the previous state."""
        return None

    @user_tool()
    @pare_event_registered(operation_type=OperationType.READ)
    def refresh(self) -> Note:
        """Reload the note content.

        Returns:
            Note: Updated note object.
        """
        with disable_events():
            _refreshed_note = cast("StatefulNotesApp", self.app).get_note_by_id(self.note_id)
        self._note = _refreshed_note
        return self._note

    @user_tool()
    @pare_event_registered(operation_type=OperationType.READ)
    def list_attachments(self) -> list[str]:
        """List attachments associated with the note.

        Returns:
            list[str]: Attachment names.
        """
        with disable_events():
            return cast("StatefulNotesApp", self.app).list_attachments(self.note_id)

    @user_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def add_attachment(self, attachment_path: str) -> str:
        """Add a file attachment to the note.

        Args:
            attachment_path (str): Path to the attachment to add.

        Returns:
            str: Returns note ID to which the attachment was added.
        """
        with disable_events():
            return cast("StatefulNotesApp", self.app).add_attachment_to_note(self.note_id, attachment_path)

    @user_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def remove_attachment(self, attachment: str) -> str:
        """Remove an attachment from the note.

        Args:
            attachment (str): Attachment identifier.

        Returns:
            str: Returns note ID from which the attachment was removed.
        """
        with disable_events():
            return cast("StatefulNotesApp", self.app).remove_attachment(self.note_id, attachment)

    @user_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def delete(self) -> str:
        """Delete the note.

        Returns:
            str: Returns note ID of the deleted note.
        """
        with disable_events():
            return cast("StatefulNotesApp", self.app).delete_note(self.note_id)

    @user_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def edit(self) -> str:
        """Open edit mode for this note.

        Returns:
            str: The note_id being edited.
        """
        return self.note_id

    @user_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def duplicate(self) -> str:
        """Create a duplicate copy of this note in the same folder.

        Returns:
            str: Note ID of the newly created duplicate.
        """
        with disable_events():
            # Get the current note's folder
            result = cast("StatefulNotesApp", self.app)._get_note_from_any_folder(self.note_id)
            if result is None:
                raise KeyError(f"Note {self.note_id} not found")
            folder_name, _ = result
            return cast("StatefulNotesApp", self.app).duplicate_note(folder_name, self.note_id)

    @user_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def move(self, dest_folder_name: str) -> str:
        """Move this note to another folder.

        Args:
            dest_folder_name (str): The destination folder name.

        Returns:
            str: Note ID of the moved note.
        """
        with disable_events():
            # Get the current note's folder
            result = cast("StatefulNotesApp", self.app)._get_note_from_any_folder(self.note_id)
            if result is None:
                raise KeyError(f"Note {self.note_id} not found")
            source_folder_name, _ = result
            return cast("StatefulNotesApp", self.app).move_note(self.note_id, source_folder_name, dest_folder_name)

__init__(note_id)

Initialize NoteDetail.

Parameters:

Name Type Description Default
note_id str

ID of the note being viewed.

required
Source code in pare/apps/note/states.py
110
111
112
113
114
115
116
117
118
def __init__(self, note_id: str) -> None:
    """Initialize NoteDetail.

    Args:
        note_id (str): ID of the note being viewed.
    """
    super().__init__()
    self.note_id = note_id
    self._note: Note | None = None

add_attachment(attachment_path)

Add a file attachment to the note.

Parameters:

Name Type Description Default
attachment_path str

Path to the attachment to add.

required

Returns:

Name Type Description
str str

Returns note ID to which the attachment was added.

Source code in pare/apps/note/states.py
159
160
161
162
163
164
165
166
167
168
169
170
171
@user_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def add_attachment(self, attachment_path: str) -> str:
    """Add a file attachment to the note.

    Args:
        attachment_path (str): Path to the attachment to add.

    Returns:
        str: Returns note ID to which the attachment was added.
    """
    with disable_events():
        return cast("StatefulNotesApp", self.app).add_attachment_to_note(self.note_id, attachment_path)

delete()

Delete the note.

Returns:

Name Type Description
str str

Returns note ID of the deleted note.

Source code in pare/apps/note/states.py
187
188
189
190
191
192
193
194
195
196
@user_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def delete(self) -> str:
    """Delete the note.

    Returns:
        str: Returns note ID of the deleted note.
    """
    with disable_events():
        return cast("StatefulNotesApp", self.app).delete_note(self.note_id)

duplicate()

Create a duplicate copy of this note in the same folder.

Returns:

Name Type Description
str str

Note ID of the newly created duplicate.

Source code in pare/apps/note/states.py
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
@user_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def duplicate(self) -> str:
    """Create a duplicate copy of this note in the same folder.

    Returns:
        str: Note ID of the newly created duplicate.
    """
    with disable_events():
        # Get the current note's folder
        result = cast("StatefulNotesApp", self.app)._get_note_from_any_folder(self.note_id)
        if result is None:
            raise KeyError(f"Note {self.note_id} not found")
        folder_name, _ = result
        return cast("StatefulNotesApp", self.app).duplicate_note(folder_name, self.note_id)

edit()

Open edit mode for this note.

Returns:

Name Type Description
str str

The note_id being edited.

Source code in pare/apps/note/states.py
198
199
200
201
202
203
204
205
206
@user_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def edit(self) -> str:
    """Open edit mode for this note.

    Returns:
        str: The note_id being edited.
    """
    return self.note_id

go_back()

Navigate back to the previous state.

Source code in pare/apps/note/states.py
129
130
131
132
133
@user_tool()
@pare_event_registered(operation_type=OperationType.READ)
def go_back(self) -> None:
    """Navigate back to the previous state."""
    return None

list_attachments()

List attachments associated with the note.

Returns:

Type Description
list[str]

list[str]: Attachment names.

Source code in pare/apps/note/states.py
148
149
150
151
152
153
154
155
156
157
@user_tool()
@pare_event_registered(operation_type=OperationType.READ)
def list_attachments(self) -> list[str]:
    """List attachments associated with the note.

    Returns:
        list[str]: Attachment names.
    """
    with disable_events():
        return cast("StatefulNotesApp", self.app).list_attachments(self.note_id)

move(dest_folder_name)

Move this note to another folder.

Parameters:

Name Type Description Default
dest_folder_name str

The destination folder name.

required

Returns:

Name Type Description
str str

Note ID of the moved note.

Source code in pare/apps/note/states.py
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
@user_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def move(self, dest_folder_name: str) -> str:
    """Move this note to another folder.

    Args:
        dest_folder_name (str): The destination folder name.

    Returns:
        str: Note ID of the moved note.
    """
    with disable_events():
        # Get the current note's folder
        result = cast("StatefulNotesApp", self.app)._get_note_from_any_folder(self.note_id)
        if result is None:
            raise KeyError(f"Note {self.note_id} not found")
        source_folder_name, _ = result
        return cast("StatefulNotesApp", self.app).move_note(self.note_id, source_folder_name, dest_folder_name)

on_enter()

Lifecycle hook when entering NoteDetail.

Source code in pare/apps/note/states.py
120
121
122
123
def on_enter(self) -> None:
    """Lifecycle hook when entering NoteDetail."""
    with disable_events():
        self._note = self.app.get_note_by_id(self.note_id)

on_exit()

Lifecycle hook when leaving NoteDetail.

Source code in pare/apps/note/states.py
125
126
127
def on_exit(self) -> None:
    """Lifecycle hook when leaving NoteDetail."""
    pass

refresh()

Reload the note content.

Returns:

Name Type Description
Note Note

Updated note object.

Source code in pare/apps/note/states.py
135
136
137
138
139
140
141
142
143
144
145
146
@user_tool()
@pare_event_registered(operation_type=OperationType.READ)
def refresh(self) -> Note:
    """Reload the note content.

    Returns:
        Note: Updated note object.
    """
    with disable_events():
        _refreshed_note = cast("StatefulNotesApp", self.app).get_note_by_id(self.note_id)
    self._note = _refreshed_note
    return self._note

remove_attachment(attachment)

Remove an attachment from the note.

Parameters:

Name Type Description Default
attachment str

Attachment identifier.

required

Returns:

Name Type Description
str str

Returns note ID from which the attachment was removed.

Source code in pare/apps/note/states.py
173
174
175
176
177
178
179
180
181
182
183
184
185
@user_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def remove_attachment(self, attachment: str) -> str:
    """Remove an attachment from the note.

    Args:
        attachment (str): Attachment identifier.

    Returns:
        str: Returns note ID from which the attachment was removed.
    """
    with disable_events():
        return cast("StatefulNotesApp", self.app).remove_attachment(self.note_id, attachment)

NoteList

Bases: AppState

State representing a list of notes within a folder or search mode.

Source code in pare/apps/note/states.py
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
class NoteList(AppState):
    """State representing a list of notes within a folder or search mode."""

    def __init__(self, folder: str = "Inbox") -> None:
        """Initialize the list view.

        Args:
            folder (str): Folder name to filter notes.
        """
        super().__init__()
        self.folder = folder

    def on_enter(self) -> None:
        """Lifecycle hook when entering NoteList."""
        pass

    def on_exit(self) -> None:
        """Lifecycle hook when leaving NoteList."""
        pass

    @user_tool()
    @pare_event_registered(operation_type=OperationType.READ)
    def go_back(self) -> None:
        """Navigate back to the previous state."""
        return None

    @user_tool()
    @pare_event_registered(operation_type=OperationType.READ)
    def list_notes(self, offset: int = 0, limit: int = 10) -> ReturnedNotes:
        """Return paginated notes under the current folder.

        Args:
            offset (int): Starting index for pagination.
            limit (int): Maximum number of notes to return.

        Returns:
            ReturnedNotes: Paginated notes container.
        """
        with disable_events():
            return cast("StatefulNotesApp", self.app).list_notes(self.folder, offset, limit)

    @user_tool()
    @pare_event_registered(operation_type=OperationType.READ)
    def open(self, note_id: str) -> Note:
        """Open a note by ID.

        Args:
            note_id (str): ID of the note to open.

        Returns:
            Note: Note object from backend.
        """
        with disable_events():
            return cast("StatefulNotesApp", self.app).get_note_by_id(note_id)

    @user_tool()
    @pare_event_registered(operation_type=OperationType.WRITE)
    def new_note(self) -> str:
        """Create a new note in the current folder.

        Returns:
            str: ID of newly created note.
        """
        with disable_events():
            return cast("StatefulNotesApp", self.app).create_note(folder=self.folder)

    @user_tool()
    @pare_event_registered(operation_type=OperationType.READ)
    def search(self, keyword: str) -> list[Note]:
        """Search notes by keyword.

        Args:
            keyword (str): Search keyword.

        Returns:
            list[Note]: List of matched notes.
        """
        with disable_events():
            return cast("StatefulNotesApp", self.app).search_notes_in_folder(keyword, self.folder)

    @user_tool()
    @pare_event_registered(operation_type=OperationType.READ)
    def list_folders(self) -> list[str]:
        """List all folders.

        Returns:
            list[str]: Folder names.
        """
        with disable_events():
            return cast("StatefulNotesApp", self.app).list_folders()

__init__(folder='Inbox')

Initialize the list view.

Parameters:

Name Type Description Default
folder str

Folder name to filter notes.

'Inbox'
Source code in pare/apps/note/states.py
18
19
20
21
22
23
24
25
def __init__(self, folder: str = "Inbox") -> None:
    """Initialize the list view.

    Args:
        folder (str): Folder name to filter notes.
    """
    super().__init__()
    self.folder = folder

go_back()

Navigate back to the previous state.

Source code in pare/apps/note/states.py
35
36
37
38
39
@user_tool()
@pare_event_registered(operation_type=OperationType.READ)
def go_back(self) -> None:
    """Navigate back to the previous state."""
    return None

list_folders()

List all folders.

Returns:

Type Description
list[str]

list[str]: Folder names.

Source code in pare/apps/note/states.py
 95
 96
 97
 98
 99
100
101
102
103
104
@user_tool()
@pare_event_registered(operation_type=OperationType.READ)
def list_folders(self) -> list[str]:
    """List all folders.

    Returns:
        list[str]: Folder names.
    """
    with disable_events():
        return cast("StatefulNotesApp", self.app).list_folders()

list_notes(offset=0, limit=10)

Return paginated notes under the current folder.

Parameters:

Name Type Description Default
offset int

Starting index for pagination.

0
limit int

Maximum number of notes to return.

10

Returns:

Name Type Description
ReturnedNotes ReturnedNotes

Paginated notes container.

Source code in pare/apps/note/states.py
41
42
43
44
45
46
47
48
49
50
51
52
53
54
@user_tool()
@pare_event_registered(operation_type=OperationType.READ)
def list_notes(self, offset: int = 0, limit: int = 10) -> ReturnedNotes:
    """Return paginated notes under the current folder.

    Args:
        offset (int): Starting index for pagination.
        limit (int): Maximum number of notes to return.

    Returns:
        ReturnedNotes: Paginated notes container.
    """
    with disable_events():
        return cast("StatefulNotesApp", self.app).list_notes(self.folder, offset, limit)

new_note()

Create a new note in the current folder.

Returns:

Name Type Description
str str

ID of newly created note.

Source code in pare/apps/note/states.py
70
71
72
73
74
75
76
77
78
79
@user_tool()
@pare_event_registered(operation_type=OperationType.WRITE)
def new_note(self) -> str:
    """Create a new note in the current folder.

    Returns:
        str: ID of newly created note.
    """
    with disable_events():
        return cast("StatefulNotesApp", self.app).create_note(folder=self.folder)

on_enter()

Lifecycle hook when entering NoteList.

Source code in pare/apps/note/states.py
27
28
29
def on_enter(self) -> None:
    """Lifecycle hook when entering NoteList."""
    pass

on_exit()

Lifecycle hook when leaving NoteList.

Source code in pare/apps/note/states.py
31
32
33
def on_exit(self) -> None:
    """Lifecycle hook when leaving NoteList."""
    pass

open(note_id)

Open a note by ID.

Parameters:

Name Type Description Default
note_id str

ID of the note to open.

required

Returns:

Name Type Description
Note Note

Note object from backend.

Source code in pare/apps/note/states.py
56
57
58
59
60
61
62
63
64
65
66
67
68
@user_tool()
@pare_event_registered(operation_type=OperationType.READ)
def open(self, note_id: str) -> Note:
    """Open a note by ID.

    Args:
        note_id (str): ID of the note to open.

    Returns:
        Note: Note object from backend.
    """
    with disable_events():
        return cast("StatefulNotesApp", self.app).get_note_by_id(note_id)

search(keyword)

Search notes by keyword.

Parameters:

Name Type Description Default
keyword str

Search keyword.

required

Returns:

Type Description
list[Note]

list[Note]: List of matched notes.

Source code in pare/apps/note/states.py
81
82
83
84
85
86
87
88
89
90
91
92
93
@user_tool()
@pare_event_registered(operation_type=OperationType.READ)
def search(self, keyword: str) -> list[Note]:
    """Search notes by keyword.

    Args:
        keyword (str): Search keyword.

    Returns:
        list[Note]: List of matched notes.
    """
    with disable_events():
        return cast("StatefulNotesApp", self.app).search_notes_in_folder(keyword, self.folder)

Type definitions for the Note app.

These are separated to avoid circular imports between app.py and states.py.

Note dataclass

Simple note data container.

Source code in pare/apps/note/types.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
@dataclass
class Note:
    """Simple note data container."""

    note_id: str
    title: str
    content: str
    pinned: bool = False
    attachments: dict[str, bytes] | None = field(default_factory=dict)
    created_at: float = field(default_factory=lambda: time.time())
    updated_at: float = field(default_factory=lambda: time.time())

    def __str__(self) -> str:
        return textwrap.dedent(
            f"""
            ID: {self.note_id}
            Title: {self.title}
            Content: {self.content}
            Pinned: {self.pinned}
            Created At: {datetime.fromtimestamp(self.created_at, tz=UTC).strftime("%Y-%m-%d %H:%M:%S")}
            Updated At: {datetime.fromtimestamp(self.updated_at, tz=UTC).strftime("%Y-%m-%d %H:%M:%S")}
            """
        )

    def __post_init__(self) -> None:
        if self.note_id is None or len(self.note_id) == 0:
            self.note_id = uuid.uuid4().hex

        if self.attachments is None:
            self.attachments = {}

    def add_attachment(self, path: str) -> None:
        """Add an attachment to the note.

        Args:
            path (str): Path to the attachment.
        """
        if not isinstance(path, str):
            raise TypeError(f"Path must be a string, got {type(path)}.")
        if len(path) == 0:
            raise ValueError("Path must be non-empty.")
        if not Path(path).exists():
            raise ValueError(f"File does not exist: {path}")
        with open(path, "rb") as f:
            file_content = base64.b64encode(f.read())
            file_name = Path(path).name
            if not self.attachments:
                self.attachments = {}
            self.attachments[file_name] = file_content

add_attachment(path)

Add an attachment to the note.

Parameters:

Name Type Description Default
path str

Path to the attachment.

required
Source code in pare/apps/note/types.py
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
def add_attachment(self, path: str) -> None:
    """Add an attachment to the note.

    Args:
        path (str): Path to the attachment.
    """
    if not isinstance(path, str):
        raise TypeError(f"Path must be a string, got {type(path)}.")
    if len(path) == 0:
        raise ValueError("Path must be non-empty.")
    if not Path(path).exists():
        raise ValueError(f"File does not exist: {path}")
    with open(path, "rb") as f:
        file_content = base64.b64encode(f.read())
        file_name = Path(path).name
        if not self.attachments:
            self.attachments = {}
        self.attachments[file_name] = file_content

ReturnedNotes dataclass

Container for paginated note results.

Source code in pare/apps/note/types.py
68
69
70
71
72
73
74
75
@dataclass
class ReturnedNotes:
    """Container for paginated note results."""

    notes: list[Note]
    notes_range: tuple[int, int]
    total_returned_notes: int
    total_notes: int