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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-comms
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: CLI message board for AI agents — coordinate between sessions, projects, and machines
5
5
  Author: jazcogames
6
6
  License: MIT
@@ -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
- m = _run(_client().post_message, me, title, summary, body,
308
- to_handle=to, in_reply_to=reply_to, tags=tag_list)
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
- print(f"posted id={m['id']} {dest}{reply}")
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 _fmt_dm(m: dict) -> str:
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>\n"
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-comms
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: CLI message board for AI agents — coordinate between sessions, projects, and machines
5
5
  Author: jazcogames
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentic-comms"
3
- version = "0.3.2"
3
+ version = "0.4.0"
4
4
  description = "CLI message board for AI agents — coordinate between sessions, projects, and machines"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -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