ucomm 0.0.1__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.
ucomm/__init__.py ADDED
@@ -0,0 +1,56 @@
1
+ """ucomm: universal communicator middleware for Ethereum Swarm (working title)."""
2
+
3
+ from .attention import Decision, Intensity, PolicyState, SenderContext, decide
4
+ from .contact import ContactCard, make_contact_card, verify_contact_card
5
+ from .daemon import (
6
+ ChannelDirectory,
7
+ Dashboard,
8
+ DashboardItem,
9
+ DirectoryEntry,
10
+ build_dashboard,
11
+ directory_read_state,
12
+ )
13
+ from .envelope import AttentionClaim, Envelope, EventKind, Genesis, GenesisError, TimeWindow
14
+ from .hints import HintSink, HintSource, InMemoryHints
15
+ from .log import AuthorLog, merge_causal, read_state
16
+ from .rendezvous import InMemoryRendezvous, Rendezvous
17
+ from .signing import InvalidSignature, address_of, sign_envelope, verify_envelope
18
+ from .store import RecordStoreAuthorLog, envelope_to_record, record_to_envelope
19
+
20
+ __all__ = [
21
+ "AttentionClaim",
22
+ "AuthorLog",
23
+ "ChannelDirectory",
24
+ "ContactCard",
25
+ "Dashboard",
26
+ "DashboardItem",
27
+ "Decision",
28
+ "DirectoryEntry",
29
+ "Envelope",
30
+ "EventKind",
31
+ "Genesis",
32
+ "GenesisError",
33
+ "HintSink",
34
+ "HintSource",
35
+ "InMemoryHints",
36
+ "InMemoryRendezvous",
37
+ "Intensity",
38
+ "InvalidSignature",
39
+ "PolicyState",
40
+ "RecordStoreAuthorLog",
41
+ "Rendezvous",
42
+ "SenderContext",
43
+ "TimeWindow",
44
+ "address_of",
45
+ "build_dashboard",
46
+ "decide",
47
+ "directory_read_state",
48
+ "envelope_to_record",
49
+ "make_contact_card",
50
+ "merge_causal",
51
+ "read_state",
52
+ "record_to_envelope",
53
+ "sign_envelope",
54
+ "verify_contact_card",
55
+ "verify_envelope",
56
+ ]
ucomm/attention.py ADDED
@@ -0,0 +1,116 @@
1
+ """Attention layer: priority algebra and policy engine (ATTENTION.md).
2
+
3
+ Pure and deterministic by design: decide(envelope, policy, now) -> Decision is a
4
+ function of its arguments only. All mutable state (ceilings, offsets, thresholds,
5
+ reputation ratchet) lives in PolicyState, owned by the daemon.
6
+
7
+ Receiver sovereignty: this module consumes AttentionClaim data and never
8
+ constructs envelopes. One-way dependency: attention -> envelope, never reverse.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Mapping
14
+ from dataclasses import dataclass, field
15
+ from enum import IntEnum
16
+
17
+ from .envelope import Envelope, EventKind, PubKey
18
+
19
+
20
+ class Intensity(IntEnum):
21
+ """Graded notification output (ATTENTION.md section 3)."""
22
+
23
+ FILED = 0 # silently filed; dashboard history only
24
+ BADGE = 1 # badge / dashboard, no interruption
25
+ SOFT = 2 # ring once / vibrate only
26
+ FULL = 3 # full alert
27
+ BREAKTHROUGH = 4 # overrides DND
28
+
29
+
30
+ # Residual thresholds for grading, in log-scale priority units.
31
+ SOFT_BAND = 5 # |residual| <= SOFT_BAND counts as "near threshold"
32
+ BREAKTHROUGH_MARGIN = 20
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class PolicyState:
37
+ """Receiver-local policy snapshot. Never published, never negotiated."""
38
+
39
+ threshold: int # current global threshold; DND = high value
40
+ default_ceiling_known: int = 40
41
+ default_ceiling_stranger: int = 0
42
+ wot_ceiling_slope: int = 10 # ceiling = base - slope * wot_distance (ATTENTION 5)
43
+ contact_ceilings: Mapping[PubKey, int] = field(default_factory=dict)
44
+ channel_offsets: Mapping[str, int] = field(default_factory=dict)
45
+ endpoint_offset: int = 0 # e.g. raised while in an interactive session
46
+ bond_credit: int = 10 # credibility bonus when valid collateral attached
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class SenderContext:
51
+ """What the receiver knows about the sender (from identity/WoT + recsys)."""
52
+
53
+ known_contact: bool
54
+ wot_distance: int | None = None # None = no path
55
+ recsys_prior: int = 0 # advisory prior from RECOMMENDATION.md section 6
56
+ collateral_valid: bool = False
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class Decision:
61
+ intensity: Intensity
62
+ effective_priority: int
63
+ residual: int
64
+ ceiling_applied: int
65
+
66
+
67
+ def ceiling_for(sender: PubKey, ctx: SenderContext, policy: PolicyState) -> int:
68
+ """Per-sender priority ceiling (ATTENTION.md sections 2, 5)."""
69
+ if sender in policy.contact_ceilings:
70
+ return policy.contact_ceilings[sender]
71
+ if ctx.known_contact:
72
+ return policy.default_ceiling_known
73
+ if ctx.wot_distance is not None:
74
+ base = policy.default_ceiling_known
75
+ return max(
76
+ policy.default_ceiling_stranger,
77
+ base - policy.wot_ceiling_slope * ctx.wot_distance + ctx.recsys_prior,
78
+ )
79
+ return policy.default_ceiling_stranger + ctx.recsys_prior
80
+
81
+
82
+ def decide(env: Envelope, ctx: SenderContext, policy: PolicyState, now: float) -> Decision:
83
+ """Map one control-plane envelope to a graded notification intensity.
84
+
85
+ Pure function: (envelope, sender context, policy state, clock) -> Decision.
86
+ """
87
+ if env.kind is not EventKind.INVITATION or env.attention is None:
88
+ return Decision(Intensity.FILED, 0, -(10**6), 0)
89
+
90
+ claim = env.attention
91
+ if not claim.relevance.active(now):
92
+ return Decision(Intensity.FILED, 0, -(10**6), 0) # expired -> timeline
93
+
94
+ cap = ceiling_for(env.author, ctx, policy)
95
+ credibility = policy.bond_credit if ctx.collateral_valid else 0
96
+
97
+ effective = (
98
+ min(claim.claimed_priority, cap)
99
+ + policy.channel_offsets.get(env.channel, 0)
100
+ + policy.endpoint_offset
101
+ + credibility
102
+ )
103
+ residual = effective - policy.threshold
104
+
105
+ if residual > BREAKTHROUGH_MARGIN:
106
+ intensity = Intensity.BREAKTHROUGH
107
+ elif residual > SOFT_BAND:
108
+ intensity = Intensity.FULL
109
+ elif residual > 0:
110
+ intensity = Intensity.SOFT
111
+ elif residual > -SOFT_BAND:
112
+ intensity = Intensity.BADGE
113
+ else:
114
+ intensity = Intensity.FILED
115
+
116
+ return Decision(intensity, effective, residual, cap)
ucomm/bee.py ADDED
@@ -0,0 +1,51 @@
1
+ """Real Swarm-backed author logs (M1).
2
+
3
+ Thin adapter from `RecordStoreAuthorLog` (`ucomm.store`) to recordstore's Bee
4
+ backends -- `BeeBytesStore` + `SwarmFeedPointer` -- instead of the in-memory
5
+ ones used for M0 prototyping. This is the "drop-in swap, not a rewrite"
6
+ ROADMAP.md promised: `RecordStoreAuthorLog` only ever needed a
7
+ `recordstore.RecordStore`; it never knew or cared whether that store was
8
+ backed by memory or a live Bee node.
9
+
10
+ Feeds need an IMMUTABLE postage batch (see CLAUDE.md, "Swarm facts to
11
+ respect"). This module never buys one -- pass an existing usable batch id
12
+ (`GET /stamps` on the target node), bought e.g. with
13
+ `POST /stamps/{amount}/{depth}?immutable=true` or swarmfs's `StampManager`.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from recordstore import BeeBytesStore, RecordStore, SwarmFeedPointer
19
+
20
+ from .envelope import ChannelId, PubKey
21
+ from .signing import address_of
22
+ from .store import RecordStoreAuthorLog
23
+
24
+
25
+ def feed_topic(channel: ChannelId, author: PubKey) -> str:
26
+ """Feed topic namespacing one author's per-channel log."""
27
+ return f"ucomm/log/{channel}/{author}"
28
+
29
+
30
+ def open_author_feed_log(
31
+ api_url: str, channel: ChannelId, *, signer_hex: str, postage_batch_id: str,
32
+ ) -> tuple[RecordStore, RecordStoreAuthorLog]:
33
+ """Open `signer_hex`'s own log for `channel`, backed by a real Swarm feed.
34
+
35
+ `signer_hex` is the author's private key (hex); the feed owner (and
36
+ hence `Envelope.author`, via `ucomm.signing.address_of`) is derived from
37
+ it. Returns `(store, log)`: append through `log`, then call
38
+ `store.commit()` to actually publish -- appends are staged locally until
39
+ then, same as any other `RecordStore` (issue K-4's batching model, not a
40
+ new one for Bee).
41
+ """
42
+ from bee.swarm.keys import PrivateKey
43
+
44
+ author: PubKey = address_of(PrivateKey.from_hex(signer_hex))
45
+ pointer = SwarmFeedPointer(
46
+ api_url, feed_topic(channel, author),
47
+ signer=signer_hex, postage_batch_id=postage_batch_id,
48
+ )
49
+ bytes_store = BeeBytesStore(api_url, postage_batch_id=postage_batch_id)
50
+ store = RecordStore(bytes_store, pointer=pointer)
51
+ return store, RecordStoreAuthorLog(store, channel, author)
@@ -0,0 +1,8 @@
1
+ """Bridges: adapters converting external protocols into ucomm envelopes.
2
+
3
+ DESIGN.md section 10: every bridge is an inbox adapter into the same
4
+ envelope schema and policy engine ucomm uses natively -- the "attention
5
+ firewall" is a unified inbox across everything, not just Swarm-native
6
+ channels. Kept out of the top-level `ucomm` namespace, same reasoning as
7
+ `ucomm.profiles`: a layer above the kernel, not part of it.
8
+ """
ucomm/bridges/imap.py ADDED
@@ -0,0 +1,215 @@
1
+ """IMAP bridge: envelope adapter for email (issue D-5).
2
+
3
+ DESIGN.md section 10: bridges convert an external protocol's events into
4
+ the same envelope schema and policy engine ucomm uses natively, so the
5
+ attention firewall covers inboxes that already exist. This module has two
6
+ layers:
7
+
8
+ - **Pure conversion** (`envelope_from_email`, `invitation_for_email`,
9
+ `bridge_author`): raw email -> envelope(s), fully offline and
10
+ unit-testable with synthetic messages (stdlib `email` objects) -- no
11
+ network. This is the part with real test coverage against arbitrary
12
+ input.
13
+ - **Live fetch** (`ImapMailbox`): connects to a real mailbox via
14
+ `IMAPClient` (per CLAUDE.md's "use established libraries, not raw
15
+ `imaplib`" convention) and calls the pure layer per message. This part
16
+ is only unit-tested against a fake client double -- it hasn't been
17
+ verified against a real IMAP server yet (no test mailbox available when
18
+ this was written), the same caveat `ucomm.bee` had before its first live
19
+ Bee run.
20
+
21
+ Bridged authorship is NOT the same guarantee as a native ucomm signature.
22
+ An email's `From:` address is only as trustworthy as SMTP/DKIM made it --
23
+ ucomm doesn't re-verify that. Bridged envelopes are left unsigned
24
+ (`sig=""`); `verify_envelope` on one is always `False`, honestly, never
25
+ silently treated as verified.
26
+
27
+ Every bridged email gets a paired `INVITATION` (DESIGN.md section 3.2, two
28
+ planes -- the message body is content, the invitation is a small pointer
29
+ to it, `refs`-only, no payload duplication) with a naive default
30
+ `AttentionClaim`. That claim is exactly as advisory as any sender's claim
31
+ ever is (CLAUDE.md invariant 1): the receiver's own policy engine decides
32
+ how loud a bridged email actually is, not the quality of this default.
33
+
34
+ This bridge is read-only by design (IMAP fetch only, `select_folder(...,
35
+ readonly=True)`) -- it ingests existing mail into the unified dashboard,
36
+ it does not send. Composing/replying would need SMTP, a distinct and
37
+ separate capability this module doesn't provide.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import hashlib
43
+ from email import message_from_bytes
44
+ from email.message import Message
45
+ from email.utils import parsedate_to_datetime
46
+ from typing import cast
47
+
48
+ from imapclient import IMAPClient
49
+
50
+ from ..envelope import (
51
+ AttentionClaim,
52
+ ChannelId,
53
+ Envelope,
54
+ EventHash,
55
+ EventKind,
56
+ MediaDescriptor,
57
+ PubKey,
58
+ TimeWindow,
59
+ )
60
+
61
+ _BRIDGE_AUTHOR_PREFIX = "bridge:imap:"
62
+
63
+ DEFAULT_RELEVANCE_SECONDS = 7 * 24 * 3600 # a week: mail is async, not urgent by default
64
+
65
+
66
+ def bridge_author(email_address: str) -> PubKey:
67
+ """A deterministic, clearly-namespaced pseudo-author for a bridged sender.
68
+
69
+ Not a real signing key's address -- visibly different in shape (a
70
+ `bridge:imap:` prefix) so it can never be confused with, or collide
71
+ with, one. Bridged envelopes are unsigned; nothing should treat this
72
+ as an authenticated identity.
73
+ """
74
+ digest = hashlib.sha256(email_address.strip().lower().encode("utf-8")).hexdigest()
75
+ return f"{_BRIDGE_AUTHOR_PREFIX}{digest}"
76
+
77
+
78
+ def _extract_body(msg: Message) -> tuple[str, str]:
79
+ """Return (mime type, text) for the best available non-attachment part.
80
+
81
+ Prefers text/plain over text/html, matching the mail profile's
82
+ text-only media declaration; either can be added properly (real
83
+ multipart/attachment media kinds) once a profile needs it.
84
+ """
85
+ if msg.is_multipart():
86
+ for wanted in ("text/plain", "text/html"):
87
+ for part in msg.walk():
88
+ if part.get_content_type() == wanted and not part.get_filename():
89
+ # get_payload(decode=True) always returns bytes|None here;
90
+ # the stub's wider return type doesn't narrow on `decode`.
91
+ payload = cast("bytes | None", part.get_payload(decode=True)) or b""
92
+ charset = part.get_content_charset() or "utf-8"
93
+ return wanted, payload.decode(charset, errors="replace")
94
+ return "text/plain", ""
95
+ payload = cast("bytes | None", msg.get_payload(decode=True)) or b""
96
+ charset = msg.get_content_charset() or "utf-8"
97
+ return msg.get_content_type() or "text/plain", payload.decode(charset, errors="replace")
98
+
99
+
100
+ def _received_at(msg: Message, *, default: float) -> float:
101
+ date_header = msg.get("Date")
102
+ if date_header:
103
+ try:
104
+ return parsedate_to_datetime(date_header).timestamp()
105
+ except (TypeError, ValueError):
106
+ pass
107
+ return default
108
+
109
+
110
+ def envelope_from_email(
111
+ msg: Message, channel: ChannelId, seq: int, refs: tuple[EventHash, ...] = (),
112
+ ) -> Envelope:
113
+ """Convert one email into a `MESSAGE` envelope. Pure; no network."""
114
+ sender = msg.get("From", "")
115
+ mime, body = _extract_body(msg)
116
+ return Envelope(
117
+ channel=channel, author=bridge_author(sender), seq=seq,
118
+ kind=EventKind.MESSAGE, refs=refs,
119
+ media=MediaDescriptor(mime=mime), inline=body.encode("utf-8"),
120
+ )
121
+
122
+
123
+ def invitation_for_email(
124
+ msg: Message, channel: ChannelId, seq: int, message_hash: EventHash, now: float,
125
+ *, importance: int = 5, urgency: int = 5,
126
+ relevance_seconds: float = DEFAULT_RELEVANCE_SECONDS,
127
+ ) -> Envelope:
128
+ """The paired attention request for a bridged email.
129
+
130
+ `refs=(message_hash,)` only -- a small pointer to the message envelope,
131
+ never the body itself (two planes, CLAUDE.md invariant 3). `now` is the
132
+ fallback "received at" when the email has no parseable `Date:` header.
133
+ """
134
+ sender = msg.get("From", "")
135
+ received = _received_at(msg, default=now)
136
+ return Envelope(
137
+ channel=channel, author=bridge_author(sender), seq=seq,
138
+ kind=EventKind.INVITATION, refs=(message_hash,),
139
+ attention=AttentionClaim(
140
+ importance=importance, urgency=urgency,
141
+ relevance=TimeWindow(start=received, end=received + relevance_seconds),
142
+ ),
143
+ )
144
+
145
+
146
+ class ImapMailbox:
147
+ """Live IMAP fetch loop: pulls new mail via `IMAPClient` and converts
148
+ each message with the pure functions above.
149
+
150
+ Uses UIDs, not sequence numbers, to track what's new -- UIDs are
151
+ stable across sessions per IMAP semantics, sequence numbers aren't.
152
+ Fetch state (last-seen UID, next `seq` per bridged author) lives on
153
+ this object, in-process only; persisting it across restarts (so a
154
+ fresh run doesn't re-fetch everything) is separate, not-yet-built
155
+ work -- the same "in-memory first" step `AuthorLog` went through
156
+ before `RecordStoreAuthorLog`.
157
+ """
158
+
159
+ def __init__(self, client: IMAPClient, channel: ChannelId) -> None:
160
+ self._client = client
161
+ self.channel = channel
162
+ self._last_uid: int | None = None
163
+ self._next_seq: dict[PubKey, int] = {}
164
+
165
+ @classmethod
166
+ def connect(
167
+ cls, host: str, username: str, password: str, channel: ChannelId,
168
+ *, folder: str = "INBOX", port: int | None = None, ssl: bool = True,
169
+ ) -> ImapMailbox:
170
+ """Connect, log in, and select `folder` read-only -- this bridge
171
+ only ever reads (see the module docstring: composing/sending
172
+ would need SMTP, which this doesn't provide)."""
173
+ client = IMAPClient(host, port=port, ssl=ssl, use_uid=True)
174
+ client.login(username, password)
175
+ client.select_folder(folder, readonly=True)
176
+ return cls(client, channel)
177
+
178
+ def close(self) -> None:
179
+ self._client.logout()
180
+
181
+ def fetch_new(self, now: float) -> list[Envelope]:
182
+ """Fetch and convert messages newer than the last call.
183
+
184
+ Advances internal state; safe to call repeatedly as a poll loop.
185
+ Returns a flat `[message, invitation, message, invitation, ...]`
186
+ list, in the shape `ucomm.daemon.build_dashboard`/
187
+ `directory_read_state` already expect as one channel's events.
188
+ """
189
+ uids = self._new_uids()
190
+ if not uids:
191
+ return []
192
+ fetched = self._client.fetch(uids, ["RFC822"])
193
+ envelopes: list[Envelope] = []
194
+ for uid in uids:
195
+ msg = message_from_bytes(fetched[uid][b"RFC822"])
196
+ author = bridge_author(msg.get("From", ""))
197
+ seq = self._next_seq.get(author, 1)
198
+ message_env = envelope_from_email(msg, self.channel, seq)
199
+ invitation = invitation_for_email(
200
+ msg, self.channel, seq + 1, message_env.event_hash(), now,
201
+ )
202
+ envelopes.extend((message_env, invitation))
203
+ self._next_seq[author] = seq + 2
204
+ self._last_uid = uid
205
+ return envelopes
206
+
207
+ def _new_uids(self) -> list[int]:
208
+ if self._last_uid is None:
209
+ return sorted(self._client.search(["ALL"]))
210
+ uids = self._client.search(["UID", f"{self._last_uid + 1}:*"])
211
+ # RFC 3501 section 9: a "n:*" range is defined to include the
212
+ # mailbox's highest UID even when nothing is actually >= n --
213
+ # filter that back out rather than re-processing the top message
214
+ # on every poll that finds nothing new.
215
+ return sorted(uid for uid in uids if uid > self._last_uid)
ucomm/contact.py ADDED
@@ -0,0 +1,83 @@
1
+ """Out-of-band contact exchange (M1).
2
+
3
+ Before any channel exists, two parties need a minimal way to hand each
4
+ other an address they can trust really corresponds to a key someone
5
+ controls, over a transport with no verification of its own -- a pasted
6
+ string, a QR code, a business card. `ContactCard` is that minimum: an
7
+ address plus a self-attestation signature, domain-separated from envelope
8
+ signing so a card can never be replayed as (or forged from) a signed
9
+ envelope. It catches a copy/paste error or an active tamperer on the
10
+ exchange channel before any channel or message exists.
11
+
12
+ This is deliberately NOT the identity-wot library (ROADMAP.md: root keys,
13
+ device delegation, petnames, attestations -- a standalone project tracking
14
+ the ecosystem "Swarm ID" work). No delegation, no petnames (DESIGN.md
15
+ section 7: petnames are local, never part of what's exchanged), no
16
+ rendezvous (that's issue K-5 / milestone M3, for *unsolicited* contact --
17
+ this is for two parties already coordinating out-of-band). Just: prove
18
+ control of an address before anyone builds a channel around it.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass
24
+
25
+ from bee.swarm.errors import BeeError
26
+ from bee.swarm.keys import PrivateKey, verify_signature
27
+ from bee.swarm.typed_bytes import EthAddress, Signature
28
+
29
+ from .envelope import PubKey
30
+ from .signing import address_of
31
+
32
+ _DOMAIN = b"ucomm-contact-card-v1:"
33
+ _STR_PREFIX = "ucomm-contact-v1:"
34
+
35
+
36
+ def _card_bytes(address: PubKey) -> bytes:
37
+ """Domain-separated from `ucomm.signing`'s envelope bytes: an envelope
38
+ signature can never be replayed as a contact card, or vice versa."""
39
+ return _DOMAIN + address.encode("ascii")
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class ContactCard:
44
+ """A self-attested address: proof its holder controls the signing key.
45
+
46
+ Not proof of *who* holds it -- that's for the receiver's own local
47
+ petname/WoT judgment (DESIGN.md section 7), never something a card
48
+ asserts about itself.
49
+ """
50
+
51
+ address: PubKey
52
+ sig: str
53
+
54
+ def to_str(self) -> str:
55
+ """Compact form safe to paste, email, or encode as a QR code."""
56
+ return f"{_STR_PREFIX}{self.address}:{self.sig}"
57
+
58
+ @classmethod
59
+ def from_str(cls, s: str) -> ContactCard:
60
+ if not s.startswith(_STR_PREFIX):
61
+ raise ValueError(f"not a ucomm contact card: {s!r}")
62
+ rest = s[len(_STR_PREFIX):]
63
+ address, sep, sig = rest.partition(":")
64
+ if not sep:
65
+ raise ValueError(f"malformed contact card: {s!r}")
66
+ return cls(address=address, sig=sig)
67
+
68
+
69
+ def make_contact_card(key: PrivateKey) -> ContactCard:
70
+ """Self-attest `key`'s address: proof of control, not proof of identity."""
71
+ address = address_of(key)
72
+ signature = key.sign(_card_bytes(address))
73
+ return ContactCard(address=address, sig=signature.to_hex())
74
+
75
+
76
+ def verify_contact_card(card: ContactCard) -> bool:
77
+ """True iff `card.sig` really proves control of `card.address`."""
78
+ try:
79
+ signature = Signature.from_hex(card.sig)
80
+ expected = EthAddress.from_hex(card.address)
81
+ except BeeError:
82
+ return False
83
+ return verify_signature(signature, _card_bytes(card.address), expected)
ucomm/daemon.py ADDED
@@ -0,0 +1,149 @@
1
+ """Notification daemon core: channel directory + graded dashboard (M2).
2
+
3
+ DESIGN.md section 10: the user keeps a private channel directory (every
4
+ channel they participate in, across all apps, with local per-channel policy
5
+ overrides); one notification daemon per device computes effective priority
6
+ over it and emits a graded dashboard -- active requests ordered by priority
7
+ (including sub-threshold ones, silently filed), plus an obsolete timeline for
8
+ expired ones.
9
+
10
+ Resolves the open question in DESIGN.md section 12: the dashboard is a
11
+ projection, not authoritative state. `build_dashboard` is a pure function of
12
+ (directory, channel events, policy, sender contexts, clock) -- like
13
+ `ucomm.attention.decide`, its output is never stored, only recomputed.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Iterable, Iterator, Mapping
19
+ from dataclasses import dataclass
20
+
21
+ from .attention import Decision, PolicyState, SenderContext, decide
22
+ from .envelope import ChannelId, Envelope, EventHash, EventKind, PubKey
23
+ from .log import merge_causal, read_state
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class DirectoryEntry:
28
+ """One participated channel, with local-only policy overrides.
29
+
30
+ Mute is a large negative `channel_offset`, not a separate mechanism
31
+ (ATTENTION.md section 2 / DESIGN.md section 11) -- so there's no
32
+ `muted` field here to duplicate it.
33
+ """
34
+
35
+ channel: ChannelId
36
+ profile: str | None = None
37
+ channel_offset: int = 0
38
+
39
+
40
+ class ChannelDirectory:
41
+ """Every channel the user participates in, across all apps (DESIGN.md
42
+ section 10). Purely local and private: never signed, never synced --
43
+ unlike the channels it lists, the directory itself has no merge rule,
44
+ because there's exactly one writer, the local user.
45
+ """
46
+
47
+ def __init__(self) -> None:
48
+ self._entries: dict[ChannelId, DirectoryEntry] = {}
49
+
50
+ def add(self, entry: DirectoryEntry) -> None:
51
+ self._entries[entry.channel] = entry
52
+
53
+ def remove(self, channel: ChannelId) -> None:
54
+ self._entries.pop(channel, None)
55
+
56
+ def __iter__(self) -> Iterator[DirectoryEntry]:
57
+ return iter(self._entries.values())
58
+
59
+ def __contains__(self, channel: ChannelId) -> bool:
60
+ return channel in self._entries
61
+
62
+ def __len__(self) -> int:
63
+ return len(self._entries)
64
+
65
+ @property
66
+ def channel_offsets(self) -> dict[ChannelId, int]:
67
+ """Directory-derived offsets, ready to merge into a `PolicyState`."""
68
+ return {e.channel: e.channel_offset for e in self._entries.values()}
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class DashboardItem:
73
+ channel: ChannelId
74
+ envelope: Envelope
75
+ decision: Decision
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class Dashboard:
80
+ """A graded view over active/obsolete attention requests -- a
81
+ projection, never persisted (DESIGN.md section 12)."""
82
+
83
+ active: tuple[DashboardItem, ...] # relevance still active; sorted by
84
+ # effective_priority, highest first
85
+ obsolete: tuple[DashboardItem, ...] # relevance window has passed
86
+
87
+
88
+ def build_dashboard(
89
+ directory: ChannelDirectory,
90
+ channel_events: Mapping[ChannelId, Iterable[Envelope]],
91
+ policy: PolicyState,
92
+ sender_contexts: Mapping[PubKey, SenderContext],
93
+ now: float,
94
+ *,
95
+ default_sender_context: SenderContext | None = None,
96
+ ) -> Dashboard:
97
+ """Compute the dashboard fresh from current state.
98
+
99
+ Never store the result -- call this again next time. It's cheap, and
100
+ recomputing is the only way the dashboard can't drift from the channels
101
+ and policy it's derived from (same discipline as `decide()` itself).
102
+
103
+ Only `INVITATION` envelopes carrying an `AttentionClaim` are dashboard
104
+ material -- ordinary messages aren't "requests" in DESIGN.md section 10's
105
+ sense and are silently skipped, matching `decide()`'s own treatment of
106
+ them (a trivial FILED sentinel) rather than cluttering the timeline.
107
+
108
+ Callers pass unmerged events (e.g. every member's raw log); each
109
+ channel's stream is merged internally, so a duplicate delivery or an
110
+ event reachable through two members' logs is counted once, not twice.
111
+ """
112
+ unknown_ctx = default_sender_context or SenderContext(known_contact=False)
113
+ active: list[DashboardItem] = []
114
+ obsolete: list[DashboardItem] = []
115
+
116
+ for entry in directory:
117
+ for env in merge_causal(channel_events.get(entry.channel, ())):
118
+ if env.kind is not EventKind.INVITATION or env.attention is None:
119
+ continue
120
+ ctx = sender_contexts.get(env.author, unknown_ctx)
121
+ item = DashboardItem(entry.channel, env, decide(env, ctx, policy, now))
122
+ if env.attention.relevance.active(now):
123
+ active.append(item)
124
+ else:
125
+ obsolete.append(item)
126
+
127
+ active.sort(key=lambda item: item.decision.effective_priority, reverse=True)
128
+ return Dashboard(active=tuple(active), obsolete=tuple(obsolete))
129
+
130
+
131
+ def directory_read_state(
132
+ directory: ChannelDirectory,
133
+ channel_events: Mapping[ChannelId, Iterable[Envelope]],
134
+ ) -> dict[ChannelId, dict[PubKey, EventHash]]:
135
+ """Read-state (`ucomm.log.read_state`) for every channel in the
136
+ directory, rolled up into one view (issue D-3).
137
+
138
+ `ucomm.profiles.chat.ChatChannel.read_state` already does this for one
139
+ channel; the daemon needs it across every channel a user is in,
140
+ regardless of profile -- possible without profile-specific code because
141
+ `RECEIPT`'s meaning (`ucomm.log.read_state`'s docstring) is a kernel
142
+ convention, not a chat-profile one. Callers pass unmerged events; each
143
+ channel's stream is merged internally so callers don't have to remember
144
+ to do it themselves.
145
+ """
146
+ return {
147
+ entry.channel: read_state(merge_causal(channel_events.get(entry.channel, ())))
148
+ for entry in directory
149
+ }