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.
Files changed (36) hide show
  1. diploid_plugins/__init__.py +25 -0
  2. diploid_plugins/auto_continue/__init__.py +5 -0
  3. diploid_plugins/auto_continue/auto_continue.py +222 -0
  4. diploid_plugins/body/__init__.py +8 -0
  5. diploid_plugins/body/body.py +186 -0
  6. diploid_plugins/body/body_mcp.py +288 -0
  7. diploid_plugins/body/config.py +21 -0
  8. diploid_plugins/body/manager.py +364 -0
  9. diploid_plugins/continuity/__init__.py +5 -0
  10. diploid_plugins/continuity/continuity.py +463 -0
  11. diploid_plugins/curriculum/__init__.py +5 -0
  12. diploid_plugins/curriculum/curriculum.py +131 -0
  13. diploid_plugins/curriculum/curriculum_mcp.py +198 -0
  14. diploid_plugins/harness/__init__.py +1 -0
  15. diploid_plugins/harness/harness_mcp.py +161 -0
  16. diploid_plugins/identity/__init__.py +5 -0
  17. diploid_plugins/identity/identity.py +160 -0
  18. diploid_plugins/identity/identity_mcp.py +193 -0
  19. diploid_plugins/persistent_memory/__init__.py +5 -0
  20. diploid_plugins/persistent_memory/persistent_memory.py +322 -0
  21. diploid_plugins/planner/__init__.py +7 -0
  22. diploid_plugins/planner/planner.py +240 -0
  23. diploid_plugins/self_management/__init__.py +5 -0
  24. diploid_plugins/self_management/approvals.py +68 -0
  25. diploid_plugins/self_management/self_management.py +13 -0
  26. diploid_plugins/self_management/self_management_mcp.py +343 -0
  27. diploid_plugins/self_state/__init__.py +5 -0
  28. diploid_plugins/self_state/self_state.py +207 -0
  29. diploid_plugins/working_memory/__init__.py +5 -0
  30. diploid_plugins/working_memory/working_memory.py +194 -0
  31. diploid_plugins/working_memory/working_memory_mcp.py +196 -0
  32. diploid_plugins-0.1.0.dist-info/METADATA +101 -0
  33. diploid_plugins-0.1.0.dist-info/RECORD +36 -0
  34. diploid_plugins-0.1.0.dist-info/WHEEL +5 -0
  35. diploid_plugins-0.1.0.dist-info/licenses/LICENSE +21 -0
  36. 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,5 @@
1
+ """Auto-continue plugin."""
2
+
3
+ from diploid_plugins.auto_continue.auto_continue import AutoContinuePlugin
4
+
5
+ Plugin = AutoContinuePlugin
@@ -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,8 @@
1
+ """Body state plugin for diploid-agent."""
2
+
3
+ from diploid_plugins.body.body import BodyPlugin
4
+ from diploid_plugins.body.body_mcp import BodyMcpServer, main
5
+
6
+ Plugin = BodyPlugin
7
+
8
+ __all__ = ["BodyMcpServer", "BodyPlugin", "Plugin", "main"]
@@ -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]