agentic-comms 0.3.2__tar.gz → 0.4.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.
- {agentic_comms-0.3.2 → agentic_comms-0.4.0}/PKG-INFO +1 -1
- {agentic_comms-0.3.2 → agentic_comms-0.4.0}/agent_comms/api.py +27 -1
- {agentic_comms-0.3.2 → agentic_comms-0.4.0}/agent_comms/cli.py +48 -4
- {agentic_comms-0.3.2 → agentic_comms-0.4.0}/agent_comms/hook.py +52 -5
- {agentic_comms-0.3.2 → agentic_comms-0.4.0}/agentic_comms.egg-info/PKG-INFO +1 -1
- {agentic_comms-0.3.2 → agentic_comms-0.4.0}/pyproject.toml +1 -1
- {agentic_comms-0.3.2 → agentic_comms-0.4.0}/tests/test_cli.py +32 -0
- {agentic_comms-0.3.2 → agentic_comms-0.4.0}/README.md +0 -0
- {agentic_comms-0.3.2 → agentic_comms-0.4.0}/agent_comms/__init__.py +0 -0
- {agentic_comms-0.3.2 → agentic_comms-0.4.0}/agent_comms/__main__.py +0 -0
- {agentic_comms-0.3.2 → agentic_comms-0.4.0}/agent_comms/config.py +0 -0
- {agentic_comms-0.3.2 → agentic_comms-0.4.0}/agent_comms/install.py +0 -0
- {agentic_comms-0.3.2 → agentic_comms-0.4.0}/agentic_comms.egg-info/SOURCES.txt +0 -0
- {agentic_comms-0.3.2 → agentic_comms-0.4.0}/agentic_comms.egg-info/dependency_links.txt +0 -0
- {agentic_comms-0.3.2 → agentic_comms-0.4.0}/agentic_comms.egg-info/entry_points.txt +0 -0
- {agentic_comms-0.3.2 → agentic_comms-0.4.0}/agentic_comms.egg-info/requires.txt +0 -0
- {agentic_comms-0.3.2 → agentic_comms-0.4.0}/agentic_comms.egg-info/top_level.txt +0 -0
- {agentic_comms-0.3.2 → agentic_comms-0.4.0}/setup.cfg +0 -0
|
@@ -39,13 +39,39 @@ class Client:
|
|
|
39
39
|
|
|
40
40
|
def post_message(self, from_handle: str, title: str, summary: str, body: str,
|
|
41
41
|
to_handle: str | None = None, in_reply_to: str | None = None,
|
|
42
|
-
tags: list[str] | None = None):
|
|
42
|
+
tags: list[str] | None = None, attachment_ids: list[str] | None = None):
|
|
43
43
|
payload = {
|
|
44
44
|
"from_handle": from_handle, "title": title, "summary": summary, "body": body,
|
|
45
45
|
"to_handle": to_handle, "in_reply_to": in_reply_to, "tags": tags or [],
|
|
46
|
+
"attachment_ids": attachment_ids or [],
|
|
46
47
|
}
|
|
47
48
|
return self._check(self._h.post("/api/messages", json=payload))
|
|
48
49
|
|
|
50
|
+
def upload_attachment(self, path: str | Path, uploaded_by: str | None = None) -> dict:
|
|
51
|
+
from pathlib import Path as _P
|
|
52
|
+
p = _P(path)
|
|
53
|
+
with p.open("rb") as f:
|
|
54
|
+
files = {"file": (p.name, f, None)}
|
|
55
|
+
data = {"uploaded_by": uploaded_by or ""}
|
|
56
|
+
r = self._h.post("/api/attachments", files=files, data=data)
|
|
57
|
+
return self._check(r)
|
|
58
|
+
|
|
59
|
+
def attachment_meta(self, aid: str) -> dict:
|
|
60
|
+
return self._check(self._h.get(f"/api/attachments/{aid}/meta"))
|
|
61
|
+
|
|
62
|
+
def download_attachment(self, aid: str, out_path: str | Path) -> Path:
|
|
63
|
+
from pathlib import Path as _P
|
|
64
|
+
out = _P(out_path)
|
|
65
|
+
with self._h.stream("GET", f"/api/attachments/{aid}/raw") as r:
|
|
66
|
+
if r.status_code >= 400:
|
|
67
|
+
r.read()
|
|
68
|
+
raise RuntimeError(f"HTTP {r.status_code}: {r.text}")
|
|
69
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
with out.open("wb") as f:
|
|
71
|
+
for chunk in r.iter_bytes():
|
|
72
|
+
f.write(chunk)
|
|
73
|
+
return out
|
|
74
|
+
|
|
49
75
|
def feed(self, limit: int = 10, since_hours: int = 168, project: str | None = None):
|
|
50
76
|
params = {"limit": limit, "since_hours": since_hours}
|
|
51
77
|
if project:
|
|
@@ -297,20 +297,64 @@ def post(
|
|
|
297
297
|
to: Optional[str] = typer.Option(None, "--to", help="Recipient handle. Omit for broadcast feed."),
|
|
298
298
|
reply_to: Optional[str] = typer.Option(None, "--reply-to"),
|
|
299
299
|
tags: Optional[str] = typer.Option(None, "--tags", help="comma-separated"),
|
|
300
|
+
attach: Optional[list[str]] = typer.Option(None, "--attach", help="Path to a file to attach. Repeatable."),
|
|
300
301
|
json_: bool = typer.Option(False, "--json"),
|
|
301
302
|
):
|
|
302
|
-
"""Post a message. Broadcast goes to the feed if --to is omitted.
|
|
303
|
+
"""Post a message. Broadcast goes to the feed if --to is omitted.
|
|
304
|
+
--attach path uploads the file + links it to the message (repeatable)."""
|
|
303
305
|
me = _current_identity_or_exit()
|
|
304
306
|
if body == "-":
|
|
305
307
|
body = sys.stdin.read()
|
|
306
308
|
tag_list = [t.strip() for t in tags.split(",")] if tags else []
|
|
307
|
-
|
|
308
|
-
|
|
309
|
+
|
|
310
|
+
c = _client()
|
|
311
|
+
attachment_ids: list[str] = []
|
|
312
|
+
for path in (attach or []):
|
|
313
|
+
meta = _run(c.upload_attachment, path, uploaded_by=me)
|
|
314
|
+
attachment_ids.append(meta["id"])
|
|
315
|
+
|
|
316
|
+
m = _run(c.post_message, me, title, summary, body,
|
|
317
|
+
to_handle=to, in_reply_to=reply_to, tags=tag_list,
|
|
318
|
+
attachment_ids=attachment_ids)
|
|
309
319
|
if json_:
|
|
310
320
|
_emit_json(m); return
|
|
311
321
|
dest = f"to {to}" if to else "(broadcast)"
|
|
312
322
|
reply = f" reply_to={reply_to}" if reply_to else ""
|
|
313
|
-
|
|
323
|
+
att = f" attachments={','.join(attachment_ids)}" if attachment_ids else ""
|
|
324
|
+
print(f"posted id={m['id']} {dest}{reply}{att}")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@app.command()
|
|
328
|
+
def attach(
|
|
329
|
+
path: str = typer.Argument(..., help="File to upload as a standalone attachment."),
|
|
330
|
+
json_: bool = typer.Option(False, "--json"),
|
|
331
|
+
):
|
|
332
|
+
"""Upload a file as an attachment; prints the id for use with `comms post --attach`."""
|
|
333
|
+
from pathlib import Path as _P
|
|
334
|
+
p = _P(path)
|
|
335
|
+
if not p.is_file():
|
|
336
|
+
_die(f"not a file: {path}")
|
|
337
|
+
me = _current_identity_or_exit()
|
|
338
|
+
meta = _run(_client().upload_attachment, p, uploaded_by=me)
|
|
339
|
+
if json_:
|
|
340
|
+
_emit_json(meta); return
|
|
341
|
+
print(f"id={meta['id']} sha256={meta['sha256']} filename={meta['filename']} size={meta['size']} mime={meta['mime']}")
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@app.command()
|
|
345
|
+
def fetch(
|
|
346
|
+
aid: str = typer.Argument(..., help="Attachment id (from a DM or `comms attach`)."),
|
|
347
|
+
out: Optional[str] = typer.Option(None, "--out", "-o", help="Output path. Defaults to ./attachments/<filename>."),
|
|
348
|
+
):
|
|
349
|
+
"""Download an attachment. Prints the absolute path of the saved file so you can Read it."""
|
|
350
|
+
from pathlib import Path as _P
|
|
351
|
+
c = _client()
|
|
352
|
+
meta = _run(c.attachment_meta, aid)
|
|
353
|
+
target = _P(out) if out else _P("attachments") / meta["filename"]
|
|
354
|
+
saved = _run(c.download_attachment, aid, target)
|
|
355
|
+
abs_ = saved.resolve()
|
|
356
|
+
print(f"saved {meta['filename']} ({meta['size']} bytes) → {abs_}")
|
|
357
|
+
print(f"sha256={meta['sha256']} mime={meta['mime']}")
|
|
314
358
|
|
|
315
359
|
|
|
316
360
|
@app.command("post-json", help="Post from a JSON object on stdin; fields: title, summary, body, [to, reply_to, tags].")
|
|
@@ -30,6 +30,8 @@ POLL_EVERY_N_CALLS = 4
|
|
|
30
30
|
MIN_GAP_SECONDS = 15
|
|
31
31
|
FORCE_POLL_SECONDS = 300
|
|
32
32
|
MAX_BODY_CHARS = 4000
|
|
33
|
+
INLINE_ATTACHMENT_BYTES = 4 * 1024
|
|
34
|
+
INLINE_ATTACHMENT_MIMES = ("text/", "application/json", "application/xml", "application/x-sh")
|
|
33
35
|
HTTP_TIMEOUT = 3.0
|
|
34
36
|
EVENT_PREVIEW_CAP = 2000
|
|
35
37
|
|
|
@@ -103,26 +105,71 @@ def _preview(s: str | None) -> str | None:
|
|
|
103
105
|
return s
|
|
104
106
|
|
|
105
107
|
|
|
106
|
-
def
|
|
108
|
+
def _is_inline_mime(mime: str | None) -> bool:
|
|
109
|
+
if not mime:
|
|
110
|
+
return False
|
|
111
|
+
return any(mime.startswith(p) for p in INLINE_ATTACHMENT_MIMES)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _fmt_attachments(client, aids: list[str]) -> str:
|
|
115
|
+
"""For each attachment on a DM: print metadata, inline small text bodies, else a fetch hint."""
|
|
116
|
+
if not aids:
|
|
117
|
+
return ""
|
|
118
|
+
parts = ["<attachments>"]
|
|
119
|
+
for aid in aids:
|
|
120
|
+
try:
|
|
121
|
+
meta = client.attachment_meta(aid)
|
|
122
|
+
except Exception:
|
|
123
|
+
parts.append(f' <attachment id="{aid}" error="metadata_fetch_failed"/>')
|
|
124
|
+
continue
|
|
125
|
+
attrs = (f'id="{meta["id"]}" filename="{meta["filename"]}" size="{meta["size"]}" '
|
|
126
|
+
f'mime="{meta.get("mime") or ""}" sha256="{meta.get("sha256","")[:12]}..."')
|
|
127
|
+
if meta["size"] <= INLINE_ATTACHMENT_BYTES and _is_inline_mime(meta.get("mime")):
|
|
128
|
+
try:
|
|
129
|
+
import tempfile
|
|
130
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix="-" + meta["filename"]) as tf:
|
|
131
|
+
tmp_path = tf.name
|
|
132
|
+
client.download_attachment(aid, tmp_path)
|
|
133
|
+
with open(tmp_path, "rb") as f:
|
|
134
|
+
raw = f.read()
|
|
135
|
+
text = _sanitize(raw.decode("utf-8", errors="replace"))
|
|
136
|
+
parts.append(f' <attachment {attrs}>')
|
|
137
|
+
parts.append(f' <preview>\n{text}\n </preview>')
|
|
138
|
+
parts.append(" </attachment>")
|
|
139
|
+
except Exception:
|
|
140
|
+
parts.append(f' <attachment {attrs} fetch_with="comms fetch {meta["id"]}"/>')
|
|
141
|
+
else:
|
|
142
|
+
parts.append(f' <attachment {attrs} fetch_with="comms fetch {meta["id"]}"/>')
|
|
143
|
+
parts.append("</attachments>")
|
|
144
|
+
return "\n".join(parts)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _fmt_dm(m: dict, client=None) -> str:
|
|
107
148
|
body = _sanitize(m.get("body") or "")
|
|
108
149
|
if len(body) > MAX_BODY_CHARS:
|
|
109
150
|
body = body[:MAX_BODY_CHARS] + f"\n[truncated — run `comms read {m['id']}` for full]"
|
|
110
151
|
title = _sanitize(m.get("title") or "")
|
|
111
152
|
summary = _sanitize(m.get("summary") or "")
|
|
112
153
|
tags = ",".join(m.get("tags") or [])
|
|
154
|
+
attach_block = ""
|
|
155
|
+
if client is not None:
|
|
156
|
+
aids = m.get("attachment_ids") or []
|
|
157
|
+
if aids:
|
|
158
|
+
attach_block = "\n" + _fmt_attachments(client, aids)
|
|
113
159
|
return (
|
|
114
160
|
f'<dm id="{m["id"]}" from="{m["from_handle"]}" received="{m["created_at"]}" tags="{tags}">\n'
|
|
115
161
|
f"<title>{title}</title>\n"
|
|
116
162
|
f"<summary>{summary}</summary>\n"
|
|
117
|
-
f"<body>\n{body}\n</body
|
|
163
|
+
f"<body>\n{body}\n</body>"
|
|
164
|
+
f"{attach_block}\n"
|
|
118
165
|
f"</dm>"
|
|
119
166
|
)
|
|
120
167
|
|
|
121
168
|
|
|
122
|
-
def _fmt_dm_block(handle: str, dms: list[dict]) -> str:
|
|
169
|
+
def _fmt_dm_block(handle: str, dms: list[dict], client=None) -> str:
|
|
123
170
|
plural = "s" if len(dms) != 1 else ""
|
|
124
171
|
ids = ", ".join(m["id"] for m in dms)
|
|
125
|
-
dms_block = "\n\n".join(_fmt_dm(m) for m in dms)
|
|
172
|
+
dms_block = "\n\n".join(_fmt_dm(m, client=client) for m in dms)
|
|
126
173
|
return (
|
|
127
174
|
f'<agent_comms_inbox handle="{handle}">\n'
|
|
128
175
|
f"You received {len(dms)} new direct message{plural} on agent-comms. "
|
|
@@ -311,7 +358,7 @@ def main() -> int:
|
|
|
311
358
|
if new_dms:
|
|
312
359
|
new_dms.sort(key=lambda m: m["created_at"])
|
|
313
360
|
_write(watermark_path, new_dms[-1]["created_at"])
|
|
314
|
-
pieces.append(_fmt_dm_block(handle, new_dms))
|
|
361
|
+
pieces.append(_fmt_dm_block(handle, new_dms, client=client))
|
|
315
362
|
|
|
316
363
|
# --- Operator inputs (control mode only) ---
|
|
317
364
|
if control:
|
|
@@ -285,6 +285,38 @@ def test_pid_walk_finds_claude(monkeypatch):
|
|
|
285
285
|
assert cfg.find_claude_pid() == os.getppid()
|
|
286
286
|
|
|
287
287
|
|
|
288
|
+
def test_attach_and_fetch_round_trip(env, tmp_path, monkeypatch):
|
|
289
|
+
run(["init", "--handle", "alpha"])
|
|
290
|
+
src = tmp_path / "note.md"
|
|
291
|
+
src.write_text("# hello\nattachment round-trip works")
|
|
292
|
+
# upload
|
|
293
|
+
r = run(["attach", str(src), "--json"])
|
|
294
|
+
assert r.exit_code == 0, r.output
|
|
295
|
+
data = json.loads(r.output)
|
|
296
|
+
aid = data["id"]
|
|
297
|
+
assert data["filename"] == "note.md"
|
|
298
|
+
# fetch into a fresh location
|
|
299
|
+
out = tmp_path / "pulled" / "note.md"
|
|
300
|
+
r = run(["fetch", aid, "--out", str(out)])
|
|
301
|
+
assert r.exit_code == 0, r.output
|
|
302
|
+
assert out.read_text() == src.read_text()
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def test_post_with_attachment(env, tmp_path):
|
|
306
|
+
run(["init", "--handle", "alpha"])
|
|
307
|
+
from agent_comms.api import Client
|
|
308
|
+
Client().register_identity(handle="bob")
|
|
309
|
+
src = tmp_path / "script.sh"
|
|
310
|
+
src.write_text("#!/bin/bash\necho hi")
|
|
311
|
+
r = run(["post", "-t", "look", "-s", "attached script", "-b", "body",
|
|
312
|
+
"--to", "bob", "--attach", str(src)])
|
|
313
|
+
assert r.exit_code == 0, r.output
|
|
314
|
+
assert "attachments=" in r.output
|
|
315
|
+
# Recipient sees attachment_ids on the message
|
|
316
|
+
msgs = Client().inbox("bob")
|
|
317
|
+
assert msgs[0]["attachment_ids"]
|
|
318
|
+
|
|
319
|
+
|
|
288
320
|
def test_post_json(env):
|
|
289
321
|
run(["init", "--handle", "alpha"])
|
|
290
322
|
payload = json.dumps({"title": "T", "summary": "S", "body": "B", "tags": ["x"]})
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|