ag2-sparrow 0.1.0__tar.gz

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.
@@ -0,0 +1,54 @@
1
+ Metadata-Version: 2.4
2
+ Name: ag2-sparrow
3
+ Version: 0.1.0
4
+ Summary: Transport client that connects a local agent to AG2 Space: long-polls the task gateway for your agent's tasks and posts results back.
5
+ Author: AG2 Space
6
+ License: MIT
7
+ Project-URL: Homepage, https://ag2.space
8
+ Project-URL: Source, https://github.com/sonichi/sutando
9
+ Keywords: ag2,agent,relay,gateway,matrix
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Intended Audience :: Developers
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+
17
+ # ag2-sparrow
18
+
19
+ *The AG2 Space task relay client.*
20
+
21
+ Transport client that connects a **local agent** to **AG2 Space**.
22
+
23
+ It long-polls the AG2 Space task gateway for *your* agent's tasks (identified by
24
+ its relay token), drops each into a workspace, and posts results back. It carries
25
+ no agent logic — pair it with a worker (e.g. [`agent-connect`](https://github.com/ag2-space/agent-connect))
26
+ that turns each task into an agent run.
27
+
28
+ ## Install
29
+
30
+ ```sh
31
+ pipx install ag2-sparrow # or: pip install ag2-sparrow
32
+ ```
33
+
34
+ ## Run
35
+
36
+ ```sh
37
+ REMOTE_TASK_TOKEN=<your relay token from the AG2 Space Agent Portal> \
38
+ REMOTE_TASK_URL=https://chat.ag2.space/relay \
39
+ ag2-sparrow
40
+ ```
41
+
42
+ The token is your AG2 Space **identity** — not a model API key. Your agent runs
43
+ locally with its own credentials; only tasks and results flow through AG2 Space.
44
+
45
+ ## Single source of truth
46
+
47
+ These modules are the canonical AG2 Space relay client, kept in lockstep with
48
+ [`sonichi/sutando`](https://github.com/sonichi/sutando) `src/`. They are copied
49
+ verbatim (never forked) via `tools/sync_from_src.py`; a CI drift-check fails if
50
+ the package diverges from the source. Zero third-party runtime dependencies.
51
+
52
+ ## License
53
+
54
+ MIT
@@ -0,0 +1,38 @@
1
+ # ag2-sparrow
2
+
3
+ *The AG2 Space task relay client.*
4
+
5
+ Transport client that connects a **local agent** to **AG2 Space**.
6
+
7
+ It long-polls the AG2 Space task gateway for *your* agent's tasks (identified by
8
+ its relay token), drops each into a workspace, and posts results back. It carries
9
+ no agent logic — pair it with a worker (e.g. [`agent-connect`](https://github.com/ag2-space/agent-connect))
10
+ that turns each task into an agent run.
11
+
12
+ ## Install
13
+
14
+ ```sh
15
+ pipx install ag2-sparrow # or: pip install ag2-sparrow
16
+ ```
17
+
18
+ ## Run
19
+
20
+ ```sh
21
+ REMOTE_TASK_TOKEN=<your relay token from the AG2 Space Agent Portal> \
22
+ REMOTE_TASK_URL=https://chat.ag2.space/relay \
23
+ ag2-sparrow
24
+ ```
25
+
26
+ The token is your AG2 Space **identity** — not a model API key. Your agent runs
27
+ locally with its own credentials; only tasks and results flow through AG2 Space.
28
+
29
+ ## Single source of truth
30
+
31
+ These modules are the canonical AG2 Space relay client, kept in lockstep with
32
+ [`sonichi/sutando`](https://github.com/sonichi/sutando) `src/`. They are copied
33
+ verbatim (never forked) via `tools/sync_from_src.py`; a CI drift-check fails if
34
+ the package diverges from the source. Zero third-party runtime dependencies.
35
+
36
+ ## License
37
+
38
+ MIT
@@ -0,0 +1,12 @@
1
+ """ag2-sparrow — connect a local agent to AG2 Space (the AG2 task relay client).
2
+
3
+ Transport only: long-polls the AG2 Space task gateway for THIS agent's tasks
4
+ (identified by its relay token), drops each into a workspace, and posts results
5
+ back. No agent logic — a worker (e.g. ag2-space/agent-connect) turns each task
6
+ into an agent run.
7
+
8
+ The modules here are the canonical AG2 Space relay client, kept in lockstep with
9
+ sonichi/sutando `src/` via tools/sync_from_src.py (a drift-check test fails CI if
10
+ they diverge — single source of truth, no hand-maintained fork).
11
+ """
12
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from .remote_gateway_bridge import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,495 @@
1
+ """
2
+ Local Task Protocol — read-side reference implementation.
3
+
4
+ Interaction-planes refactor, step 3 (read side). The durable local execution
5
+ boundary: this module names the schema of `tasks/*.txt` files and provides the
6
+ canonical pure functions for reading them. It consolidates parsing that today
7
+ is hand-rolled per consumer (task_priority.py, task-bridge's `_isVoiceTask`,
8
+ each bridge's header scan) so new code imports ONE definition. Writers are
9
+ deliberately untouched in this phase — the write-side switch happens per
10
+ bridge, later, with byte-identical golden tests.
11
+
12
+ The result-body half of the protocol already lives in `src/result_markers.py`
13
+ (#873) and stays there; this module is the TASK-file half plus shared schema
14
+ constants.
15
+
16
+ R1 invariant (design doc §6): everything here is stdlib-only, no network, no
17
+ daemon, no lock service — the last-resort producers (health-check --emit-task,
18
+ Sutando.app context-drop) must keep working under total daemon death, and the
19
+ future write side of this module inherits that constraint.
20
+
21
+ ## The two header shapes (and why there are two parsers)
22
+
23
+ Producers serialize headers in two shapes with three distinct trust
24
+ mechanisms — established by surveying 3.4k archived task files plus every
25
+ live writer (2026-07-06):
26
+
27
+ **task-last** (task-bridge.ts chat/voice/context-drop, agent-api `/task`):
28
+ every header precedes the `task:` line; the body after it is untrusted
29
+ multi-line content. Ordering IS the trust mechanism — consumers stop
30
+ header-scanning at the first `task:` line, or a body containing
31
+ `\naccess_tier: owner` forges headers (the PR #982 injection, re-flagged in
32
+ #1035). Use `parse_task_headers()`.
33
+
34
+ **task-mid** (all Python bridges: discord/slack/telegram/gateway, plus
35
+ health-check and github-webhook): `task:` lands mid-file and real headers
36
+ (source, channel_id, …) follow it. Safe only because the writer neutralizes
37
+ the body by one of two mechanisms: newline-stripping every value
38
+ (`_one_line` in the gateway, the sanitizer in github-webhook) or defanging
39
+ header-like body lines with a ZWSP prefix (`task_body_guard.
40
+ confine_user_content` in discord/telegram). Use
41
+ `parse_task_headers_trusted()` — full scan, LAST occurrence wins, which the
42
+ gateway's tier defense depends on (its locally-decided `access_tier` is
43
+ written last to beat anything the remote side claimed).
44
+
45
+ Pick the parser by writer, never by sniffing content. The safe default for
46
+ a file of unknown provenance is `parse_task_headers()` — it under-reads
47
+ task-mid files rather than over-trusting task-last ones. That under-read is
48
+ real: `parse_priority_from_text` (stop-at-`task:`) has never seen a
49
+ bridge-written `priority:` field. The write-side phase converges writers on
50
+ task-last; until then both parsers exist and are named for their trust model.
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ import json
56
+ import re
57
+ from dataclasses import dataclass, field
58
+ from pathlib import Path
59
+ from typing import Iterable
60
+
61
+ # ── Schema constants ─────────────────────────────────────────────────────────
62
+
63
+ # Interaction-plane vocabulary (step 1, PR #1953). Producers stamp exactly one
64
+ # of these next to `source:`; the remote-gateway bridge whitelists inbound
65
+ # values against this set (unknown → "message").
66
+ INTERACTION_TYPES = frozenset({
67
+ "message", "realtime_audio", "realtime_video",
68
+ "tool_initiated", "system_event", "self_reflective",
69
+ })
70
+
71
+ # Content-modality vocabulary (interaction-model 4D, step 1.5). The modalities
72
+ # a task's payload spans — orthogonal to interaction_type (which names the
73
+ # *mode*, not the media). A text-only DM is {"text"}; a photo with a caption is
74
+ # {"text", "image"}; a voice note is {"audio"}. Producers stamp a comma-joined
75
+ # subset next to `interaction_type`; readers whitelist against this set
76
+ # (unknown token dropped), the same discipline as the interaction_type gateway
77
+ # whitelist.
78
+ CONTENT_MODALITIES = frozenset({"text", "image", "audio", "video", "file"})
79
+
80
+ # Media-form vocabulary — the load-bearing routing axis of the 4D model:
81
+ # `attachment` = a discrete, already-bounded object delivered alongside a
82
+ # message (a photo, a PDF, a voice note) → lands in the task file as an
83
+ # AttachmentRef the Core fetches + analyzes. `live_stream` = a continuous
84
+ # real-time media plane (a call's audio, a screen share) → does NOT belong in
85
+ # a task file; it is owned by the LiveAgentRuntime. A missing header defaults
86
+ # to `attachment`: messaging is the default plane, and `live_stream` is the
87
+ # explicit opt-out that routes a payload away from the task-file path.
88
+ MEDIA_FORMS = frozenset({"attachment", "live_stream"})
89
+
90
+ # Task priority enum (src/task_priority.py is the behavior owner; these are
91
+ # the schema names). Consumer semantics: highest first, mtime FIFO tiebreak.
92
+ PRIORITIES = ("urgent", "normal", "low")
93
+
94
+ # Access tiers (CLAUDE.md access-control sections). `owner` is full
95
+ # processing; team/other are sandboxed. A missing header reads as owner for
96
+ # legacy local files — that default belongs to consumers, not this module.
97
+ ACCESS_TIERS = ("owner", "team", "other")
98
+
99
+ # The header vocabulary: every key observed in the real archive corpus
100
+ # (3,401 files, 2026-07-06) plus the live writers' full sets. This list is
101
+ # ENFORCED in two places that must stay in lockstep (Codex P2 on PR #1954):
102
+ # - the parsers below promote ONLY these keys to headers (an unknown
103
+ # `key: value` line stays in the body — so junk like transcript dialogue
104
+ # `Caller: hi` never becomes metadata), and
105
+ # - task_body_guard.confine_user_content defangs exactly these keys in
106
+ # untrusted bodies (it imports this list), so no key a parser would trust
107
+ # can survive undefanged in user-supplied content.
108
+ # Adding a producer header = add it here; the guard follows automatically.
109
+ KNOWN_HEADER_KEYS = (
110
+ "id", "timestamp", "task", "source", "access_tier", "user_id",
111
+ "channel_id", "priority", "interaction_type", "source_message_id",
112
+ "channel_name", "guild_name", "attempts", "sender_name", "room_name",
113
+ "parent_message_id", "reminder", "author_name", "author_id", "chat_id",
114
+ "thread_ts", "reply_to_event", "reply_to_me", "callSid", "caller",
115
+ "from", "call_sid", "hint", "instructions", "transcript",
116
+ # interaction-model 4D, step 1.5 — structured media metadata. Listing them
117
+ # here promotes them to headers AND (via the guard's shared import) defangs
118
+ # them in untrusted bodies, so a forged `attachments:` body line can never
119
+ # smuggle a fetch locator past the parser.
120
+ "content_modalities", "media_form", "attachments",
121
+ # Platform-signed metadata pointer (one-line JSON object): a signed
122
+ # reference to the delivering platform's canonical agent operating card,
123
+ # verifiable offline-of-the-task against the platform's well-known key
124
+ # (skills/agent-room-ops/verify_platform_card.py). Header status means a
125
+ # trusted bridge wrote it; the guard defangs a forged `platform_card:`
126
+ # body line the same as `attachments:`.
127
+ "platform_card",
128
+ )
129
+ _KNOWN_KEY_SET = frozenset(KNOWN_HEADER_KEYS)
130
+
131
+ # Task-id shape: `task-<slug>` where slug is dash-separated [a-z0-9] segments
132
+ # (task-1783..., task-chat-1783..., task-phone-..., task-summary-...,
133
+ # task-gh-..., task-health-...). Mirrors the gateway bridge's `_valid_tid`
134
+ # defense: ids become filenames, so the charset is the path-traversal guard.
135
+ TASK_ID_RE = re.compile(r"^task-[A-Za-z0-9][A-Za-z0-9-]{0,120}$")
136
+
137
+
138
+ def valid_task_id(tid: str) -> bool:
139
+ """True iff `tid` is a well-formed task id, safe to embed in a filename.
140
+
141
+ Rejects path separators, dots, whitespace, and empty/oversized ids — the
142
+ id is used as `tasks/<tid>.txt` and `results/<tid>.txt`, so this is the
143
+ single traversal gate for readers that accept ids from message content
144
+ (e.g. `[deduped: <tid>]` holders).
145
+ """
146
+ return bool(TASK_ID_RE.match(tid or ""))
147
+
148
+
149
+ # ── Header parsing ───────────────────────────────────────────────────────────
150
+
151
+ _HEADER_LINE_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*):[ \t]?(.*)$")
152
+
153
+
154
+ @dataclass
155
+ class TaskHeaders:
156
+ """Parsed view of a task file. `headers` preserves the parser's trust
157
+ rule (see module docstring).
158
+
159
+ `body` semantics differ BY PARSER — this is deliberate and load-bearing:
160
+ - `parse_task_headers` (task-last): the full work item — the `task:`
161
+ line's content plus every line after it, verbatim.
162
+ - `parse_task_headers_trusted` / `_lenient` (task-mid): the `task:`
163
+ line's content plus every subsequent NON-vocabulary line. Vocabulary
164
+ header lines (KNOWN_HEADER_KEYS) after `task:` are promoted to
165
+ `headers` and excluded from body; everything else — health-check
166
+ failure bullets, phone transcript dialogue, unknown `key:`-shaped
167
+ lines — stays in body. Every post-`task:` line lands in exactly one
168
+ of headers/body (no silent loss; Codex P2s on PR #1954). For the
169
+ byte-verbatim work item including trailing headers, use `task_body()`.
170
+ """
171
+ headers: dict = field(default_factory=dict)
172
+ body: str = ""
173
+
174
+ def get(self, key: str, default: str | None = None) -> str | None:
175
+ return self.headers.get(key, default)
176
+
177
+
178
+ def task_body(text: str) -> str:
179
+ """The complete work item of a task file, shape-independent: everything
180
+ from the first `task:` line onward, verbatim, with only the `task:`
181
+ prefix stripped from that first line. Never drops a line — for task-mid
182
+ files this includes trailing headers, which is the honest trade: a reader
183
+ that wants clean headers uses the parsers; a reader that wants the full
184
+ work item (health bullets, phone transcript, meeting instructions) uses
185
+ this and must tolerate header lines inside it."""
186
+ lines = text.split("\n")
187
+ for i, line in enumerate(lines):
188
+ if line.startswith("task:"):
189
+ return "\n".join([line[len("task:"):].lstrip()] + lines[i + 1:])
190
+ return ""
191
+
192
+
193
+ def parse_task_headers(text: str) -> TaskHeaders:
194
+ """Parse a **task-last** file: collect `key: value` lines strictly BEFORE
195
+ the first `task:` line; everything from `task:` onward is untrusted body.
196
+
197
+ This is the safe default parser — the delimiter rule that keeps a
198
+ user-supplied body from forging headers (PR #982 / #1035). First
199
+ occurrence of a key wins (a duplicate later is more likely forged than
200
+ corrective). The body includes the `task:` line's own content.
201
+ """
202
+ headers: dict = {}
203
+ body_lines: list[str] = []
204
+ in_body = False
205
+ for line in text.split("\n"):
206
+ if in_body:
207
+ body_lines.append(line)
208
+ continue
209
+ if line.startswith("task:"):
210
+ in_body = True
211
+ body_lines.append(line[len("task:"):].lstrip())
212
+ continue
213
+ m = _HEADER_LINE_RE.match(line)
214
+ if m and m.group(1) in _KNOWN_KEY_SET:
215
+ headers.setdefault(m.group(1), m.group(2))
216
+ # Unknown-key and non-matching lines before task: are tolerated and
217
+ # skipped — real archive files contain blank lines and free-text
218
+ # hint blocks; only vocabulary keys become metadata.
219
+ return TaskHeaders(headers=headers, body="\n".join(body_lines))
220
+
221
+
222
+ def parse_task_headers_lenient(text: str) -> TaskHeaders:
223
+ """Parse across ALL lines, FIRST occurrence of each key wins.
224
+
225
+ The shape-union reader: producers' field order has changed across eras
226
+ (May-2026 voice tasks were task-mid; today's are task-last), so consumers
227
+ that must classify files of any age — e.g. discord-bridge's DM-fallback
228
+ `source:` probe — need a scan that finds headers wherever that era's
229
+ writer put them. First-wins resists trailing-body forgery when the real
230
+ header exists; it does NOT protect a file that legitimately lacks the
231
+ probed key (a body line can then supply it). That spoofability predates
232
+ this module — hardening it means changing verdicts for historical shapes
233
+ and is deliberately out of scope for the read-side refactor.
234
+ """
235
+ return _parse_task_mid(text, last_wins=False)
236
+
237
+
238
+ def parse_task_headers_trusted(text: str) -> TaskHeaders:
239
+ """Parse a **task-mid** file from a trusted writer (remote-gateway-bridge):
240
+ scan ALL lines for `key: value`, LAST occurrence wins.
241
+
242
+ Only valid when the writer is known to newline-strip every value — that
243
+ property is what makes the full scan injection-free, and last-wins is
244
+ load-bearing: the gateway writes its locally-decided `access_tier` last
245
+ precisely so it beats anything the remote side claimed earlier in the
246
+ file. Applying this parser to a task-last file would let the body forge
247
+ headers; pick the parser by writer, not by content sniffing.
248
+ """
249
+ return _parse_task_mid(text, last_wins=True)
250
+
251
+
252
+ def _parse_task_mid(text: str, last_wins: bool) -> TaskHeaders:
253
+ """Shared task-mid scan. Headers = vocabulary keys only
254
+ (KNOWN_HEADER_KEYS); body = the `task:` line's content plus every
255
+ subsequent NON-vocabulary line — so continuation content (health-check
256
+ failure bullets, phone transcript dialogue, skill-hint blocks) is
257
+ preserved losslessly in body while trailing real headers are excluded.
258
+ Every input line after `task:` lands in exactly one of headers/body
259
+ (fidelity is asserted over the full archive corpus in the golden test).
260
+ An unknown `key: value` shape (`Caller: hi`) is body, never metadata."""
261
+ headers: dict = {}
262
+ body_lines: list[str] = []
263
+ in_body = False
264
+ for line in text.split("\n"):
265
+ if not in_body and line.startswith("task:"):
266
+ in_body = True
267
+ body_lines.append(line[len("task:"):].lstrip())
268
+ continue
269
+ m = _HEADER_LINE_RE.match(line)
270
+ if m and m.group(1) in _KNOWN_KEY_SET:
271
+ if last_wins:
272
+ headers[m.group(1)] = m.group(2)
273
+ else:
274
+ headers.setdefault(m.group(1), m.group(2))
275
+ elif in_body:
276
+ body_lines.append(line)
277
+ # Trailing blank lines are file-termination artifacts, not content —
278
+ # interior blank lines are preserved.
279
+ while body_lines and body_lines[-1] == "":
280
+ body_lines.pop()
281
+ return TaskHeaders(headers=headers, body="\n".join(body_lines))
282
+
283
+
284
+ # ── Media attachments (interaction-model 4D, step 1.5) ───────────────────────
285
+
286
+
287
+ @dataclass
288
+ class AttachmentRef:
289
+ """One discrete media object carried by a task, source-independent.
290
+
291
+ The unified descriptor every bridge normalizes its native attachment into
292
+ (Discord CDN url, Slack file, Telegram file_id, Matrix mxc://, or an
293
+ already-downloaded local path) so the Core sees ONE shape regardless of
294
+ surface. `locator` is the source-specific pointer to the bytes; the
295
+ safe-fetch policy (bridge/relay side, a later slice) resolves a remote
296
+ locator to a local path and may rewrite `locator` in place once fetched.
297
+
298
+ Deliberately metadata-only: no bytes, no file handles (R1 stdlib-only, and
299
+ a task file must stay a small text record). `size`/`sha256` let a consumer
300
+ enforce a limit and dedupe *before* fetching. Every field but `locator` is
301
+ optional — a ref with nothing to point at is meaningless, so an element
302
+ lacking a locator is dropped on both encode and decode.
303
+ """
304
+ locator: str
305
+ id: str = ""
306
+ mime: str = ""
307
+ filename: str = ""
308
+ size: int = 0
309
+ sha256: str = ""
310
+ expiry: str = "" # ISO-8601 UTC; "" = no expiry / already-local
311
+
312
+ def as_dict(self) -> dict:
313
+ """Compact dict for JSON serialization — omits empty/zero fields so the
314
+ header line stays small and round-trips back to an equal ref."""
315
+ d: dict = {"locator": self.locator}
316
+ for k in ("id", "mime", "filename", "sha256", "expiry"):
317
+ v = getattr(self, k)
318
+ if v:
319
+ d[k] = v
320
+ if self.size:
321
+ d["size"] = self.size
322
+ return d
323
+
324
+
325
+ def parse_content_modalities(headers: dict) -> frozenset:
326
+ """The whitelisted set of content modalities from a parsed header dict.
327
+
328
+ Reads the comma-joined `content_modalities` value, lower-cases + trims each
329
+ token, and drops anything outside CONTENT_MODALITIES — an unknown token is
330
+ noise, never a new modality (same rule as the interaction_type whitelist).
331
+ Missing/empty → empty set; a text-only task need not stamp `{"text"}`."""
332
+ raw = (headers or {}).get("content_modalities") or ""
333
+ return frozenset(
334
+ t for t in (tok.strip().lower() for tok in raw.split(",")) if t in CONTENT_MODALITIES
335
+ )
336
+
337
+
338
+ def parse_media_form(headers: dict) -> str:
339
+ """The task's media form: `attachment` (default) or `live_stream`.
340
+
341
+ Whitelists the `media_form` value; anything unknown or missing collapses to
342
+ `attachment`, the safe default that routes the payload through the normal
343
+ task-file path rather than mis-claiming a LiveAgentRuntime stream."""
344
+ v = ((headers or {}).get("media_form") or "").strip().lower()
345
+ return v if v in MEDIA_FORMS else "attachment"
346
+
347
+
348
+ def parse_attachments(headers: dict) -> list["AttachmentRef"]:
349
+ """Decode the `attachments:` header — a one-line JSON array of objects —
350
+ into AttachmentRefs.
351
+
352
+ Tolerant by contract: a malformed value, a non-list payload, a non-object
353
+ element, or an element with no `locator` is skipped, never raised. A bad
354
+ attachments header must not block reading the rest of the task (the same
355
+ never-block principle as the audit sink). Missing header → []."""
356
+ raw = (headers or {}).get("attachments")
357
+ if not raw:
358
+ return []
359
+ try:
360
+ arr = json.loads(raw)
361
+ except (ValueError, TypeError):
362
+ return []
363
+ if not isinstance(arr, list):
364
+ return []
365
+ refs: list[AttachmentRef] = []
366
+ for el in arr:
367
+ if not isinstance(el, dict):
368
+ continue
369
+ locator = el.get("locator")
370
+ if not locator or not isinstance(locator, str):
371
+ continue
372
+ size = el.get("size", 0)
373
+ if isinstance(size, bool):
374
+ size = 0 # a JSON bool is not a byte count (int(True) == 1 would lie)
375
+ elif not isinstance(size, int):
376
+ try:
377
+ size = int(size)
378
+ except (ValueError, TypeError):
379
+ size = 0
380
+ if size < 0:
381
+ size = 0 # a negative byte count is nonsense and would slip past a
382
+ # `ref.size and ref.size > max_bytes` cap check — normalize
383
+ # it to 0 (unknown) so limit enforcement stays sound.
384
+ refs.append(AttachmentRef(
385
+ locator=locator,
386
+ id=str(el.get("id", "")),
387
+ mime=str(el.get("mime", "")),
388
+ filename=str(el.get("filename", "")),
389
+ size=size,
390
+ sha256=str(el.get("sha256", "")),
391
+ expiry=str(el.get("expiry", "")),
392
+ ))
393
+ return refs
394
+
395
+
396
+ def format_attachments(refs: Iterable["AttachmentRef"]) -> str:
397
+ """Encode AttachmentRefs into the one-line JSON value of an `attachments:`
398
+ header — the write-side inverse of parse_attachments.
399
+
400
+ Guaranteed single-line (json escapes any embedded newline in a field), so
401
+ it is safe for both task-last single-line values and the task-mid
402
+ newline-stripping writers. Refs without a locator are dropped (they'd be
403
+ unparseable on read, so encoding them would not round-trip)."""
404
+ payload = [r.as_dict() for r in refs if r.locator]
405
+ return json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
406
+
407
+
408
+ def modality_for_mime(mime: str) -> str:
409
+ """Map an attachment MIME type to a content modality (CONTENT_MODALITIES):
410
+ image/audio/video by top-level type, everything else (pdf, zip, docx,
411
+ unknown) → `file`. The shared home for the mapping each bridge needs when it
412
+ stamps `content_modalities` — promoted here once a third bridge required it
413
+ (discord/telegram carried private copies first)."""
414
+ m = (mime or "").lower()
415
+ if m.startswith("image/"):
416
+ return "image"
417
+ if m.startswith("audio/"):
418
+ return "audio"
419
+ if m.startswith("video/"):
420
+ return "video"
421
+ return "file"
422
+
423
+
424
+ def media_attachment_headers(attachment_refs: Iterable["AttachmentRef"], has_text: bool) -> str:
425
+ """Build the `content_modalities`/`media_form`/`attachments` header block a
426
+ bridge stamps on a task file when a message carried attachments (step 1.5).
427
+ Returns "" when there are no refs — so a text-only task's headers are
428
+ unchanged.
429
+
430
+ `content_modalities` = `text` (iff `has_text`) plus one modality per ref
431
+ mime; `media_form` = `attachment` (these are discrete objects — a live
432
+ stream is never emitted through the messaging task path); `attachments` =
433
+ the one-line JSON from `format_attachments`. The block is newline-terminated
434
+ so it composes directly into a writer's header f-string. Pure — a bridge can
435
+ unit-test its write path without a live message object."""
436
+ refs = [r for r in attachment_refs if r.locator]
437
+ if not refs:
438
+ return ""
439
+ mods = set()
440
+ if has_text:
441
+ mods.add("text")
442
+ for r in refs:
443
+ mods.add(modality_for_mime(r.mime))
444
+ return (
445
+ f"content_modalities: {','.join(sorted(mods))}\n"
446
+ f"media_form: attachment\n"
447
+ f"attachments: {format_attachments(refs)}\n"
448
+ )
449
+
450
+
451
+ # ── Archive rules ────────────────────────────────────────────────────────────
452
+
453
+ _MONTH_DIR_RE = re.compile(r"^\d{4}-\d{2}$")
454
+
455
+
456
+ def archive_month_dir(base: Path, iso_timestamp: str) -> Path:
457
+ """Month-partitioned archive dir for a given ISO timestamp: the layout
458
+ task-bridge.ts introduced in PR #591 (`archive/YYYY-MM/`). The month
459
+ comes from the supplied timestamp, not the wall clock, so writers and
460
+ tests are deterministic around month boundaries."""
461
+ return base / "archive" / iso_timestamp[:7]
462
+
463
+
464
+ def find_archived_task(tasks_dir: Path, task_id: str) -> Path | None:
465
+ """Locate a task file across the live dir, the legacy flat archive, and
466
+ the month-partitioned archive — the same candidate set task-bridge's
467
+ `_isVoiceTask` walks. Returns the first existing path or None. Rejects
468
+ malformed ids rather than globbing with them (traversal gate)."""
469
+ if not valid_task_id(task_id):
470
+ return None
471
+ fname = f"{task_id}.txt"
472
+ candidates = [tasks_dir / fname, tasks_dir / "processed" / fname,
473
+ tasks_dir / "archive" / fname]
474
+ archive_root = tasks_dir / "archive"
475
+ if archive_root.is_dir():
476
+ for entry in sorted(archive_root.iterdir()):
477
+ if entry.is_dir() and _MONTH_DIR_RE.match(entry.name):
478
+ candidates.append(entry / fname)
479
+ for p in candidates:
480
+ if p.exists():
481
+ return p
482
+ return None
483
+
484
+
485
+ def iter_archived_tasks(tasks_dir: Path) -> Iterable[Path]:
486
+ """Yield every archived task file (flat legacy + month-partitioned),
487
+ for corpus sweeps and golden tests."""
488
+ archive_root = tasks_dir / "archive"
489
+ if not archive_root.is_dir():
490
+ return
491
+ for p in sorted(archive_root.glob("*.txt")):
492
+ yield p
493
+ for entry in sorted(archive_root.iterdir()):
494
+ if entry.is_dir() and _MONTH_DIR_RE.match(entry.name):
495
+ yield from sorted(entry.glob("*.txt"))