diploid-plugins 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- diploid_plugins/__init__.py +25 -0
- diploid_plugins/auto_continue/__init__.py +5 -0
- diploid_plugins/auto_continue/auto_continue.py +222 -0
- diploid_plugins/body/__init__.py +8 -0
- diploid_plugins/body/body.py +186 -0
- diploid_plugins/body/body_mcp.py +288 -0
- diploid_plugins/body/config.py +21 -0
- diploid_plugins/body/manager.py +364 -0
- diploid_plugins/continuity/__init__.py +5 -0
- diploid_plugins/continuity/continuity.py +463 -0
- diploid_plugins/curriculum/__init__.py +5 -0
- diploid_plugins/curriculum/curriculum.py +131 -0
- diploid_plugins/curriculum/curriculum_mcp.py +198 -0
- diploid_plugins/harness/__init__.py +1 -0
- diploid_plugins/harness/harness_mcp.py +161 -0
- diploid_plugins/identity/__init__.py +5 -0
- diploid_plugins/identity/identity.py +160 -0
- diploid_plugins/identity/identity_mcp.py +193 -0
- diploid_plugins/persistent_memory/__init__.py +5 -0
- diploid_plugins/persistent_memory/persistent_memory.py +322 -0
- diploid_plugins/planner/__init__.py +7 -0
- diploid_plugins/planner/planner.py +240 -0
- diploid_plugins/self_management/__init__.py +5 -0
- diploid_plugins/self_management/approvals.py +68 -0
- diploid_plugins/self_management/self_management.py +13 -0
- diploid_plugins/self_management/self_management_mcp.py +343 -0
- diploid_plugins/self_state/__init__.py +5 -0
- diploid_plugins/self_state/self_state.py +207 -0
- diploid_plugins/working_memory/__init__.py +5 -0
- diploid_plugins/working_memory/working_memory.py +194 -0
- diploid_plugins/working_memory/working_memory_mcp.py +196 -0
- diploid_plugins-0.1.0.dist-info/METADATA +101 -0
- diploid_plugins-0.1.0.dist-info/RECORD +36 -0
- diploid_plugins-0.1.0.dist-info/WHEEL +5 -0
- diploid_plugins-0.1.0.dist-info/licenses/LICENSE +21 -0
- diploid_plugins-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Built-in state plugins for diploid-agent."""
|
|
2
|
+
|
|
3
|
+
from diploid_plugins.auto_continue import AutoContinuePlugin
|
|
4
|
+
from diploid_plugins.body import BodyPlugin
|
|
5
|
+
from diploid_plugins.continuity import ContinuityPlugin
|
|
6
|
+
from diploid_plugins.curriculum import CurriculumPlugin
|
|
7
|
+
from diploid_plugins.identity import IdentityPlugin
|
|
8
|
+
from diploid_plugins.persistent_memory import PersistentMemoryPlugin
|
|
9
|
+
from diploid_plugins.planner import PlannerPlugin
|
|
10
|
+
from diploid_plugins.self_management import SelfManagementPlugin
|
|
11
|
+
from diploid_plugins.self_state import SelfStatePlugin
|
|
12
|
+
from diploid_plugins.working_memory import WorkingMemoryPlugin
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AutoContinuePlugin",
|
|
16
|
+
"BodyPlugin",
|
|
17
|
+
"ContinuityPlugin",
|
|
18
|
+
"CurriculumPlugin",
|
|
19
|
+
"IdentityPlugin",
|
|
20
|
+
"PersistentMemoryPlugin",
|
|
21
|
+
"PlannerPlugin",
|
|
22
|
+
"SelfManagementPlugin",
|
|
23
|
+
"SelfStatePlugin",
|
|
24
|
+
"WorkingMemoryPlugin",
|
|
25
|
+
]
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Auto-continue plugin: resume a chat turn automatically after a stop reason."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from diploid_agent.config import PluginConfig
|
|
12
|
+
from diploid_agent.models import WakeEvent
|
|
13
|
+
from diploid_agent.plugins.base import StatePlugin, TurnInfo
|
|
14
|
+
from diploid_agent.plugins.contexts import TurnStartContext
|
|
15
|
+
from diploid_agent.runtime.plugin_runtime import PluginRuntime
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
# Cap the continuation payload so the wake queue and downstream Telegram edits
|
|
20
|
+
# do not balloon with a huge partial thought stream.
|
|
21
|
+
_MAX_CONTINUATION_TEXT_CHARS = 2000
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _join_notices(*parts: str | None) -> str | None:
|
|
25
|
+
"""Concatenate non-empty notice strings with a blank line between them."""
|
|
26
|
+
joined = "\n\n".join(p for p in parts if p)
|
|
27
|
+
return joined or None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AutoContinuePlugin(StatePlugin):
|
|
31
|
+
"""Resume a chat turn automatically after a configured stop reason."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
config: PluginConfig,
|
|
36
|
+
chat_id: str,
|
|
37
|
+
sessions_root: Path,
|
|
38
|
+
runtime: PluginRuntime | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
super().__init__(config, chat_id, sessions_root, runtime=runtime)
|
|
41
|
+
self._state = self._load_state()
|
|
42
|
+
cfg = self.config.config or {}
|
|
43
|
+
self._delay_seconds = float(cfg.get("delay_seconds", 2.0))
|
|
44
|
+
self._max_attempts = int(cfg.get("max_attempts", 3))
|
|
45
|
+
self._delay_cap_seconds = float(cfg.get("delay_cap_seconds", 60.0))
|
|
46
|
+
self._stop_reasons = set(cfg.get("stop_reasons", ["timeout", "cancelled"]))
|
|
47
|
+
self._per_reason = dict(cfg.get("per_reason", {}))
|
|
48
|
+
self._reason = cfg.get("reason", "auto_continue")
|
|
49
|
+
|
|
50
|
+
def _settings(self, stop_reason: str | None) -> tuple[int, float, float]:
|
|
51
|
+
"""Return (max_attempts, delay_seconds, delay_cap) for a stop reason."""
|
|
52
|
+
reason_cfg = self._per_reason.get(stop_reason, {})
|
|
53
|
+
max_attempts = int(reason_cfg.get("max_attempts", self._max_attempts))
|
|
54
|
+
delay = float(reason_cfg.get("delay_seconds", self._delay_seconds))
|
|
55
|
+
cap = float(reason_cfg.get("delay_cap_seconds", self._delay_cap_seconds))
|
|
56
|
+
return max_attempts, delay, cap
|
|
57
|
+
|
|
58
|
+
def _backoff_delay(self, delay: float, attempt: int, cap: float) -> float:
|
|
59
|
+
"""Exponential backoff capped at delay_cap_seconds."""
|
|
60
|
+
return min(delay * (2 ** (attempt - 1)), cap)
|
|
61
|
+
|
|
62
|
+
def _load_state(self) -> dict[str, Any]:
|
|
63
|
+
path = self.state_path()
|
|
64
|
+
if path is None or not path.exists():
|
|
65
|
+
return {}
|
|
66
|
+
try:
|
|
67
|
+
return json.loads(path.read_text())
|
|
68
|
+
except (json.JSONDecodeError, OSError):
|
|
69
|
+
return {}
|
|
70
|
+
|
|
71
|
+
def _save_state(self) -> None:
|
|
72
|
+
path = self.state_path()
|
|
73
|
+
if path is None:
|
|
74
|
+
return
|
|
75
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
path.write_text(json.dumps(self._state, indent=2, default=str))
|
|
77
|
+
|
|
78
|
+
def _is_continuation(self, text: str) -> bool:
|
|
79
|
+
if not self._runtime:
|
|
80
|
+
return False
|
|
81
|
+
return self._runtime.is_continuation_message(text)
|
|
82
|
+
|
|
83
|
+
def _cancel_pending(self) -> None:
|
|
84
|
+
if not self._runtime:
|
|
85
|
+
return
|
|
86
|
+
now = time.time()
|
|
87
|
+
for event in self._runtime.wake_queue.pending(chat_id=self.chat_id):
|
|
88
|
+
if event.reason != self._reason:
|
|
89
|
+
continue
|
|
90
|
+
if event.leased_until is not None and event.leased_until > now:
|
|
91
|
+
continue
|
|
92
|
+
self._runtime.wake_queue.complete(event.id)
|
|
93
|
+
|
|
94
|
+
def before_turn(self, context: TurnStartContext) -> None:
|
|
95
|
+
record = context.record
|
|
96
|
+
if record is None or record.last_stop_reason not in self._stop_reasons:
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
if self._is_continuation(context.user_message):
|
|
100
|
+
# Manual continue takes over; cancel any pending auto-continue and
|
|
101
|
+
# drop any deferred partial notice.
|
|
102
|
+
self._cancel_pending()
|
|
103
|
+
self._state.pop("deferred_notice", None)
|
|
104
|
+
self._state.pop("send_deferred", None)
|
|
105
|
+
self._save_state()
|
|
106
|
+
# Attempt count stays as-is because this manual turn is the next attempt.
|
|
107
|
+
else:
|
|
108
|
+
# User changed topic; cancel the auto-continue chain and drop the
|
|
109
|
+
# deferred partial notice. If the user wants the partial result, they
|
|
110
|
+
# can still say Continue; otherwise we should not leak a soft-timeout
|
|
111
|
+
# / stopped notice into a normal reply.
|
|
112
|
+
self._cancel_pending()
|
|
113
|
+
self._state["attempt"] = 0
|
|
114
|
+
self._state.pop("deferred_notice", None)
|
|
115
|
+
self._state.pop("send_deferred", None)
|
|
116
|
+
self._save_state()
|
|
117
|
+
|
|
118
|
+
def after_turn(self, turn: TurnInfo) -> None:
|
|
119
|
+
# If the harness has suppressed auto-continue (e.g., a service restart
|
|
120
|
+
# is pending), drop the continuation chain and any deferred notice.
|
|
121
|
+
if self._runtime and self._runtime.is_auto_continue_suppressed(self.chat_id):
|
|
122
|
+
self._cancel_pending()
|
|
123
|
+
self._state.pop("deferred_notice", None)
|
|
124
|
+
self._state.pop("send_deferred", None)
|
|
125
|
+
self._state["attempt"] = 0
|
|
126
|
+
self._save_state()
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
reason = turn.last_stop_reason
|
|
130
|
+
if reason not in self._stop_reasons:
|
|
131
|
+
# The turn finished or was stopped by the user. Send any deferred
|
|
132
|
+
# partial notice that has been pending, then reset the chain.
|
|
133
|
+
if self._state.get("send_deferred") and self._state.get("deferred_notice"):
|
|
134
|
+
turn.notice = _join_notices(self._state.pop("deferred_notice"), turn.notice)
|
|
135
|
+
self._state.pop("send_deferred", None)
|
|
136
|
+
self._save_state()
|
|
137
|
+
elif self._state.get("attempt"):
|
|
138
|
+
self._state["attempt"] = 0
|
|
139
|
+
self._save_state()
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
max_attempts, base_delay, delay_cap = self._settings(reason)
|
|
143
|
+
attempt = self._state.get("attempt", 0) + 1
|
|
144
|
+
if attempt > max_attempts:
|
|
145
|
+
logger.info(
|
|
146
|
+
"Auto-continue for %s reached max %d attempts",
|
|
147
|
+
self.chat_id,
|
|
148
|
+
max_attempts,
|
|
149
|
+
)
|
|
150
|
+
# Give up: send the current partial notice along with any older one.
|
|
151
|
+
if turn.partial_notice or self._state.get("deferred_notice"):
|
|
152
|
+
turn.notice = _join_notices(
|
|
153
|
+
turn.partial_notice,
|
|
154
|
+
self._state.pop("deferred_notice", None),
|
|
155
|
+
turn.notice,
|
|
156
|
+
)
|
|
157
|
+
turn.partial_notice = None
|
|
158
|
+
self._state.pop("send_deferred", None)
|
|
159
|
+
self._state["attempt"] = 0
|
|
160
|
+
self._save_state()
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
# Show the working indicator, but keep the partial notice visible so the
|
|
164
|
+
# user can see what was already completed before the auto-continuation.
|
|
165
|
+
delay = self._backoff_delay(base_delay, attempt, delay_cap)
|
|
166
|
+
working = f"Working... (attempt {attempt}/{max_attempts}); continuing automatically in {delay:.0f}s."
|
|
167
|
+
if turn.partial_notice:
|
|
168
|
+
turn.notice = _join_notices(
|
|
169
|
+
turn.notice,
|
|
170
|
+
turn.partial_notice,
|
|
171
|
+
working,
|
|
172
|
+
)
|
|
173
|
+
turn.partial_notice = None
|
|
174
|
+
# Do not carry a stale deferred notice from a previous version.
|
|
175
|
+
self._state.pop("deferred_notice", None)
|
|
176
|
+
self._state.pop("send_deferred", None)
|
|
177
|
+
else:
|
|
178
|
+
turn.notice = _join_notices(turn.notice, working)
|
|
179
|
+
|
|
180
|
+
self._schedule(attempt, max_attempts, delay)
|
|
181
|
+
|
|
182
|
+
def _schedule(self, attempt: int, max_attempts: int, delay: float) -> None:
|
|
183
|
+
if not self._runtime:
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
payload: dict[str, Any] = {"user_message": "Continue"}
|
|
187
|
+
try:
|
|
188
|
+
chat_id_int = int(self.chat_id)
|
|
189
|
+
placeholder_path = self.sessions_root / ".poller-placeholders" / f"{chat_id_int}.json"
|
|
190
|
+
if placeholder_path.exists():
|
|
191
|
+
placeholder_state = json.loads(placeholder_path.read_text())
|
|
192
|
+
payload["message_id"] = placeholder_state.get("message_id")
|
|
193
|
+
payload["thought_id"] = placeholder_state.get("thought_id")
|
|
194
|
+
|
|
195
|
+
active = self._runtime._active_turns.get(self.chat_id)
|
|
196
|
+
if active is not None:
|
|
197
|
+
payload["message_text"] = active.message_text[:_MAX_CONTINUATION_TEXT_CHARS]
|
|
198
|
+
payload["thought_text"] = active.thought_text[:_MAX_CONTINUATION_TEXT_CHARS]
|
|
199
|
+
except Exception:
|
|
200
|
+
logger.exception("Failed to capture continuation placeholder for %s", self.chat_id)
|
|
201
|
+
|
|
202
|
+
event = WakeEvent(
|
|
203
|
+
id="",
|
|
204
|
+
chat_id=self.chat_id,
|
|
205
|
+
reason=self._reason,
|
|
206
|
+
priority=1,
|
|
207
|
+
scheduled_at=time.time() + delay,
|
|
208
|
+
payload=payload,
|
|
209
|
+
silent=False,
|
|
210
|
+
created_at=time.time(),
|
|
211
|
+
ready=True,
|
|
212
|
+
)
|
|
213
|
+
self._runtime.wake_queue.enqueue(event)
|
|
214
|
+
self._state["attempt"] = attempt
|
|
215
|
+
self._save_state()
|
|
216
|
+
logger.info(
|
|
217
|
+
"Auto-continue scheduled for %s in %.1fs (attempt %d/%d)",
|
|
218
|
+
self.chat_id,
|
|
219
|
+
delay,
|
|
220
|
+
attempt,
|
|
221
|
+
max_attempts,
|
|
222
|
+
)
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Body-state plugin for diploid-agent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from diploid_agent.config import McpServerConfig, PluginConfig
|
|
11
|
+
from diploid_agent.memory import MemoryItem
|
|
12
|
+
from diploid_agent.plugins.base import SleepContext, StatePlugin, TurnInfo, WakeContext
|
|
13
|
+
from diploid_agent.runtime.plugin_runtime import PluginRuntime
|
|
14
|
+
|
|
15
|
+
from diploid_plugins.body.config import BodyConfig
|
|
16
|
+
from diploid_plugins.body.manager import BodyEvent, BodyManager
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BodyPlugin(StatePlugin):
|
|
20
|
+
"""Per-chat body state that survives transport restarts."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
config: PluginConfig,
|
|
25
|
+
chat_id: str,
|
|
26
|
+
sessions_root: Any,
|
|
27
|
+
runtime: PluginRuntime | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
super().__init__(config, chat_id, sessions_root, runtime=runtime)
|
|
30
|
+
self._decay_rate = float(config.config.get("decay_rate_per_minute", 0.05))
|
|
31
|
+
self._max_intensity = float(config.config.get("max_intensity", 1.0))
|
|
32
|
+
felt_keys = (
|
|
33
|
+
"felt_turn_retention",
|
|
34
|
+
"felt_wake_fade",
|
|
35
|
+
"felt_silence_cap_hours",
|
|
36
|
+
"felt_surface_threshold",
|
|
37
|
+
"felt_expire_seconds",
|
|
38
|
+
"felt_event_gain",
|
|
39
|
+
"max_felt_summary_chars",
|
|
40
|
+
"felt_events_max",
|
|
41
|
+
"felt_event_record_threshold",
|
|
42
|
+
)
|
|
43
|
+
self._felt_overrides = {
|
|
44
|
+
k: config.config[k] for k in felt_keys if k in config.config
|
|
45
|
+
}
|
|
46
|
+
body_cfg = BodyConfig(
|
|
47
|
+
state_file=config.state_file or "chat_body_state.json",
|
|
48
|
+
max_prompt_chars=config.max_prompt_chars,
|
|
49
|
+
decay_rate_per_minute=self._decay_rate,
|
|
50
|
+
max_intensity=self._max_intensity,
|
|
51
|
+
**self._felt_overrides,
|
|
52
|
+
)
|
|
53
|
+
self._body = BodyManager(sessions_root, chat_id, body_cfg)
|
|
54
|
+
|
|
55
|
+
def mcp_server(self) -> McpServerConfig | None:
|
|
56
|
+
return McpServerConfig(
|
|
57
|
+
name="diploid-body",
|
|
58
|
+
command="python",
|
|
59
|
+
args=[
|
|
60
|
+
"-m",
|
|
61
|
+
"diploid_plugins.body.body_mcp",
|
|
62
|
+
"--chat-id",
|
|
63
|
+
"{chat_id}",
|
|
64
|
+
"--sessions-root",
|
|
65
|
+
"{sessions_root}",
|
|
66
|
+
"--state-file",
|
|
67
|
+
self.config.state_file or "chat_body_state.json",
|
|
68
|
+
"--decay-rate",
|
|
69
|
+
str(self._decay_rate),
|
|
70
|
+
"--max-intensity",
|
|
71
|
+
str(self._max_intensity),
|
|
72
|
+
"--felt-config",
|
|
73
|
+
json.dumps(self._felt_overrides),
|
|
74
|
+
],
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def _body_map_path(self) -> Path | None:
|
|
78
|
+
if self._runtime is None:
|
|
79
|
+
return None
|
|
80
|
+
try:
|
|
81
|
+
return self._runtime.config.persona.profile_root / "references" / "body.md"
|
|
82
|
+
except AttributeError:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
def _body_map_text(self) -> str | None:
|
|
86
|
+
path = self._body_map_path()
|
|
87
|
+
if path is None or not path.exists():
|
|
88
|
+
return None
|
|
89
|
+
return path.read_text().strip()
|
|
90
|
+
|
|
91
|
+
def on_waking(self, context: WakeContext) -> None:
|
|
92
|
+
"""Reload from the restored snapshot and let sensations fade."""
|
|
93
|
+
self._body.refresh()
|
|
94
|
+
self._body.decay()
|
|
95
|
+
since = context.previous_turn_at or self._body.state.updated_at
|
|
96
|
+
silence = max(0.0, context.now - since) if since else 0.0
|
|
97
|
+
self._body.felt_wake(silence)
|
|
98
|
+
|
|
99
|
+
def on_sleeping(self, context: SleepContext) -> None:
|
|
100
|
+
"""Flush body state to disk before the transport can die."""
|
|
101
|
+
self._body._save_state()
|
|
102
|
+
|
|
103
|
+
def prompt_block(self, max_chars: int | None = None) -> str | None:
|
|
104
|
+
body_map = self._body_map_text()
|
|
105
|
+
state = self._body.state_for_prompt()
|
|
106
|
+
text = f"{body_map}\n\n{state}" if body_map else state
|
|
107
|
+
if not text:
|
|
108
|
+
return None
|
|
109
|
+
if max_chars is not None and len(text) > max_chars:
|
|
110
|
+
text = text[:max_chars]
|
|
111
|
+
return text
|
|
112
|
+
|
|
113
|
+
def event(
|
|
114
|
+
self,
|
|
115
|
+
*,
|
|
116
|
+
event: str | None = None,
|
|
117
|
+
raw_args: str | None = None,
|
|
118
|
+
**params: Any,
|
|
119
|
+
) -> str:
|
|
120
|
+
kind = event
|
|
121
|
+
if not kind:
|
|
122
|
+
return "Usage: /state body <kind> [location] [intensity]"
|
|
123
|
+
location = params.get("location")
|
|
124
|
+
intensity = params.get("intensity", 0.5)
|
|
125
|
+
if raw_args:
|
|
126
|
+
parts = _split_unquoted(raw_args)
|
|
127
|
+
if not location and len(parts) >= 1:
|
|
128
|
+
location = parts[0]
|
|
129
|
+
if len(parts) >= 2:
|
|
130
|
+
try:
|
|
131
|
+
intensity = float(parts[1])
|
|
132
|
+
except ValueError:
|
|
133
|
+
intensity = 0.5
|
|
134
|
+
try:
|
|
135
|
+
intensity = float(intensity)
|
|
136
|
+
except (TypeError, ValueError):
|
|
137
|
+
intensity = 0.5
|
|
138
|
+
if kind == "felt":
|
|
139
|
+
summary = location or (raw_args or "")
|
|
140
|
+
self._body.set_felt(summary, intensity)
|
|
141
|
+
return "Felt line written."
|
|
142
|
+
self._body.event(kind, location, intensity)
|
|
143
|
+
return f"Felt: {kind} {location or ''} ({intensity}).".strip()
|
|
144
|
+
|
|
145
|
+
def memory_items(self, since: float) -> list[MemoryItem]:
|
|
146
|
+
return [
|
|
147
|
+
MemoryItem(
|
|
148
|
+
content=_body_event_description(event),
|
|
149
|
+
timestamp=_ts_iso(event.timestamp),
|
|
150
|
+
document_id=f"body-{self.chat_id}-{int(event.timestamp * 1000)}",
|
|
151
|
+
session_number=0,
|
|
152
|
+
metadata={
|
|
153
|
+
"kind": event.kind,
|
|
154
|
+
"location": event.location,
|
|
155
|
+
"intensity": event.intensity,
|
|
156
|
+
"chat_id": self.chat_id,
|
|
157
|
+
},
|
|
158
|
+
tags=["body", "sensation", f"chat:{self.chat_id}"],
|
|
159
|
+
)
|
|
160
|
+
for event in self._body.recent_events(since=since)
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
def on_turn_end(self, turn: TurnInfo) -> None:
|
|
164
|
+
self._body.clear_events_before(turn.updated_at)
|
|
165
|
+
self._body.decay()
|
|
166
|
+
self._body.felt_turn_step()
|
|
167
|
+
self._body._save_state()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _body_event_description(event: BodyEvent) -> str:
|
|
171
|
+
return (
|
|
172
|
+
f"{event.kind}"
|
|
173
|
+
f"{f' at {event.location}' if event.location else ''}"
|
|
174
|
+
f" (intensity {event.intensity})"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _ts_iso(when: float) -> str:
|
|
179
|
+
from datetime import UTC, datetime
|
|
180
|
+
|
|
181
|
+
return datetime.fromtimestamp(when, tz=UTC).isoformat()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _split_unquoted(text: str) -> list[str]:
|
|
185
|
+
parts = re.findall(r'"([^"]*)"|(\S+)', text)
|
|
186
|
+
return [q or u for q, u in parts]
|