alter-runtime 0.3.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.
- alter_runtime/__init__.py +11 -0
- alter_runtime/adapters/__init__.py +19 -0
- alter_runtime/adapters/claude_jsonl_watcher.py +545 -0
- alter_runtime/adapters/git_watcher.py +457 -0
- alter_runtime/adapters/household/__init__.py +29 -0
- alter_runtime/adapters/household/_base.py +138 -0
- alter_runtime/adapters/household/compost/__init__.py +17 -0
- alter_runtime/adapters/household/compost/adapter.py +81 -0
- alter_runtime/adapters/household/compost/storage.py +75 -0
- alter_runtime/adapters/household/compost/tests/__init__.py +0 -0
- alter_runtime/adapters/household/compost/tests/test_adapter.py +62 -0
- alter_runtime/adapters/household/compost/tests/test_storage.py +23 -0
- alter_runtime/adapters/household/compost/tests/test_traits.py +38 -0
- alter_runtime/adapters/household/compost/traits.py +79 -0
- alter_runtime/adapters/household/self_hoster/__init__.py +30 -0
- alter_runtime/adapters/household/self_hoster/adapter.py +248 -0
- alter_runtime/adapters/household/self_hoster/storage.py +83 -0
- alter_runtime/adapters/household/self_hoster/tests/__init__.py +0 -0
- alter_runtime/adapters/household/self_hoster/tests/test_adapter.py +216 -0
- alter_runtime/adapters/household/self_hoster/tests/test_storage.py +25 -0
- alter_runtime/adapters/household/self_hoster/tests/test_traits.py +55 -0
- alter_runtime/adapters/household/self_hoster/traits.py +105 -0
- alter_runtime/adapters/household/tapo_ecosystem/__init__.py +22 -0
- alter_runtime/adapters/household/tapo_ecosystem/adapter.py +98 -0
- alter_runtime/adapters/household/tapo_ecosystem/storage.py +95 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/__init__.py +0 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_adapter.py +55 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_storage.py +28 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_traits.py +45 -0
- alter_runtime/adapters/household/tapo_ecosystem/traits.py +97 -0
- alter_runtime/adapters/household/workshop_tools/__init__.py +25 -0
- alter_runtime/adapters/household/workshop_tools/adapter.py +77 -0
- alter_runtime/adapters/household/workshop_tools/storage.py +92 -0
- alter_runtime/adapters/household/workshop_tools/tests/__init__.py +0 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_adapter.py +48 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_storage.py +26 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_traits.py +45 -0
- alter_runtime/adapters/household/workshop_tools/traits.py +95 -0
- alter_runtime/adapters/worktree_watcher.py +378 -0
- alter_runtime/atlas/__init__.py +48 -0
- alter_runtime/atlas/base.py +102 -0
- alter_runtime/atlas/ledger.py +196 -0
- alter_runtime/atlas/observations.py +136 -0
- alter_runtime/atlas/schema.py +106 -0
- alter_runtime/cap_cache.py +392 -0
- alter_runtime/cli.py +517 -0
- alter_runtime/clients/__init__.py +0 -0
- alter_runtime/clients/token_usage_client.py +273 -0
- alter_runtime/config.py +648 -0
- alter_runtime/consent.py +425 -0
- alter_runtime/daemon.py +518 -0
- alter_runtime/floor_loop.py +335 -0
- alter_runtime/floor_preflight.py +734 -0
- alter_runtime/http_auth.py +173 -0
- alter_runtime/notifiers/__init__.py +18 -0
- alter_runtime/notifiers/desktop.py +321 -0
- alter_runtime/sdk/__init__.py +12 -0
- alter_runtime/sdk/client.py +399 -0
- alter_runtime/service_install.py +616 -0
- alter_runtime/services/__init__.py +59 -0
- alter_runtime/services/launchd/com.alter.runtime.plist.in +90 -0
- alter_runtime/services/systemd/alter-runtime.service.in +74 -0
- alter_runtime/services/systemd/cf-access-env.conf.in +29 -0
- alter_runtime/sockets/__init__.py +20 -0
- alter_runtime/sockets/dbus.py +272 -0
- alter_runtime/sockets/unix.py +702 -0
- alter_runtime/subscribers/__init__.py +58 -0
- alter_runtime/subscribers/active_sessions_cron_emitter.py +313 -0
- alter_runtime/subscribers/active_sessions_do_publisher.py +1159 -0
- alter_runtime/subscribers/active_sessions_gc.py +432 -0
- alter_runtime/subscribers/active_sessions_writer.py +446 -0
- alter_runtime/subscribers/adapters_writer.py +415 -0
- alter_runtime/subscribers/agent_frames.py +461 -0
- alter_runtime/subscribers/bus.py +188 -0
- alter_runtime/subscribers/cache_writer.py +347 -0
- alter_runtime/subscribers/ceremony_echo.py +290 -0
- alter_runtime/subscribers/do_sse.py +864 -0
- alter_runtime/subscribers/ebpf.py +506 -0
- alter_runtime/subscribers/inbox_writer.py +469 -0
- alter_runtime/subscribers/mcp_fallback.py +391 -0
- alter_runtime/subscribers/presence_writer.py +426 -0
- alter_runtime/subscribers/session_presence.py +467 -0
- alter_runtime/subscribers/sse.py +125 -0
- alter_runtime/subscribers/weave_intent_writer.py +608 -0
- alter_runtime/update_loop.py +519 -0
- alter_runtime/weave/__init__.py +21 -0
- alter_runtime/weave/resolver.py +544 -0
- alter_runtime-0.3.0.dist-info/METADATA +289 -0
- alter_runtime-0.3.0.dist-info/RECORD +92 -0
- alter_runtime-0.3.0.dist-info/WHEEL +4 -0
- alter_runtime-0.3.0.dist-info/entry_points.txt +2 -0
- alter_runtime-0.3.0.dist-info/licenses/LICENSE +190 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""CacheWriter - projects identity state into the shared shell cache file.
|
|
2
|
+
|
|
3
|
+
This is the W2.2d *first pixel* glue. The existing shell-side tools -
|
|
4
|
+
``scripts/alter-identity.sh`` (neofetch / waybar / prompt integration) and
|
|
5
|
+
``.claude/hooks/alter-identity.sh`` (CC session context injection) - read
|
|
6
|
+
live identity state from a file at ``$XDG_CACHE_HOME/alter/identity.json``.
|
|
7
|
+
Before this component existed, the shell script hit the backend MCP
|
|
8
|
+
endpoint directly on every cache miss; that path is slow (HTTPS round-trip)
|
|
9
|
+
and fails offline.
|
|
10
|
+
|
|
11
|
+
With CacheWriter registered in the daemon, every ``identity.event`` that
|
|
12
|
+
looks like a state snapshot (whether from the live DO SSE stream or the
|
|
13
|
+
cold-start MCP fallback) projects into the cache file with an atomic
|
|
14
|
+
``tmp → rename`` write. The shell script's existing ``cache_is_fresh``
|
|
15
|
+
+ ``read_cache`` flow therefore transparently picks up live state as
|
|
16
|
+
soon as the daemon is running - no shell script changes needed for the
|
|
17
|
+
first pixel.
|
|
18
|
+
|
|
19
|
+
The cache schema matches the JSON already written by the legacy
|
|
20
|
+
``alter-identity.sh`` API path (see ``scripts/alter-identity.sh:242``):
|
|
21
|
+
|
|
22
|
+
::
|
|
23
|
+
|
|
24
|
+
{
|
|
25
|
+
"handle": "blake", - bare handle, no leading ~
|
|
26
|
+
"level": "3", - bare numeric tier, no leading L
|
|
27
|
+
"attunement": "0.42", - decimal 0..1 or integer percentage
|
|
28
|
+
"income": "1.23" - stringified USD earnings
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
All fields are strings to match the shell script's ``jq -r`` consumers.
|
|
32
|
+
When a field is missing in the source event the output key is an empty
|
|
33
|
+
string (``""``) rather than omitted, so ``jq -r '.handle // ""'``
|
|
34
|
+
produces consistent behaviour across cache misses.
|
|
35
|
+
|
|
36
|
+
**Why strip the ``~`` and ``L`` prefixes?** The shell script prepends
|
|
37
|
+
them at render time (``scripts/alter-identity.sh:264-265``):
|
|
38
|
+
|
|
39
|
+
.. code-block:: bash
|
|
40
|
+
|
|
41
|
+
display_handle="${handle:+~$handle}"
|
|
42
|
+
display_level="${level:+L$level}"
|
|
43
|
+
|
|
44
|
+
If we stored ``"~blake"`` / ``"L3"``, the script would render
|
|
45
|
+
``"~~blake"`` / ``"LL3"``. The W2.2d *first pixel* design is to feed
|
|
46
|
+
the existing shell scripts live data with **zero script changes**, so
|
|
47
|
+
the CacheWriter matches the raw shape the scripts already expect.
|
|
48
|
+
|
|
49
|
+
Design notes
|
|
50
|
+
------------
|
|
51
|
+
|
|
52
|
+
* The projection is *idempotent and lossy*. We keep only the four fields
|
|
53
|
+
the shell script cares about; anything else from the source event is
|
|
54
|
+
dropped. If a field is unchanged from the last write we still rewrite
|
|
55
|
+
the file - atomic rename is cheap and the shell script uses file mtime
|
|
56
|
+
as its TTL probe, so we want the mtime to bump on every refresh.
|
|
57
|
+
* The writer is tolerant of a variety of source shapes. State sync
|
|
58
|
+
events from ``mcp_fallback`` wrap their payload under ``payload``;
|
|
59
|
+
direct DO state frames put fields at the top level. The
|
|
60
|
+
:func:`_extract_state` helper checks both.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
from __future__ import annotations
|
|
64
|
+
|
|
65
|
+
import asyncio
|
|
66
|
+
import contextlib
|
|
67
|
+
import json
|
|
68
|
+
import logging
|
|
69
|
+
import os
|
|
70
|
+
from pathlib import Path
|
|
71
|
+
from typing import TYPE_CHECKING, Any
|
|
72
|
+
|
|
73
|
+
from alter_runtime.config import cache_dir
|
|
74
|
+
from alter_runtime.daemon import Component
|
|
75
|
+
|
|
76
|
+
if TYPE_CHECKING:
|
|
77
|
+
from alter_runtime.subscribers.bus import EventBus
|
|
78
|
+
|
|
79
|
+
__all__ = ["CacheWriter", "project_state_to_cache"]
|
|
80
|
+
|
|
81
|
+
logger = logging.getLogger("alter_runtime.subscribers.cache_writer")
|
|
82
|
+
|
|
83
|
+
#: Filename inside ``cache_dir()`` that the shell script reads.
|
|
84
|
+
CACHE_FILENAME: str = "identity.json"
|
|
85
|
+
|
|
86
|
+
#: Event kinds we recognise as carrying identity state.
|
|
87
|
+
STATE_KINDS: frozenset[str] = frozenset(
|
|
88
|
+
{
|
|
89
|
+
"state_sync",
|
|
90
|
+
"alter_whoami",
|
|
91
|
+
"attunement_transition",
|
|
92
|
+
"identity_state",
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class CacheWriter(Component):
|
|
98
|
+
"""Writes identity state into ``$XDG_CACHE_HOME/alter/identity.json``.
|
|
99
|
+
|
|
100
|
+
Parameters
|
|
101
|
+
----------
|
|
102
|
+
bus:
|
|
103
|
+
The shared :class:`EventBus`. Subscribes to ``identity.event``.
|
|
104
|
+
cache_path:
|
|
105
|
+
Override the cache file path. Tests inject a ``tmp_path`` value.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
name = "cache_writer"
|
|
109
|
+
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
bus: EventBus,
|
|
113
|
+
*,
|
|
114
|
+
cache_path: Path | None = None,
|
|
115
|
+
) -> None:
|
|
116
|
+
self._bus = bus
|
|
117
|
+
self._cache_path: Path = (
|
|
118
|
+
cache_path if cache_path is not None else cache_dir() / CACHE_FILENAME
|
|
119
|
+
)
|
|
120
|
+
self._stop_event = asyncio.Event()
|
|
121
|
+
self._last_written: dict[str, str] | None = None
|
|
122
|
+
|
|
123
|
+
# ------------------------------------------------------------------
|
|
124
|
+
# Component lifecycle
|
|
125
|
+
# ------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
async def run(self) -> None:
|
|
128
|
+
self._bus.subscribe("identity.event", self._on_event)
|
|
129
|
+
logger.info("cache_writer started cache=%s", self._cache_path)
|
|
130
|
+
try:
|
|
131
|
+
await self._stop_event.wait()
|
|
132
|
+
finally:
|
|
133
|
+
self._bus.unsubscribe("identity.event", self._on_event)
|
|
134
|
+
logger.info("cache_writer stopped")
|
|
135
|
+
|
|
136
|
+
async def stop(self) -> None:
|
|
137
|
+
self._stop_event.set()
|
|
138
|
+
|
|
139
|
+
# ------------------------------------------------------------------
|
|
140
|
+
# Bus callback
|
|
141
|
+
# ------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
async def _on_event(self, event: dict[str, Any]) -> None:
|
|
144
|
+
"""Project a bus event to the cache file (if it's a state snapshot)."""
|
|
145
|
+
if not isinstance(event, dict):
|
|
146
|
+
return
|
|
147
|
+
kind = event.get("kind")
|
|
148
|
+
if kind not in STATE_KINDS:
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
projected = project_state_to_cache(event)
|
|
152
|
+
if not projected:
|
|
153
|
+
logger.debug("cache_writer ignored event kind=%s - no extractable state", kind)
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
self._atomic_write(projected)
|
|
158
|
+
except OSError as exc:
|
|
159
|
+
logger.warning("cache_writer: write failed: %s - keeping previous cache", exc)
|
|
160
|
+
return
|
|
161
|
+
self._last_written = projected
|
|
162
|
+
logger.info(
|
|
163
|
+
"cache_writer wrote handle=%s level=%s attunement=%s",
|
|
164
|
+
projected.get("handle"),
|
|
165
|
+
projected.get("level"),
|
|
166
|
+
projected.get("attunement"),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# ------------------------------------------------------------------
|
|
170
|
+
# Atomic write
|
|
171
|
+
# ------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
def _atomic_write(self, data: dict[str, str]) -> None:
|
|
174
|
+
"""Write ``data`` to the cache file via a tmp-and-rename sequence."""
|
|
175
|
+
self._cache_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
176
|
+
tmp_path = self._cache_path.with_suffix(self._cache_path.suffix + ".tmp")
|
|
177
|
+
|
|
178
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
|
179
|
+
fd = os.open(tmp_path, flags, 0o600)
|
|
180
|
+
try:
|
|
181
|
+
with contextlib.suppress(OSError):
|
|
182
|
+
os.fchmod(fd, 0o600)
|
|
183
|
+
payload = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
|
|
184
|
+
os.write(fd, payload.encode("utf-8"))
|
|
185
|
+
os.fsync(fd)
|
|
186
|
+
finally:
|
|
187
|
+
os.close(fd)
|
|
188
|
+
|
|
189
|
+
os.replace(tmp_path, self._cache_path)
|
|
190
|
+
with contextlib.suppress(OSError):
|
|
191
|
+
os.chmod(self._cache_path, 0o600)
|
|
192
|
+
|
|
193
|
+
# ------------------------------------------------------------------
|
|
194
|
+
# Test introspection
|
|
195
|
+
# ------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def cache_path(self) -> Path:
|
|
199
|
+
return self._cache_path
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def last_written(self) -> dict[str, str] | None:
|
|
203
|
+
return self._last_written
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
# Pure projection helper
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def project_state_to_cache(event: dict[str, Any]) -> dict[str, str]:
|
|
212
|
+
"""Project a bus event dict into the shell-script cache schema.
|
|
213
|
+
|
|
214
|
+
Returns an empty dict if the event doesn't carry any recognisable
|
|
215
|
+
identity state. The result always contains the four fields the shell
|
|
216
|
+
script reads (``handle``, ``level``, ``attunement``, ``income``) with
|
|
217
|
+
empty strings filling any missing values - this keeps ``jq -r`` consumers
|
|
218
|
+
happy across variant source shapes.
|
|
219
|
+
|
|
220
|
+
The function is pure (no filesystem access, no bus calls) so it can be
|
|
221
|
+
unit-tested independently of the component lifecycle.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
# State can live at the top level (direct identity events from the DO)
|
|
225
|
+
# or nested under ``payload`` (synthetic state_sync events from the MCP
|
|
226
|
+
# fallback subscriber). Try both.
|
|
227
|
+
sources: list[dict[str, Any]] = []
|
|
228
|
+
payload = event.get("payload")
|
|
229
|
+
if isinstance(payload, dict):
|
|
230
|
+
sources.append(payload)
|
|
231
|
+
sources.append(event)
|
|
232
|
+
|
|
233
|
+
def pick(field: str) -> str:
|
|
234
|
+
for src in sources:
|
|
235
|
+
value = src.get(field)
|
|
236
|
+
if value is not None and value != "":
|
|
237
|
+
return _stringify(value)
|
|
238
|
+
return ""
|
|
239
|
+
|
|
240
|
+
handle = _strip_tilde(pick("handle"))
|
|
241
|
+
# ``level`` - accept the string label "L3" or an integer engagement_level
|
|
242
|
+
level = _normalise_level(pick("level"))
|
|
243
|
+
if not level:
|
|
244
|
+
engagement = ""
|
|
245
|
+
for src in sources:
|
|
246
|
+
engagement = src.get("engagement_level") or engagement
|
|
247
|
+
if engagement:
|
|
248
|
+
level = _normalise_level(engagement)
|
|
249
|
+
if not level:
|
|
250
|
+
tier = ""
|
|
251
|
+
for src in sources:
|
|
252
|
+
tier = src.get("consent_tier") or tier
|
|
253
|
+
if tier:
|
|
254
|
+
level = _normalise_level(tier)
|
|
255
|
+
|
|
256
|
+
attunement = pick("attunement")
|
|
257
|
+
if not attunement:
|
|
258
|
+
for src in sources:
|
|
259
|
+
value = src.get("attunement_score")
|
|
260
|
+
if value is not None and value != "":
|
|
261
|
+
attunement = _stringify(value)
|
|
262
|
+
break
|
|
263
|
+
|
|
264
|
+
income = pick("income")
|
|
265
|
+
if not income:
|
|
266
|
+
for src in sources:
|
|
267
|
+
value = src.get("total_earnings") or src.get("identity_income")
|
|
268
|
+
if value is not None and value != "":
|
|
269
|
+
income = _stringify(value)
|
|
270
|
+
break
|
|
271
|
+
|
|
272
|
+
# Empty projection - nothing useful to cache.
|
|
273
|
+
if not any((handle, level, attunement, income)):
|
|
274
|
+
return {}
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
"handle": handle,
|
|
278
|
+
"level": level,
|
|
279
|
+
"attunement": attunement,
|
|
280
|
+
"income": income,
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _stringify(value: Any) -> str:
|
|
285
|
+
"""Convert a Python value into the string form the shell script expects."""
|
|
286
|
+
if isinstance(value, bool):
|
|
287
|
+
return "true" if value else "false"
|
|
288
|
+
if isinstance(value, (int, float)):
|
|
289
|
+
return str(value)
|
|
290
|
+
if isinstance(value, str):
|
|
291
|
+
return value
|
|
292
|
+
# dicts / lists don't fit the shell schema - coerce to JSON and let the
|
|
293
|
+
# shell script decide whether to render them.
|
|
294
|
+
return json.dumps(value, separators=(",", ":"), ensure_ascii=False)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _normalise_level(value: Any) -> str:
|
|
298
|
+
"""Normalise any engagement-level representation to ``"1".."4"``.
|
|
299
|
+
|
|
300
|
+
Accepts integers (``1``), strings (``"L3"`` or ``"3"``), or labels
|
|
301
|
+
(``"augmented"``). Returns the bare numeric tier as a string so the
|
|
302
|
+
cache matches the raw shape ``scripts/alter-identity.sh`` already
|
|
303
|
+
expects (the shell script prepends ``L`` at render time). Unknown
|
|
304
|
+
inputs become an empty string.
|
|
305
|
+
"""
|
|
306
|
+
if isinstance(value, bool):
|
|
307
|
+
return ""
|
|
308
|
+
if isinstance(value, int):
|
|
309
|
+
if 1 <= value <= 4:
|
|
310
|
+
return str(value)
|
|
311
|
+
return ""
|
|
312
|
+
if isinstance(value, str):
|
|
313
|
+
stripped = value.strip()
|
|
314
|
+
if not stripped:
|
|
315
|
+
return ""
|
|
316
|
+
# "L1".."L4"
|
|
317
|
+
if len(stripped) == 2 and stripped[0] in ("L", "l") and stripped[1].isdigit():
|
|
318
|
+
tier = int(stripped[1])
|
|
319
|
+
if 1 <= tier <= 4:
|
|
320
|
+
return str(tier)
|
|
321
|
+
# Raw numeric "1".."4"
|
|
322
|
+
try:
|
|
323
|
+
tier = int(stripped)
|
|
324
|
+
except ValueError:
|
|
325
|
+
# Known label fallbacks - mirrors the alter-identity types file.
|
|
326
|
+
label_map = {
|
|
327
|
+
"explorer": "1",
|
|
328
|
+
"learner": "2",
|
|
329
|
+
"augmented": "3",
|
|
330
|
+
"deployed": "4",
|
|
331
|
+
}
|
|
332
|
+
return label_map.get(stripped.lower(), "")
|
|
333
|
+
if 1 <= tier <= 4:
|
|
334
|
+
return str(tier)
|
|
335
|
+
return ""
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _strip_tilde(value: str) -> str:
|
|
339
|
+
"""Strip a single leading ``~`` from a handle, if present.
|
|
340
|
+
|
|
341
|
+
The shell script prepends ``~`` at render time, so storing the bare
|
|
342
|
+
handle keeps ``scripts/alter-identity.sh`` rendering correctly without
|
|
343
|
+
any shell changes (see the module docstring for the full rationale).
|
|
344
|
+
"""
|
|
345
|
+
if value.startswith("~"):
|
|
346
|
+
return value[1:]
|
|
347
|
+
return value
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""CeremonyEchoWriter - projects recognition events into a 72h echo state.
|
|
2
|
+
|
|
3
|
+
Wave 2 of *D-CUST-1* (proposed; ratification pending). The CeremonyEchoWriter
|
|
4
|
+
is a long-lived :class:`alter_runtime.daemon.Component` that sits next to
|
|
5
|
+
:class:`InboxWriter` on the SSE bus. Where the InboxWriter projects the full
|
|
6
|
+
inbox JSONL, the CeremonyEchoWriter watches for recognition-class events and
|
|
7
|
+
maintains a tiny single-file state - the most-recent recognition event, its
|
|
8
|
+
declared kind, and the absolute timestamp at which the echo expires (72 h
|
|
9
|
+
after the event).
|
|
10
|
+
|
|
11
|
+
A consumer (``alter room`` in alter-cli, or any future shell-greeting
|
|
12
|
+
renderer) reads ``ceremony-echo.json`` from the runtime data directory at
|
|
13
|
+
each invocation and renders an echo line iff the current time is before the
|
|
14
|
+
expiry. The user cannot author the echo, cannot extend it, cannot dismiss it
|
|
15
|
+
early - "the house noticed" (brief §3 surface 21). The echo is observation,
|
|
16
|
+
not affordance.
|
|
17
|
+
|
|
18
|
+
Filtered content_types
|
|
19
|
+
* ``x-alter-recognition`` - the canonical recognition event
|
|
20
|
+
* ``x-alter-ceremony`` - broader ceremony class (Seat, Mirror, Discovery,
|
|
21
|
+
Accord) that should also surface a 72 h echo
|
|
22
|
+
Both are members of ``ALLOWED_CONTENT_TYPES`` in
|
|
23
|
+
``backend/app/mcp/tools/messaging.py``.
|
|
24
|
+
|
|
25
|
+
The component is designed to swallow and log all errors so a single
|
|
26
|
+
malformed frame, full disk, or transient SSE disconnect cannot crash the
|
|
27
|
+
daemon supervisor. Like InboxWriter, the supervisor will restart on
|
|
28
|
+
:meth:`run` exceptions but :meth:`handle_event` only ever logs and returns.
|
|
29
|
+
|
|
30
|
+
Refs:
|
|
31
|
+
* proposed-D-CUST-1 (alter-internal #140) - surface 21 (ceremony echo)
|
|
32
|
+
* embedded-messenger/05-design-synthesis.md - content_type taxonomy
|
|
33
|
+
* .claude/handovers/2026-04-24-alter-myspace-customisation-m2-and-c-spike.md
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import asyncio
|
|
39
|
+
import contextlib
|
|
40
|
+
import json
|
|
41
|
+
import logging
|
|
42
|
+
import os
|
|
43
|
+
import tempfile
|
|
44
|
+
from datetime import datetime, timedelta, timezone
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from typing import TYPE_CHECKING, Any
|
|
47
|
+
|
|
48
|
+
from alter_runtime.config import DaemonConfig, data_dir
|
|
49
|
+
from alter_runtime.daemon import Component
|
|
50
|
+
from alter_runtime.subscribers.sse import SSEFrame
|
|
51
|
+
|
|
52
|
+
if TYPE_CHECKING:
|
|
53
|
+
from alter_runtime.config import Session
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"CEREMONY_ECHO_FILENAME",
|
|
57
|
+
"DEFAULT_ECHO_DURATION",
|
|
58
|
+
"RECOGNITION_CONTENT_TYPES",
|
|
59
|
+
"CeremonyEchoWriter",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
logger = logging.getLogger("alter_runtime.subscribers.ceremony_echo")
|
|
63
|
+
|
|
64
|
+
#: Filename for the ceremony-echo state (within ``data_dir()``).
|
|
65
|
+
CEREMONY_ECHO_FILENAME: str = "ceremony-echo.json"
|
|
66
|
+
|
|
67
|
+
#: How long an echo remains visible after the event arrives. The brief
|
|
68
|
+
#: locks this at 72 h - long enough to catch the user across multiple
|
|
69
|
+
#: shell sessions and a working week's natural cadence, short enough that
|
|
70
|
+
#: the echo doesn't become wallpaper.
|
|
71
|
+
DEFAULT_ECHO_DURATION: timedelta = timedelta(hours=72)
|
|
72
|
+
|
|
73
|
+
#: Content_type values that produce a ceremony echo. Both are in
|
|
74
|
+
#: ``backend/app/mcp/tools/messaging.py``'s ``ALLOWED_CONTENT_TYPES``
|
|
75
|
+
#: frozenset; if that taxonomy expands, this set should be revisited.
|
|
76
|
+
RECOGNITION_CONTENT_TYPES: frozenset[str] = frozenset(
|
|
77
|
+
{
|
|
78
|
+
"x-alter-recognition",
|
|
79
|
+
"x-alter-ceremony",
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class CeremonyEchoWriter(Component):
|
|
85
|
+
"""Tails the per-handle SSE stream and persists the most-recent
|
|
86
|
+
recognition-class event to ``ceremony-echo.json`` for shell-greeting
|
|
87
|
+
consumers.
|
|
88
|
+
|
|
89
|
+
Parameters
|
|
90
|
+
----------
|
|
91
|
+
config:
|
|
92
|
+
Loaded :class:`DaemonConfig`. Reserved for future use; not consulted
|
|
93
|
+
in the M2 implementation.
|
|
94
|
+
session:
|
|
95
|
+
Authenticated alter-cli session. Used for log context only - the
|
|
96
|
+
bus delivers the per-handle frames, the writer does not need to
|
|
97
|
+
construct an SSE URL itself.
|
|
98
|
+
echo_path:
|
|
99
|
+
Override the state file path. Tests use this to redirect writes
|
|
100
|
+
to a ``tmp_path`` fixture without touching ``$HOME``.
|
|
101
|
+
echo_duration:
|
|
102
|
+
Override the echo-visible window. Tests pass a short duration to
|
|
103
|
+
exercise the expiry path without wall-clock waits.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
name = "ceremony_echo"
|
|
107
|
+
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
config: DaemonConfig,
|
|
111
|
+
session: Session,
|
|
112
|
+
*,
|
|
113
|
+
echo_path: Path | None = None,
|
|
114
|
+
echo_duration: timedelta = DEFAULT_ECHO_DURATION,
|
|
115
|
+
) -> None:
|
|
116
|
+
self._config = config
|
|
117
|
+
self._session = session
|
|
118
|
+
self._echo_duration = echo_duration
|
|
119
|
+
|
|
120
|
+
self._echo_path: Path = (
|
|
121
|
+
echo_path if echo_path is not None else data_dir() / CEREMONY_ECHO_FILENAME
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
self._lock = asyncio.Lock()
|
|
125
|
+
self._shutdown_event = asyncio.Event()
|
|
126
|
+
|
|
127
|
+
# ------------------------------------------------------------------
|
|
128
|
+
# Component lifecycle
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
async def run(self) -> None:
|
|
132
|
+
"""Long-lived idle loop. The bus is the actual ingress path -
|
|
133
|
+
:meth:`handle_raw_frame` is the seam the supervisor wires into
|
|
134
|
+
``identity.frame``. We just block on shutdown so the supervisor
|
|
135
|
+
can register us as a peer of InboxWriter without opening any
|
|
136
|
+
sockets of our own.
|
|
137
|
+
"""
|
|
138
|
+
logger.info(
|
|
139
|
+
"ceremony_echo started handle=%s echo_path=%s",
|
|
140
|
+
self._session.handle,
|
|
141
|
+
self._echo_path,
|
|
142
|
+
)
|
|
143
|
+
try:
|
|
144
|
+
await self._shutdown_event.wait()
|
|
145
|
+
except asyncio.CancelledError:
|
|
146
|
+
raise
|
|
147
|
+
finally:
|
|
148
|
+
logger.info("ceremony_echo stopped handle=%s", self._session.handle)
|
|
149
|
+
|
|
150
|
+
async def stop(self) -> None:
|
|
151
|
+
"""Cooperative shutdown - release the run loop."""
|
|
152
|
+
self._shutdown_event.set()
|
|
153
|
+
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
# Frame ingest - public surface for the SSE bus and tests
|
|
156
|
+
# ------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
async def handle_raw_frame(self, frame: SSEFrame) -> None:
|
|
159
|
+
"""Parse an SSE frame and dispatch to :meth:`handle_event`.
|
|
160
|
+
|
|
161
|
+
Errors are logged and swallowed - the supervisor never sees a
|
|
162
|
+
write failure (per the project convention "swallow and continue").
|
|
163
|
+
"""
|
|
164
|
+
try:
|
|
165
|
+
payload = frame.json
|
|
166
|
+
except (ValueError, json.JSONDecodeError) as exc:
|
|
167
|
+
logger.warning("ceremony_echo: malformed SSE frame body: %s", exc)
|
|
168
|
+
return
|
|
169
|
+
if not isinstance(payload, dict):
|
|
170
|
+
logger.warning("ceremony_echo: SSE frame payload is not a dict: %r", type(payload))
|
|
171
|
+
return
|
|
172
|
+
await self.handle_event(payload)
|
|
173
|
+
|
|
174
|
+
async def handle_event(self, event: dict[str, Any]) -> None:
|
|
175
|
+
"""Project a single parsed IdentityEvent dict.
|
|
176
|
+
|
|
177
|
+
This is the unit-test seam - the test suite calls this directly
|
|
178
|
+
with synthesised dicts to avoid having to drive a real SSE socket.
|
|
179
|
+
|
|
180
|
+
We accept ``alter_message`` events whose payload's ``content_type``
|
|
181
|
+
is in :data:`RECOGNITION_CONTENT_TYPES`. Other kinds and other
|
|
182
|
+
content_types are silently dropped (the InboxWriter handles the
|
|
183
|
+
full inbox projection - we only care about the echo subset).
|
|
184
|
+
"""
|
|
185
|
+
# ---- 1. Filter on kind ---------------------------------------
|
|
186
|
+
if event.get("kind") != "alter_message":
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
# The DO emits the IdentityEvent envelope with the message
|
|
190
|
+
# payload either at the top level or nested under ``payload``.
|
|
191
|
+
# Tolerate both shapes during the wire-contract rollout - same
|
|
192
|
+
# tolerance pattern as InboxWriter.
|
|
193
|
+
body = event.get("payload") if isinstance(event.get("payload"), dict) else event
|
|
194
|
+
|
|
195
|
+
content_type = body.get("content_type")
|
|
196
|
+
if content_type not in RECOGNITION_CONTENT_TYPES:
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
# ---- 2. Extract the echo-state fields ------------------------
|
|
200
|
+
sender = body.get("sender_handle") or body.get("sender")
|
|
201
|
+
body_md = body.get("body_md")
|
|
202
|
+
message_id = event.get("id") or body.get("id")
|
|
203
|
+
received_at = (
|
|
204
|
+
event.get("timestamp") or body.get("sent_at") or datetime.now(timezone.utc).isoformat()
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
if not sender:
|
|
208
|
+
logger.warning(
|
|
209
|
+
"ceremony_echo: %s frame missing sender - dropping (id=%r)",
|
|
210
|
+
content_type,
|
|
211
|
+
message_id,
|
|
212
|
+
)
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
# ---- 3. Compute expiry --------------------------------------
|
|
216
|
+
# Anchor the expiry on the event's own timestamp where possible
|
|
217
|
+
# so a delayed delivery does not extend the user-visible window.
|
|
218
|
+
try:
|
|
219
|
+
event_time = datetime.fromisoformat(received_at.replace("Z", "+00:00"))
|
|
220
|
+
except (ValueError, AttributeError):
|
|
221
|
+
event_time = datetime.now(timezone.utc)
|
|
222
|
+
expires_at = (event_time + self._echo_duration).isoformat()
|
|
223
|
+
|
|
224
|
+
# ---- 4. Persist atomically ----------------------------------
|
|
225
|
+
state = {
|
|
226
|
+
"schema_version": 1,
|
|
227
|
+
"last_recognition": {
|
|
228
|
+
"sender": sender,
|
|
229
|
+
"kind": content_type,
|
|
230
|
+
"body_md": str(body_md) if body_md is not None else "",
|
|
231
|
+
"received_at": received_at,
|
|
232
|
+
},
|
|
233
|
+
"expires_at": expires_at,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async with self._lock:
|
|
237
|
+
try:
|
|
238
|
+
self._write_state(state)
|
|
239
|
+
except OSError as exc:
|
|
240
|
+
logger.warning(
|
|
241
|
+
"ceremony_echo: state write failed: %s - dropping event",
|
|
242
|
+
exc,
|
|
243
|
+
)
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
logger.info(
|
|
247
|
+
"ceremony_echo: persisted %s from %s expires_at=%s",
|
|
248
|
+
content_type,
|
|
249
|
+
sender,
|
|
250
|
+
expires_at,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# ------------------------------------------------------------------
|
|
254
|
+
# Persistence
|
|
255
|
+
# ------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
def _write_state(self, state: dict[str, Any]) -> None:
|
|
258
|
+
"""Atomic-rename a JSON state file with mode 0o600.
|
|
259
|
+
|
|
260
|
+
The temp file is created in the same directory as the target so
|
|
261
|
+
the rename is atomic (cross-device renames are not). Parent
|
|
262
|
+
directory is created with mode 0o700 if absent.
|
|
263
|
+
"""
|
|
264
|
+
target = self._echo_path
|
|
265
|
+
target.parent.mkdir(parents=True, mode=0o700, exist_ok=True)
|
|
266
|
+
|
|
267
|
+
with tempfile.NamedTemporaryFile(
|
|
268
|
+
mode="w",
|
|
269
|
+
dir=str(target.parent),
|
|
270
|
+
prefix=f".{target.name}.",
|
|
271
|
+
suffix=".tmp",
|
|
272
|
+
delete=False,
|
|
273
|
+
) as fh:
|
|
274
|
+
tmp_path = Path(fh.name)
|
|
275
|
+
try:
|
|
276
|
+
json.dump(state, fh, separators=(",", ":"), ensure_ascii=False)
|
|
277
|
+
fh.flush()
|
|
278
|
+
os.fsync(fh.fileno())
|
|
279
|
+
except Exception:
|
|
280
|
+
with contextlib.suppress(OSError):
|
|
281
|
+
tmp_path.unlink()
|
|
282
|
+
raise
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
os.chmod(tmp_path, 0o600)
|
|
286
|
+
os.replace(tmp_path, target)
|
|
287
|
+
except OSError:
|
|
288
|
+
with contextlib.suppress(OSError):
|
|
289
|
+
tmp_path.unlink()
|
|
290
|
+
raise
|